@noy-db/hub 0.1.0-pre.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/dist/aggregate/index.cjs +476 -0
- package/dist/aggregate/index.cjs.map +1 -0
- package/dist/aggregate/index.d.cts +38 -0
- package/dist/aggregate/index.d.ts +38 -0
- package/dist/aggregate/index.js +53 -0
- package/dist/aggregate/index.js.map +1 -0
- package/dist/blobs/index.cjs +1480 -0
- package/dist/blobs/index.cjs.map +1 -0
- package/dist/blobs/index.d.cts +45 -0
- package/dist/blobs/index.d.ts +45 -0
- package/dist/blobs/index.js +48 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/bundle/index.cjs +496 -0
- package/dist/bundle/index.cjs.map +1 -0
- package/dist/bundle/index.d.cts +7 -0
- package/dist/bundle/index.d.ts +7 -0
- package/dist/bundle/index.js +51 -0
- package/dist/bundle/index.js.map +1 -0
- package/dist/chunk-2QR2PQTT.js +217 -0
- package/dist/chunk-2QR2PQTT.js.map +1 -0
- package/dist/chunk-72UIIX3E.js +1109 -0
- package/dist/chunk-72UIIX3E.js.map +1 -0
- package/dist/chunk-A4NFZKRW.js +722 -0
- package/dist/chunk-A4NFZKRW.js.map +1 -0
- package/dist/chunk-AOYCZP2H.js +793 -0
- package/dist/chunk-AOYCZP2H.js.map +1 -0
- package/dist/chunk-CIMZBAZB.js +72 -0
- package/dist/chunk-CIMZBAZB.js.map +1 -0
- package/dist/chunk-E3AGCGJ4.js +160 -0
- package/dist/chunk-E3AGCGJ4.js.map +1 -0
- package/dist/chunk-EKX3YVCI.js +97 -0
- package/dist/chunk-EKX3YVCI.js.map +1 -0
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/chunk-EMMRIE3C.js +72 -0
- package/dist/chunk-EMMRIE3C.js.map +1 -0
- package/dist/chunk-EUNIORPU.js +680 -0
- package/dist/chunk-EUNIORPU.js.map +1 -0
- package/dist/chunk-FZU343FL.js +32 -0
- package/dist/chunk-FZU343FL.js.map +1 -0
- package/dist/chunk-GHGXG53C.js +795 -0
- package/dist/chunk-GHGXG53C.js.map +1 -0
- package/dist/chunk-GKA4BGJN.js +79 -0
- package/dist/chunk-GKA4BGJN.js.map +1 -0
- package/dist/chunk-HG2OWBLX.js +430 -0
- package/dist/chunk-HG2OWBLX.js.map +1 -0
- package/dist/chunk-IGAROPKM.js +34 -0
- package/dist/chunk-IGAROPKM.js.map +1 -0
- package/dist/chunk-J66GRPNH.js +111 -0
- package/dist/chunk-J66GRPNH.js.map +1 -0
- package/dist/chunk-LVMMDXFT.js +275 -0
- package/dist/chunk-LVMMDXFT.js.map +1 -0
- package/dist/chunk-M5INGEFC.js +84 -0
- package/dist/chunk-M5INGEFC.js.map +1 -0
- package/dist/chunk-NBYQNDXA.js +557 -0
- package/dist/chunk-NBYQNDXA.js.map +1 -0
- package/dist/chunk-NPC4LFV5.js +132 -0
- package/dist/chunk-NPC4LFV5.js.map +1 -0
- package/dist/chunk-NSWHB5VQ.js +1285 -0
- package/dist/chunk-NSWHB5VQ.js.map +1 -0
- package/dist/chunk-OLM4LA6K.js +392 -0
- package/dist/chunk-OLM4LA6K.js.map +1 -0
- package/dist/chunk-UAFBZWFB.js +155 -0
- package/dist/chunk-UAFBZWFB.js.map +1 -0
- package/dist/chunk-UF3BUNQZ.js +1 -0
- package/dist/chunk-UF3BUNQZ.js.map +1 -0
- package/dist/chunk-UMMAVAYW.js +17 -0
- package/dist/chunk-UMMAVAYW.js.map +1 -0
- package/dist/chunk-UPY7WLBH.js +381 -0
- package/dist/chunk-UPY7WLBH.js.map +1 -0
- package/dist/chunk-W63BWEJH.js +311 -0
- package/dist/chunk-W63BWEJH.js.map +1 -0
- package/dist/chunk-WIGI5OJK.js +90 -0
- package/dist/chunk-WIGI5OJK.js.map +1 -0
- package/dist/chunk-XNL2TKKR.js +490 -0
- package/dist/chunk-XNL2TKKR.js.map +1 -0
- package/dist/chunk-XWNUJPIS.js +367 -0
- package/dist/chunk-XWNUJPIS.js.map +1 -0
- package/dist/chunk-YWKJZZGV.js +715 -0
- package/dist/chunk-YWKJZZGV.js.map +1 -0
- package/dist/consent/index.cjs +204 -0
- package/dist/consent/index.cjs.map +1 -0
- package/dist/consent/index.d.cts +24 -0
- package/dist/consent/index.d.ts +24 -0
- package/dist/consent/index.js +23 -0
- package/dist/consent/index.js.map +1 -0
- package/dist/crdt/index.cjs +152 -0
- package/dist/crdt/index.cjs.map +1 -0
- package/dist/crdt/index.d.cts +30 -0
- package/dist/crdt/index.d.ts +30 -0
- package/dist/crdt/index.js +24 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/crypto-6PNIHP7W.js +44 -0
- package/dist/crypto-6PNIHP7W.js.map +1 -0
- package/dist/delegation-WVIVMF73.js +17 -0
- package/dist/delegation-WVIVMF73.js.map +1 -0
- package/dist/dev-unlock-D4xB0_gs.d.cts +263 -0
- package/dist/dev-unlock-Dz8GEbd3.d.ts +263 -0
- package/dist/hash--EflSV65.d.cts +63 -0
- package/dist/hash-CRdXYnv3.d.ts +63 -0
- package/dist/history/index.cjs +1215 -0
- package/dist/history/index.cjs.map +1 -0
- package/dist/history/index.d.cts +62 -0
- package/dist/history/index.d.ts +62 -0
- package/dist/history/index.js +79 -0
- package/dist/history/index.js.map +1 -0
- package/dist/i18n/index.cjs +840 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +38 -0
- package/dist/i18n/index.d.ts +38 -0
- package/dist/i18n/index.js +68 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index-CD1VnONm.d.cts +415 -0
- package/dist/index-CLRxPs-W.d.cts +1960 -0
- package/dist/index-CUi9wfss.d.ts +415 -0
- package/dist/index-DtV93TMP.d.ts +1960 -0
- package/dist/index.cjs +17387 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +565 -0
- package/dist/index.d.ts +565 -0
- package/dist/index.js +7525 -0
- package/dist/index.js.map +1 -0
- package/dist/indexing/index.cjs +736 -0
- package/dist/indexing/index.cjs.map +1 -0
- package/dist/indexing/index.d.cts +36 -0
- package/dist/indexing/index.d.ts +36 -0
- package/dist/indexing/index.js +77 -0
- package/dist/indexing/index.js.map +1 -0
- package/dist/lazy-builder-BwEoBQZ9.d.ts +304 -0
- package/dist/lazy-builder-CZVLKh0Z.d.cts +304 -0
- package/dist/ledger-HBBH2NPZ.js +33 -0
- package/dist/ledger-HBBH2NPZ.js.map +1 -0
- package/dist/mime-magic-CBBSOkjm.d.cts +50 -0
- package/dist/mime-magic-CBBSOkjm.d.ts +50 -0
- package/dist/periods/index.cjs +1035 -0
- package/dist/periods/index.cjs.map +1 -0
- package/dist/periods/index.d.cts +21 -0
- package/dist/periods/index.d.ts +21 -0
- package/dist/periods/index.js +25 -0
- package/dist/periods/index.js.map +1 -0
- package/dist/predicate-SBHmi6D0.d.cts +161 -0
- package/dist/predicate-SBHmi6D0.d.ts +161 -0
- package/dist/public-envelope-TLQA6REO.js +31 -0
- package/dist/public-envelope-TLQA6REO.js.map +1 -0
- package/dist/query/index.cjs +1999 -0
- package/dist/query/index.cjs.map +1 -0
- package/dist/query/index.d.cts +3 -0
- package/dist/query/index.d.ts +3 -0
- package/dist/query/index.js +73 -0
- package/dist/query/index.js.map +1 -0
- package/dist/session/index.cjs +495 -0
- package/dist/session/index.cjs.map +1 -0
- package/dist/session/index.d.cts +45 -0
- package/dist/session/index.d.ts +45 -0
- package/dist/session/index.js +51 -0
- package/dist/session/index.js.map +1 -0
- package/dist/shadow/index.cjs +133 -0
- package/dist/shadow/index.cjs.map +1 -0
- package/dist/shadow/index.d.cts +16 -0
- package/dist/shadow/index.d.ts +16 -0
- package/dist/shadow/index.js +20 -0
- package/dist/shadow/index.js.map +1 -0
- package/dist/store/index.cjs +1083 -0
- package/dist/store/index.cjs.map +1 -0
- package/dist/store/index.d.cts +491 -0
- package/dist/store/index.d.ts +491 -0
- package/dist/store/index.js +37 -0
- package/dist/store/index.js.map +1 -0
- package/dist/strategy-BSxFXGzb.d.cts +110 -0
- package/dist/strategy-BSxFXGzb.d.ts +110 -0
- package/dist/strategy-D-SrOLCl.d.cts +548 -0
- package/dist/strategy-D-SrOLCl.d.ts +548 -0
- package/dist/sync/index.cjs +1062 -0
- package/dist/sync/index.cjs.map +1 -0
- package/dist/sync/index.d.cts +42 -0
- package/dist/sync/index.d.ts +42 -0
- package/dist/sync/index.js +28 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/team/index.cjs +2606 -0
- package/dist/team/index.cjs.map +1 -0
- package/dist/team/index.d.cts +117 -0
- package/dist/team/index.d.ts +117 -0
- package/dist/team/index.js +106 -0
- package/dist/team/index.js.map +1 -0
- package/dist/tx/index.cjs +212 -0
- package/dist/tx/index.cjs.map +1 -0
- package/dist/tx/index.d.cts +20 -0
- package/dist/tx/index.d.ts +20 -0
- package/dist/tx/index.js +20 -0
- package/dist/tx/index.js.map +1 -0
- package/dist/types-DSFLtbKg.d.ts +9702 -0
- package/dist/types-zwwMOqkg.d.cts +9702 -0
- package/dist/ulid-COREQ2RQ.js +9 -0
- package/dist/ulid-COREQ2RQ.js.map +1 -0
- package/dist/util/index.cjs +230 -0
- package/dist/util/index.cjs.map +1 -0
- package/dist/util/index.d.cts +77 -0
- package/dist/util/index.d.ts +77 -0
- package/dist/util/index.js +190 -0
- package/dist/util/index.js.map +1 -0
- package/package.json +244 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/crdt/crdt.ts"],"sourcesContent":["/**\n * CRDT state types, merge logic, and build helpers.\n * per-collection CRDT mode: 'lww-map' | 'rga' | 'yjs'\n *\n * The encrypted envelope wraps the CRDT state (not the resolved snapshot).\n * Adapters only ever see ciphertext. `collection.get(id)` returns the\n * resolved snapshot; `collection.getRaw(id)` returns the full CRDT state.\n */\n\n// ─── Mode ─────────────────────────────────────────────────────────────\n\n/** Per-collection CRDT mode. */\nexport type CrdtMode = 'lww-map' | 'rga' | 'yjs'\n\n// ─── State shapes ─────────────────────────────────────────────────────\n\n/**\n * Per-field last-write-wins registers.\n * Each field carries its latest value and the ISO timestamp of the last write.\n * Merge: for each field, keep the entry with the lexicographically higher `ts`.\n */\nexport interface LwwMapState {\n readonly _crdt: 'lww-map'\n readonly fields: Record<string, { readonly v: unknown; readonly ts: string }>\n}\n\n/**\n * Simplified Replicated Growable Array.\n * Items are assigned stable NID (noy-db id) strings on first insertion.\n * Deleted items are tracked as tombstones so concurrent removals commute.\n *\n * The resolved snapshot is the ordered list of non-tombstoned `v` values.\n */\nexport interface RgaState {\n readonly _crdt: 'rga'\n readonly items: ReadonlyArray<{ readonly nid: string; readonly v: unknown }>\n readonly tombstones: readonly string[]\n}\n\n/**\n * Yjs binary state marker. `update` is base64(Y.encodeStateAsUpdate()).\n * Core stores and retrieves the blob opaquely. `@noy-db/yjs` is responsible\n * for encoding, decoding, and merging via `Y.mergeUpdates`.\n * Core falls back to last-write-wins (higher `_v`) for conflict resolution.\n */\nexport interface YjsState {\n readonly _crdt: 'yjs'\n /** base64-encoded Y.encodeStateAsUpdate() bytes. */\n readonly update: string\n}\n\nexport type CrdtState = LwwMapState | RgaState | YjsState\n\n// ─── Snapshot resolution ──────────────────────────────────────────────\n\n/**\n * Resolve a CRDT state into the end-user record snapshot.\n *\n * - `lww-map` → `Record<string, unknown>` (field values extracted from registers)\n * - `rga` → `unknown[]` (non-tombstoned items in insertion order)\n * - `yjs` → `string` (base64 update blob; use @noy-db/yjs for a Y.Doc)\n */\nexport function resolveCrdtSnapshot(state: CrdtState): unknown {\n switch (state._crdt) {\n case 'lww-map': {\n const result: Record<string, unknown> = {}\n for (const [field, reg] of Object.entries(state.fields)) {\n result[field] = reg.v\n }\n return result\n }\n case 'rga': {\n const dead = new Set(state.tombstones)\n return state.items.filter(i => !dead.has(i.nid)).map(i => i.v)\n }\n case 'yjs':\n return state.update\n }\n}\n\n// ─── CRDT merge ───────────────────────────────────────────────────────\n\n/**\n * Merge two CRDT states produced by concurrent writes.\n * Called by the collection-level conflict resolver registered with SyncEngine.\n *\n * For `yjs`: core cannot merge Yjs without importing the `yjs` package.\n * The caller must handle that case by falling back to the higher-`_v` envelope.\n */\nexport function mergeCrdtStates(a: CrdtState, b: CrdtState): CrdtState {\n // Mismatched modes shouldn't happen in practice — same collection, same schema.\n if (a._crdt !== b._crdt) return a\n\n switch (a._crdt) {\n case 'lww-map':\n return mergeLwwMap(a, b as LwwMapState)\n case 'rga':\n return mergeRga(a, b as RgaState)\n case 'yjs':\n // Signal to caller that Yjs merge is needed externally\n return a\n }\n}\n\nfunction mergeLwwMap(a: LwwMapState, b: LwwMapState): LwwMapState {\n const merged: Record<string, { v: unknown; ts: string }> = {}\n const allFields = new Set([...Object.keys(a.fields), ...Object.keys(b.fields)])\n for (const field of allFields) {\n const fa = a.fields[field]\n const fb = b.fields[field]\n if (!fa) { merged[field] = fb! }\n else if (!fb) { merged[field] = fa }\n else { merged[field] = fa.ts >= fb.ts ? fa : fb }\n }\n return { _crdt: 'lww-map', fields: merged }\n}\n\nfunction mergeRga(a: RgaState, b: RgaState): RgaState {\n // Union tombstones from both sides\n const allTombstones = new Set([...a.tombstones, ...b.tombstones])\n // Union items by nid: start with a's ordering, append b-only items\n const seenNids = new Set(a.items.map(i => i.nid))\n const merged: Array<{ nid: string; v: unknown }> = [\n ...a.items,\n ...b.items.filter(i => !seenNids.has(i.nid)),\n ]\n return { _crdt: 'rga', items: merged, tombstones: [...allTombstones] }\n}\n\n// ─── Build helpers ────────────────────────────────────────────────────\n\n/**\n * Build (or update) an lww-map state from a new record.\n *\n * All fields in the new record win at timestamp `now`.\n * Fields present in the existing state but absent from the new record\n * are preserved (they were written by another device).\n */\nexport function buildLwwMapState(\n record: Record<string, unknown>,\n existing: LwwMapState | undefined,\n now: string,\n): LwwMapState {\n const fields: Record<string, { v: unknown; ts: string }> = {}\n\n // New record fields all get the current timestamp — this device wins for these\n for (const [field, value] of Object.entries(record)) {\n fields[field] = { v: value, ts: now }\n }\n\n // Preserve fields from the existing state that aren't in the new record\n if (existing) {\n for (const [field, reg] of Object.entries(existing.fields)) {\n if (!(field in fields)) {\n fields[field] = reg\n }\n }\n }\n\n return { _crdt: 'lww-map', fields }\n}\n\n/**\n * Build (or update) an RGA state from a new array.\n *\n * Existing items are matched to new elements by deep-equality of their `v`.\n * Unmatched existing items are tombstoned. New elements that have no existing\n * match get a fresh NID via `generateNid()`.\n */\nexport function buildRgaState(\n arr: unknown[],\n existing: RgaState | undefined,\n generateNid: () => string,\n): RgaState {\n // Build an index from JSON(v) → existing item so we can match by value\n const existingByValue = new Map<string, { nid: string; v: unknown }>()\n if (existing) {\n for (const item of existing.items) {\n // Only add first occurrence per value to avoid double-matching\n const key = JSON.stringify(item.v)\n if (!existingByValue.has(key)) existingByValue.set(key, item)\n }\n }\n\n const usedNids = new Set<string>()\n const newItems: Array<{ nid: string; v: unknown }> = []\n\n for (const el of arr) {\n const key = JSON.stringify(el)\n const match = existingByValue.get(key)\n if (match && !usedNids.has(match.nid)) {\n // Reuse existing NID to preserve cross-device identity\n newItems.push(match)\n usedNids.add(match.nid)\n } else {\n // New element — assign a fresh NID\n const nid = generateNid()\n newItems.push({ nid, v: el })\n usedNids.add(nid)\n }\n }\n\n // Elements in the existing state that aren't in the new array → tombstone.\n // Tombstoned items are kept in the items array to preserve ordering for\n // cross-device merge — the resolved snapshot filters them out.\n const tombstones: string[] = existing ? [...existing.tombstones] : []\n const extraItems: Array<{ nid: string; v: unknown }> = []\n if (existing) {\n for (const item of existing.items) {\n if (!usedNids.has(item.nid)) {\n if (!tombstones.includes(item.nid)) tombstones.push(item.nid)\n extraItems.push(item) // retain in items for ordering\n }\n }\n }\n\n // Final items: live items in new order, then tombstoned extras at the end\n const items = [...newItems, ...extraItems]\n\n return { _crdt: 'rga', items, tombstones }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DecryptionError,
|
|
3
|
+
InvalidKeyError,
|
|
4
|
+
TamperedError
|
|
5
|
+
} from "./chunk-NBYQNDXA.js";
|
|
6
|
+
|
|
7
|
+
// src/crypto.ts
|
|
8
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
9
|
+
var SALT_BYTES = 32;
|
|
10
|
+
var IV_BYTES = 12;
|
|
11
|
+
var KEY_BITS = 256;
|
|
12
|
+
var subtle = globalThis.crypto.subtle;
|
|
13
|
+
async function deriveKey(passphrase, salt) {
|
|
14
|
+
const keyMaterial = await subtle.importKey(
|
|
15
|
+
"raw",
|
|
16
|
+
new TextEncoder().encode(passphrase),
|
|
17
|
+
"PBKDF2",
|
|
18
|
+
false,
|
|
19
|
+
["deriveKey"]
|
|
20
|
+
);
|
|
21
|
+
return subtle.deriveKey(
|
|
22
|
+
{
|
|
23
|
+
name: "PBKDF2",
|
|
24
|
+
salt,
|
|
25
|
+
iterations: PBKDF2_ITERATIONS,
|
|
26
|
+
hash: "SHA-256"
|
|
27
|
+
},
|
|
28
|
+
keyMaterial,
|
|
29
|
+
{ name: "AES-KW", length: KEY_BITS },
|
|
30
|
+
false,
|
|
31
|
+
["wrapKey", "unwrapKey"]
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
async function generateDEK() {
|
|
35
|
+
return subtle.generateKey(
|
|
36
|
+
{ name: "AES-GCM", length: KEY_BITS },
|
|
37
|
+
true,
|
|
38
|
+
// extractable — needed for AES-KW wrapping
|
|
39
|
+
["encrypt", "decrypt"]
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
async function wrapKey(dek, kek) {
|
|
43
|
+
const wrapped = await subtle.wrapKey("raw", dek, kek, "AES-KW");
|
|
44
|
+
return bufferToBase64(wrapped);
|
|
45
|
+
}
|
|
46
|
+
async function unwrapKey(wrappedBase64, kek) {
|
|
47
|
+
try {
|
|
48
|
+
return await subtle.unwrapKey(
|
|
49
|
+
"raw",
|
|
50
|
+
base64ToBuffer(wrappedBase64),
|
|
51
|
+
kek,
|
|
52
|
+
"AES-KW",
|
|
53
|
+
{ name: "AES-GCM", length: KEY_BITS },
|
|
54
|
+
true,
|
|
55
|
+
["encrypt", "decrypt"]
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new InvalidKeyError();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function encrypt(plaintext, dek) {
|
|
62
|
+
const iv = generateIV();
|
|
63
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
64
|
+
const ciphertext = await subtle.encrypt(
|
|
65
|
+
{ name: "AES-GCM", iv },
|
|
66
|
+
dek,
|
|
67
|
+
encoded
|
|
68
|
+
);
|
|
69
|
+
return {
|
|
70
|
+
iv: bufferToBase64(iv),
|
|
71
|
+
data: bufferToBase64(ciphertext)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function decrypt(ivBase64, dataBase64, dek) {
|
|
75
|
+
const iv = base64ToBuffer(ivBase64);
|
|
76
|
+
const ciphertext = base64ToBuffer(dataBase64);
|
|
77
|
+
try {
|
|
78
|
+
const plaintext = await subtle.decrypt(
|
|
79
|
+
{ name: "AES-GCM", iv },
|
|
80
|
+
dek,
|
|
81
|
+
ciphertext
|
|
82
|
+
);
|
|
83
|
+
return new TextDecoder().decode(plaintext);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof Error && err.name === "OperationError") {
|
|
86
|
+
throw new TamperedError();
|
|
87
|
+
}
|
|
88
|
+
throw new DecryptionError(
|
|
89
|
+
err instanceof Error ? err.message : "Decryption failed"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function encryptBytes(data, dek) {
|
|
94
|
+
const iv = generateIV();
|
|
95
|
+
const ciphertext = await subtle.encrypt(
|
|
96
|
+
{ name: "AES-GCM", iv },
|
|
97
|
+
dek,
|
|
98
|
+
data
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
iv: bufferToBase64(iv),
|
|
102
|
+
data: bufferToBase64(ciphertext)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function decryptBytes(ivBase64, dataBase64, dek) {
|
|
106
|
+
const iv = base64ToBuffer(ivBase64);
|
|
107
|
+
const ciphertext = base64ToBuffer(dataBase64);
|
|
108
|
+
try {
|
|
109
|
+
const plaintext = await subtle.decrypt(
|
|
110
|
+
{ name: "AES-GCM", iv },
|
|
111
|
+
dek,
|
|
112
|
+
ciphertext
|
|
113
|
+
);
|
|
114
|
+
return new Uint8Array(plaintext);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err instanceof Error && err.name === "OperationError") {
|
|
117
|
+
throw new TamperedError();
|
|
118
|
+
}
|
|
119
|
+
throw new DecryptionError(
|
|
120
|
+
err instanceof Error ? err.message : "Decryption failed"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function sha256Hex(data) {
|
|
125
|
+
const hash = await subtle.digest("SHA-256", data);
|
|
126
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
127
|
+
}
|
|
128
|
+
async function hmacSha256Hex(key, data) {
|
|
129
|
+
const rawKey = await subtle.exportKey("raw", key);
|
|
130
|
+
const hmacKey = await subtle.importKey(
|
|
131
|
+
"raw",
|
|
132
|
+
rawKey,
|
|
133
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
134
|
+
false,
|
|
135
|
+
["sign"]
|
|
136
|
+
);
|
|
137
|
+
const sig = await subtle.sign("HMAC", hmacKey, data);
|
|
138
|
+
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
139
|
+
}
|
|
140
|
+
async function encryptBytesWithAAD(data, dek, aad) {
|
|
141
|
+
const iv = generateIV();
|
|
142
|
+
const ciphertext = await subtle.encrypt(
|
|
143
|
+
{
|
|
144
|
+
name: "AES-GCM",
|
|
145
|
+
iv,
|
|
146
|
+
additionalData: aad
|
|
147
|
+
},
|
|
148
|
+
dek,
|
|
149
|
+
data
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
iv: bufferToBase64(iv),
|
|
153
|
+
data: bufferToBase64(ciphertext)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function decryptBytesWithAAD(ivBase64, dataBase64, dek, aad) {
|
|
157
|
+
const iv = base64ToBuffer(ivBase64);
|
|
158
|
+
const ciphertext = base64ToBuffer(dataBase64);
|
|
159
|
+
try {
|
|
160
|
+
const plaintext = await subtle.decrypt(
|
|
161
|
+
{
|
|
162
|
+
name: "AES-GCM",
|
|
163
|
+
iv,
|
|
164
|
+
additionalData: aad
|
|
165
|
+
},
|
|
166
|
+
dek,
|
|
167
|
+
ciphertext
|
|
168
|
+
);
|
|
169
|
+
return new Uint8Array(plaintext);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof Error && err.name === "OperationError") {
|
|
172
|
+
throw new TamperedError();
|
|
173
|
+
}
|
|
174
|
+
throw new DecryptionError(
|
|
175
|
+
err instanceof Error ? err.message : "Decryption failed"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function derivePresenceKey(dek, collectionName) {
|
|
180
|
+
const rawDek = await subtle.exportKey("raw", dek);
|
|
181
|
+
const hkdfKey = await subtle.importKey(
|
|
182
|
+
"raw",
|
|
183
|
+
rawDek,
|
|
184
|
+
"HKDF",
|
|
185
|
+
false,
|
|
186
|
+
["deriveBits"]
|
|
187
|
+
);
|
|
188
|
+
const salt = new TextEncoder().encode("noydb-presence");
|
|
189
|
+
const info = new TextEncoder().encode(collectionName);
|
|
190
|
+
const bits = await subtle.deriveBits(
|
|
191
|
+
{ name: "HKDF", hash: "SHA-256", salt, info },
|
|
192
|
+
hkdfKey,
|
|
193
|
+
KEY_BITS
|
|
194
|
+
);
|
|
195
|
+
return subtle.importKey(
|
|
196
|
+
"raw",
|
|
197
|
+
bits,
|
|
198
|
+
{ name: "AES-GCM", length: KEY_BITS },
|
|
199
|
+
false,
|
|
200
|
+
["encrypt", "decrypt"]
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
async function deriveDeterministicIV(dek, context, plaintext) {
|
|
204
|
+
const rawDek = await subtle.exportKey("raw", dek);
|
|
205
|
+
const hkdfKey = await subtle.importKey("raw", rawDek, "HKDF", false, ["deriveBits"]);
|
|
206
|
+
const salt = new TextEncoder().encode("noydb-deterministic-v1");
|
|
207
|
+
const info = new TextEncoder().encode(`${context}\0${plaintext}`);
|
|
208
|
+
const bits = await subtle.deriveBits(
|
|
209
|
+
{ name: "HKDF", hash: "SHA-256", salt, info },
|
|
210
|
+
hkdfKey,
|
|
211
|
+
IV_BYTES * 8
|
|
212
|
+
);
|
|
213
|
+
return new Uint8Array(bits);
|
|
214
|
+
}
|
|
215
|
+
async function encryptDeterministic(plaintext, dek, context) {
|
|
216
|
+
const iv = await deriveDeterministicIV(dek, context, plaintext);
|
|
217
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
218
|
+
const ciphertext = await subtle.encrypt(
|
|
219
|
+
{ name: "AES-GCM", iv },
|
|
220
|
+
dek,
|
|
221
|
+
encoded
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
iv: bufferToBase64(iv),
|
|
225
|
+
data: bufferToBase64(ciphertext)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function decryptDeterministic(ivBase64, dataBase64, dek) {
|
|
229
|
+
return decrypt(ivBase64, dataBase64, dek);
|
|
230
|
+
}
|
|
231
|
+
function generateIV() {
|
|
232
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
233
|
+
}
|
|
234
|
+
function generateSalt() {
|
|
235
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
236
|
+
}
|
|
237
|
+
function bufferToBase64(buffer) {
|
|
238
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
239
|
+
let binary = "";
|
|
240
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
241
|
+
binary += String.fromCharCode(bytes[i]);
|
|
242
|
+
}
|
|
243
|
+
return btoa(binary);
|
|
244
|
+
}
|
|
245
|
+
function base64ToBuffer(base64) {
|
|
246
|
+
const binary = atob(base64);
|
|
247
|
+
const bytes = new Uint8Array(binary.length);
|
|
248
|
+
for (let i = 0; i < binary.length; i++) {
|
|
249
|
+
bytes[i] = binary.charCodeAt(i);
|
|
250
|
+
}
|
|
251
|
+
return bytes;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
deriveKey,
|
|
256
|
+
generateDEK,
|
|
257
|
+
wrapKey,
|
|
258
|
+
unwrapKey,
|
|
259
|
+
encrypt,
|
|
260
|
+
decrypt,
|
|
261
|
+
encryptBytes,
|
|
262
|
+
decryptBytes,
|
|
263
|
+
sha256Hex,
|
|
264
|
+
hmacSha256Hex,
|
|
265
|
+
encryptBytesWithAAD,
|
|
266
|
+
decryptBytesWithAAD,
|
|
267
|
+
derivePresenceKey,
|
|
268
|
+
encryptDeterministic,
|
|
269
|
+
decryptDeterministic,
|
|
270
|
+
generateIV,
|
|
271
|
+
generateSalt,
|
|
272
|
+
bufferToBase64,
|
|
273
|
+
base64ToBuffer
|
|
274
|
+
};
|
|
275
|
+
//# sourceMappingURL=chunk-LVMMDXFT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Cryptographic primitives — thin wrappers around the Web Crypto API.\n *\n * ## Design principle\n *\n * **Zero npm crypto dependencies.** Every operation uses `globalThis.crypto.subtle`,\n * which is available natively in Node.js ≥ 18, all modern browsers, and\n * Deno/Bun. This avoids supply-chain risk from third-party crypto packages and\n * ensures the library stays auditable.\n *\n * ## Algorithms\n *\n * | Use case | Algorithm | Parameters |\n * |----------|-----------|------------|\n * | Key derivation | PBKDF2-SHA256 | 600,000 iterations, 32-byte salt |\n * | Record encryption | AES-256-GCM | 12-byte random IV per operation |\n * | DEK wrapping | AES-KW (RFC 3394) | 256-bit KEK |\n * | Binary encrypt | AES-256-GCM | same as record encryption |\n * | Integrity | HMAC-SHA256 | for presence channels |\n * | Content hash | SHA-256 | for ledger and bundle integrity |\n *\n * ## Key lifecycle\n *\n * ```\n * passphrase + salt\n * └─► deriveKey() → KEK (CryptoKey, extractable: false)\n * └─► wrapKey() → wrapped DEK bytes [stored in keyring]\n * └─► unwrapKey() → DEK (CryptoKey) [memory only during session]\n * └─► encrypt() / decrypt() → ciphertext / plaintext\n * ```\n *\n * IVs are generated fresh by {@link generateIV} on every encrypt call.\n * Reusing an IV with the same key would break GCM's authentication guarantee —\n * this function should be the only place IVs are produced.\n *\n * @module\n */\n\nimport { DecryptionError, InvalidKeyError, TamperedError } from './errors.js'\n\nconst PBKDF2_ITERATIONS = 600_000\nconst SALT_BYTES = 32\nconst IV_BYTES = 12\nconst KEY_BITS = 256\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Key Derivation ────────────────────────────────────────────────────\n\n/** Derive a KEK from a passphrase and salt using PBKDF2-SHA256. */\nexport async function deriveKey(\n passphrase: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const keyMaterial = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(passphrase),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n keyMaterial,\n { name: 'AES-KW', length: KEY_BITS },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// ─── DEK Generation ────────────────────────────────────────────────────\n\n/** Generate a random AES-256-GCM data encryption key. */\nexport async function generateDEK(): Promise<CryptoKey> {\n return subtle.generateKey(\n { name: 'AES-GCM', length: KEY_BITS },\n true, // extractable — needed for AES-KW wrapping\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Key Wrapping ──────────────────────────────────────────────────────\n\n/** Wrap (encrypt) a DEK with a KEK using AES-KW. Returns base64 string. */\nexport async function wrapKey(dek: CryptoKey, kek: CryptoKey): Promise<string> {\n const wrapped = await subtle.wrapKey('raw', dek, kek, 'AES-KW')\n return bufferToBase64(wrapped)\n}\n\n/** Unwrap (decrypt) a DEK from base64 string using a KEK. */\nexport async function unwrapKey(\n wrappedBase64: string,\n kek: CryptoKey,\n): Promise<CryptoKey> {\n try {\n return await subtle.unwrapKey(\n 'raw',\n base64ToBuffer(wrappedBase64) as BufferSource,\n kek,\n 'AES-KW',\n { name: 'AES-GCM', length: KEY_BITS },\n true,\n ['encrypt', 'decrypt'],\n )\n } catch {\n throw new InvalidKeyError()\n }\n}\n\n// ─── Encrypt / Decrypt ─────────────────────────────────────────────────\n\nexport interface EncryptResult {\n iv: string // base64\n data: string // base64\n}\n\n/** Encrypt plaintext JSON string with AES-256-GCM. Fresh IV per call. */\nexport async function encrypt(\n plaintext: string,\n dek: CryptoKey,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const encoded = new TextEncoder().encode(plaintext)\n\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n encoded,\n )\n\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/** Decrypt AES-256-GCM ciphertext. Throws on wrong key or tampered data. */\nexport async function decrypt(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<string> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n\n try {\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n ciphertext as BufferSource,\n )\n return new TextDecoder().decode(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n// ─── Binary Encrypt / Decrypt ────────\n\n/**\n * Encrypt raw bytes with AES-256-GCM using a fresh random IV.\n * Used by the attachment store so binary blobs avoid double base64 encoding\n * (the existing `encrypt()` function calls `TextEncoder` on a string — here\n * we pass the `Uint8Array` directly to `subtle.encrypt`).\n */\nexport async function encryptBytes(\n data: Uint8Array,\n dek: CryptoKey,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n data as unknown as BufferSource,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext back to raw bytes.\n * Counterpart to `encryptBytes`. Throws `TamperedError` on auth-tag failure.\n */\nexport async function decryptBytes(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<Uint8Array> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n try {\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n ciphertext as BufferSource,\n )\n return new Uint8Array(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n/**\n * SHA-256 hex digest of raw bytes. Used to derive content-addressed\n * eTags for blob deduplication. Computed on plaintext bytes\n * before compression and encryption so the eTag identifies content, not\n * ciphertext, and survives re-encryption (key rotation, re-upload).\n */\nexport async function sha256Hex(data: Uint8Array): Promise<string> {\n const hash = await subtle.digest('SHA-256', data as unknown as BufferSource)\n return Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\n// ─── HMAC-SHA-256 ─────────────────────────────\n\n/**\n * Compute HMAC-SHA-256(key, data) and return hex string.\n *\n * Used to derive content-addressed eTags that are opaque to the store:\n * ```\n * eTag = hmacSha256Hex(blobDEK, plaintext)\n * ```\n *\n * Unlike a plain SHA-256, the HMAC is keyed by the vault-shared `_blob` DEK,\n * so an attacker with store access cannot pre-compute eTags for known files.\n * Deduplication still works within a vault (same key + same content = same eTag).\n */\nexport async function hmacSha256Hex(key: CryptoKey, data: Uint8Array): Promise<string> {\n // Export AES-GCM DEK raw bytes → import as HMAC key\n const rawKey = await subtle.exportKey('raw', key)\n const hmacKey = await subtle.importKey(\n 'raw',\n rawKey,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const sig = await subtle.sign('HMAC', hmacKey, data as unknown as BufferSource)\n return Array.from(new Uint8Array(sig))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\n// ─── AAD-aware Binary Encrypt / Decrypt ──\n\n/**\n * Encrypt raw bytes with AES-256-GCM using Additional Authenticated Data.\n *\n * The AAD binds each chunk to its parent blob and position, preventing\n * chunk reorder, substitution, and truncation attacks:\n * ```\n * AAD = UTF-8(\"{eTag}:{chunkIndex}:{chunkCount}\")\n * ```\n *\n * The AAD is NOT stored — the reader reconstructs it from `BlobObject`\n * metadata and passes it to `decryptBytesWithAAD`.\n */\nexport async function encryptBytesWithAAD(\n data: Uint8Array,\n dek: CryptoKey,\n aad: Uint8Array,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const ciphertext = await subtle.encrypt(\n {\n name: 'AES-GCM',\n iv: iv as BufferSource,\n additionalData: aad as BufferSource,\n },\n dek,\n data as unknown as BufferSource,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext with AAD verification.\n *\n * If the AAD does not match the one used at encryption time (e.g. because\n * a chunk was reordered or substituted from another blob), the GCM auth\n * tag fails and this throws `TamperedError`.\n */\nexport async function decryptBytesWithAAD(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n aad: Uint8Array,\n): Promise<Uint8Array> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n try {\n const plaintext = await subtle.decrypt(\n {\n name: 'AES-GCM',\n iv: iv as BufferSource,\n additionalData: aad as BufferSource,\n },\n dek,\n ciphertext as BufferSource,\n )\n return new Uint8Array(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n// ─── Presence Key Derivation ──────────────────────────────\n\n/**\n * Derive an AES-256-GCM presence key from a collection DEK using HKDF-SHA256.\n *\n * The presence key is domain-separated from the data DEK by the fixed salt\n * `'noydb-presence'` and the `info` = collection name. This means:\n * - The adapter never sees the presence key.\n * - Presence payloads rotate automatically when the collection DEK is rotated.\n * - Revoked users cannot derive the new presence key after a DEK rotation.\n *\n * @param dek The collection's AES-256-GCM DEK (extractable).\n * @param collectionName Used as the HKDF `info` parameter for domain separation.\n * @returns A non-extractable AES-256-GCM key suitable for presence payload encryption.\n */\nexport async function derivePresenceKey(dek: CryptoKey, collectionName: string): Promise<CryptoKey> {\n // Step 1: export DEK raw bytes\n const rawDek = await subtle.exportKey('raw', dek)\n\n // Step 2: import as HKDF key material\n const hkdfKey = await subtle.importKey(\n 'raw',\n rawDek,\n 'HKDF',\n false,\n ['deriveBits'],\n )\n\n // Step 3: derive 256 bits with salt='noydb-presence' and info=collectionName\n const salt = new TextEncoder().encode('noydb-presence')\n const info = new TextEncoder().encode(collectionName)\n const bits = await subtle.deriveBits(\n { name: 'HKDF', hash: 'SHA-256', salt, info },\n hkdfKey,\n KEY_BITS,\n )\n\n // Step 4: import derived bits as AES-GCM key\n return subtle.importKey(\n 'raw',\n bits,\n { name: 'AES-GCM', length: KEY_BITS },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Deterministic Encryption ────────────────────────────\n\n/**\n * Derive a deterministic 12-byte IV from `{ DEK, context, plaintext }`\n * via HKDF-SHA256. Given the same three inputs, the IV is identical, so\n * `encryptDeterministic` produces the same ciphertext on every call —\n * which is precisely what enables blind equality search on encrypted\n * fields.\n *\n * **The side channel this opens.** Two records whose field value is the\n * same produce the same ciphertext. An observer with store access can\n * therefore tell which records share a value — not *what* the value is,\n * but the equivalence class. This is the well-known trade-off of\n * deterministic encryption and is why the feature is strictly opt-in\n * per field, guarded by `acknowledgeDeterministicRisk: true` at\n * collection creation.\n *\n * The context string MUST include the collection name and field name,\n * so:\n * - The same plaintext in two different fields encrypts differently\n * (no cross-field equality leak).\n * - The same plaintext in two different collections (different DEKs)\n * encrypts differently by virtue of the key, even before HKDF\n * domain separation kicks in.\n */\nasync function deriveDeterministicIV(\n dek: CryptoKey,\n context: string,\n plaintext: string,\n): Promise<Uint8Array> {\n const rawDek = await subtle.exportKey('raw', dek)\n const hkdfKey = await subtle.importKey('raw', rawDek, 'HKDF', false, ['deriveBits'])\n const salt = new TextEncoder().encode('noydb-deterministic-v1')\n const info = new TextEncoder().encode(`${context}\\x00${plaintext}`)\n const bits = await subtle.deriveBits(\n { name: 'HKDF', hash: 'SHA-256', salt, info },\n hkdfKey,\n IV_BYTES * 8,\n )\n return new Uint8Array(bits)\n}\n\n/**\n * Encrypt a plaintext string with AES-256-GCM and a deterministic,\n * HKDF-derived IV.\n *\n * The same `{ dek, context, plaintext }` triple always produces the\n * same `{ iv, data }` — call this twice and you can string-compare the\n * ciphertexts to check equality of the inputs without decrypting them.\n *\n * @param context Domain-separation string — by convention\n * `'<collection>/<field>'`. Different contexts encrypt\n * the same plaintext to different ciphertexts, so\n * `email` in collection `users` does not collide with\n * `email` in collection `customers`.\n */\nexport async function encryptDeterministic(\n plaintext: string,\n dek: CryptoKey,\n context: string,\n): Promise<EncryptResult> {\n const iv = await deriveDeterministicIV(dek, context, plaintext)\n const encoded = new TextEncoder().encode(plaintext)\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n encoded,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Counterpart to {@link encryptDeterministic}. The IV is stored\n * alongside the ciphertext (exactly like the randomized path), so\n * decrypt uses the stored IV and verifies the GCM auth tag — a tampered\n * ciphertext throws `TamperedError` just like randomized AES-GCM.\n */\nexport async function decryptDeterministic(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<string> {\n return decrypt(ivBase64, dataBase64, dek)\n}\n\n// ─── Random Generation ─────────────────────────────────────────────────\n\n/** Generate a random 12-byte IV for AES-GCM. */\nexport function generateIV(): Uint8Array {\n return globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES))\n}\n\n/** Generate a random 32-byte salt for PBKDF2. */\nexport function generateSalt(): Uint8Array {\n return globalThis.crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n}\n\n// ─── Base64 Helpers ────────────────────────────────────────────────────\n\nexport function bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {\n const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)\n let binary = ''\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!)\n }\n return btoa(binary)\n}\n\nexport function base64ToBuffer(base64: string): Uint8Array<ArrayBuffer> {\n const binary = atob(base64)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i)\n }\n return bytes\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/query/predicate.ts
|
|
2
|
+
function readPath(record, path) {
|
|
3
|
+
if (record === null || record === void 0) return void 0;
|
|
4
|
+
if (!path.includes(".")) {
|
|
5
|
+
return record[path];
|
|
6
|
+
}
|
|
7
|
+
const segments = path.split(".");
|
|
8
|
+
let cursor = record;
|
|
9
|
+
for (const segment of segments) {
|
|
10
|
+
if (cursor === null || cursor === void 0) return void 0;
|
|
11
|
+
cursor = cursor[segment];
|
|
12
|
+
}
|
|
13
|
+
return cursor;
|
|
14
|
+
}
|
|
15
|
+
function evaluateFieldClause(record, clause) {
|
|
16
|
+
const actual = readPath(record, clause.field);
|
|
17
|
+
const { op, value } = clause;
|
|
18
|
+
switch (op) {
|
|
19
|
+
case "==":
|
|
20
|
+
return actual === value;
|
|
21
|
+
case "!=":
|
|
22
|
+
return actual !== value;
|
|
23
|
+
case "<":
|
|
24
|
+
return isComparable(actual, value) && actual < value;
|
|
25
|
+
case "<=":
|
|
26
|
+
return isComparable(actual, value) && actual <= value;
|
|
27
|
+
case ">":
|
|
28
|
+
return isComparable(actual, value) && actual > value;
|
|
29
|
+
case ">=":
|
|
30
|
+
return isComparable(actual, value) && actual >= value;
|
|
31
|
+
case "in":
|
|
32
|
+
return Array.isArray(value) && value.includes(actual);
|
|
33
|
+
case "contains":
|
|
34
|
+
if (typeof actual === "string") return typeof value === "string" && actual.includes(value);
|
|
35
|
+
if (Array.isArray(actual)) return actual.includes(value);
|
|
36
|
+
return false;
|
|
37
|
+
case "startsWith":
|
|
38
|
+
return typeof actual === "string" && typeof value === "string" && actual.startsWith(value);
|
|
39
|
+
case "between": {
|
|
40
|
+
if (!Array.isArray(value) || value.length !== 2) return false;
|
|
41
|
+
const [lo, hi] = value;
|
|
42
|
+
if (!isComparable(actual, lo) || !isComparable(actual, hi)) return false;
|
|
43
|
+
return actual >= lo && actual <= hi;
|
|
44
|
+
}
|
|
45
|
+
default: {
|
|
46
|
+
const _exhaustive = op;
|
|
47
|
+
void _exhaustive;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isComparable(a, b) {
|
|
53
|
+
if (typeof a === "number" && typeof b === "number") return true;
|
|
54
|
+
if (typeof a === "string" && typeof b === "string") return true;
|
|
55
|
+
if (a instanceof Date && b instanceof Date) return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
function evaluateClause(record, clause) {
|
|
59
|
+
switch (clause.type) {
|
|
60
|
+
case "field":
|
|
61
|
+
return evaluateFieldClause(record, clause);
|
|
62
|
+
case "filter":
|
|
63
|
+
return clause.fn(record);
|
|
64
|
+
case "group":
|
|
65
|
+
if (clause.op === "and") {
|
|
66
|
+
for (const child of clause.clauses) {
|
|
67
|
+
if (!evaluateClause(record, child)) return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
} else {
|
|
71
|
+
for (const child of clause.clauses) {
|
|
72
|
+
if (evaluateClause(record, child)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
readPath,
|
|
81
|
+
evaluateFieldClause,
|
|
82
|
+
evaluateClause
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=chunk-M5INGEFC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/query/predicate.ts"],"sourcesContent":["/**\n * Operator implementations for the query DSL.\n *\n * All predicates run client-side, AFTER decryption — they never see ciphertext.\n * This file is dependency-free and tree-shakeable.\n */\n\n/** Comparison operators supported by the where() builder. */\nexport type Operator =\n | '=='\n | '!='\n | '<'\n | '<='\n | '>'\n | '>='\n | 'in'\n | 'contains'\n | 'startsWith'\n | 'between'\n\n/**\n * A single field comparison clause inside a query plan.\n * Plans are JSON-serializable, so this type uses primitives only.\n */\nexport interface FieldClause {\n readonly type: 'field'\n readonly field: string\n readonly op: Operator\n readonly value: unknown\n}\n\n/**\n * A user-supplied predicate function escape hatch. Not serializable.\n *\n * The predicate accepts `unknown` at the type level so the surrounding\n * Clause type can stay non-parametric — this keeps Collection<T> covariant\n * in T at the public API surface. Builder methods cast user predicates\n * (typed `(record: T) => boolean`) into this shape on the way in.\n */\nexport interface FilterClause {\n readonly type: 'filter'\n readonly fn: (record: unknown) => boolean\n}\n\n/** A logical group of clauses combined by AND or OR. */\nexport interface GroupClause {\n readonly type: 'group'\n readonly op: 'and' | 'or'\n readonly clauses: readonly Clause[]\n}\n\nexport type Clause = FieldClause | FilterClause | GroupClause\n\n/**\n * Read a possibly nested field path like \"address.city\" from a record.\n * Returns undefined if any segment is missing.\n */\nexport function readPath(record: unknown, path: string): unknown {\n if (record === null || record === undefined) return undefined\n if (!path.includes('.')) {\n return (record as Record<string, unknown>)[path]\n }\n const segments = path.split('.')\n let cursor: unknown = record\n for (const segment of segments) {\n if (cursor === null || cursor === undefined) return undefined\n cursor = (cursor as Record<string, unknown>)[segment]\n }\n return cursor\n}\n\n/**\n * Evaluate a single field clause against a record.\n * Returns false on type mismatches rather than throwing — query results\n * exclude non-matching records by definition.\n */\nexport function evaluateFieldClause(record: unknown, clause: FieldClause): boolean {\n const actual = readPath(record, clause.field)\n const { op, value } = clause\n\n switch (op) {\n case '==':\n return actual === value\n case '!=':\n return actual !== value\n case '<':\n return isComparable(actual, value) && (actual as number) < (value as number)\n case '<=':\n return isComparable(actual, value) && (actual as number) <= (value as number)\n case '>':\n return isComparable(actual, value) && (actual as number) > (value as number)\n case '>=':\n return isComparable(actual, value) && (actual as number) >= (value as number)\n case 'in':\n return Array.isArray(value) && value.includes(actual)\n case 'contains':\n if (typeof actual === 'string') return typeof value === 'string' && actual.includes(value)\n if (Array.isArray(actual)) return actual.includes(value)\n return false\n case 'startsWith':\n return typeof actual === 'string' && typeof value === 'string' && actual.startsWith(value)\n case 'between': {\n if (!Array.isArray(value) || value.length !== 2) return false\n const [lo, hi] = value\n if (!isComparable(actual, lo) || !isComparable(actual, hi)) return false\n return (actual as number) >= (lo as number) && (actual as number) <= (hi as number)\n }\n default: {\n // Exhaustiveness — TS will error if a new operator is added without a case.\n const _exhaustive: never = op\n void _exhaustive\n return false\n }\n }\n}\n\n/**\n * Two values are \"comparable\" if they share an order-defined runtime type.\n * Strings compare lexicographically; numbers and Dates numerically; otherwise false.\n */\nfunction isComparable(a: unknown, b: unknown): boolean {\n if (typeof a === 'number' && typeof b === 'number') return true\n if (typeof a === 'string' && typeof b === 'string') return true\n if (a instanceof Date && b instanceof Date) return true\n return false\n}\n\n/**\n * Evaluate any clause (field / filter / group) against a record.\n * The recursion depth is bounded by the user's query expression — no risk of\n * blowing the stack on a 50K-record collection.\n */\nexport function evaluateClause(record: unknown, clause: Clause): boolean {\n switch (clause.type) {\n case 'field':\n return evaluateFieldClause(record, clause)\n case 'filter':\n return clause.fn(record)\n case 'group':\n if (clause.op === 'and') {\n for (const child of clause.clauses) {\n if (!evaluateClause(record, child)) return false\n }\n return true\n } else {\n for (const child of clause.clauses) {\n if (evaluateClause(record, child)) return true\n }\n return false\n }\n }\n}\n"],"mappings":";AAyDO,SAAS,SAAS,QAAiB,MAAuB;AAC/D,MAAI,WAAW,QAAQ,WAAW,OAAW,QAAO;AACpD,MAAI,CAAC,KAAK,SAAS,GAAG,GAAG;AACvB,WAAQ,OAAmC,IAAI;AAAA,EACjD;AACA,QAAM,WAAW,KAAK,MAAM,GAAG;AAC/B,MAAI,SAAkB;AACtB,aAAW,WAAW,UAAU;AAC9B,QAAI,WAAW,QAAQ,WAAW,OAAW,QAAO;AACpD,aAAU,OAAmC,OAAO;AAAA,EACtD;AACA,SAAO;AACT;AAOO,SAAS,oBAAoB,QAAiB,QAA8B;AACjF,QAAM,SAAS,SAAS,QAAQ,OAAO,KAAK;AAC5C,QAAM,EAAE,IAAI,MAAM,IAAI;AAEtB,UAAQ,IAAI;AAAA,IACV,KAAK;AACH,aAAO,WAAW;AAAA,IACpB,KAAK;AACH,aAAO,WAAW;AAAA,IACpB,KAAK;AACH,aAAO,aAAa,QAAQ,KAAK,KAAM,SAAqB;AAAA,IAC9D,KAAK;AACH,aAAO,aAAa,QAAQ,KAAK,KAAM,UAAsB;AAAA,IAC/D,KAAK;AACH,aAAO,aAAa,QAAQ,KAAK,KAAM,SAAqB;AAAA,IAC9D,KAAK;AACH,aAAO,aAAa,QAAQ,KAAK,KAAM,UAAsB;AAAA,IAC/D,KAAK;AACH,aAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,MAAM;AAAA,IACtD,KAAK;AACH,UAAI,OAAO,WAAW,SAAU,QAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK;AACzF,UAAI,MAAM,QAAQ,MAAM,EAAG,QAAO,OAAO,SAAS,KAAK;AACvD,aAAO;AAAA,IACT,KAAK;AACH,aAAO,OAAO,WAAW,YAAY,OAAO,UAAU,YAAY,OAAO,WAAW,KAAK;AAAA,IAC3F,KAAK,WAAW;AACd,UAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,EAAG,QAAO;AACxD,YAAM,CAAC,IAAI,EAAE,IAAI;AACjB,UAAI,CAAC,aAAa,QAAQ,EAAE,KAAK,CAAC,aAAa,QAAQ,EAAE,EAAG,QAAO;AACnE,aAAQ,UAAsB,MAAkB,UAAsB;AAAA,IACxE;AAAA,IACA,SAAS;AAEP,YAAM,cAAqB;AAC3B,WAAK;AACL,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAMA,SAAS,aAAa,GAAY,GAAqB;AACrD,MAAI,OAAO,MAAM,YAAY,OAAO,MAAM,SAAU,QAAO;AAC3D,MAAI,OAAO,MAAM,YAAY,OAAO,MAAM,SAAU,QAAO;AAC3D,MAAI,aAAa,QAAQ,aAAa,KAAM,QAAO;AACnD,SAAO;AACT;AAOO,SAAS,eAAe,QAAiB,QAAyB;AACvE,UAAQ,OAAO,MAAM;AAAA,IACnB,KAAK;AACH,aAAO,oBAAoB,QAAQ,MAAM;AAAA,IAC3C,KAAK;AACH,aAAO,OAAO,GAAG,MAAM;AAAA,IACzB,KAAK;AACH,UAAI,OAAO,OAAO,OAAO;AACvB,mBAAW,SAAS,OAAO,SAAS;AAClC,cAAI,CAAC,eAAe,QAAQ,KAAK,EAAG,QAAO;AAAA,QAC7C;AACA,eAAO;AAAA,MACT,OAAO;AACL,mBAAW,SAAS,OAAO,SAAS;AAClC,cAAI,eAAe,QAAQ,KAAK,EAAG,QAAO;AAAA,QAC5C;AACA,eAAO;AAAA,MACT;AAAA,EACJ;AACF;","names":[]}
|