quiver-client 0.22.0

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/dist/dcpe.js ADDED
@@ -0,0 +1,336 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ //! Client-side DCPE vector encryption (ADR-0031, hardened in ADR-0035) for the
3
+ //! Quiver TypeScript SDK.
4
+ //
5
+ // A faithful port of the reference cipher `quiver_crypto::dcpe` (and the Python
6
+ // `quiver.dcpe`): the **Scale-And-Perturb (SAP)** distance-comparison-preserving
7
+ // scheme of Fuchsbauer, Ghosal, Hauke & O'Neill (ePrint 2021/1666, SCN 2022). It
8
+ // encrypts embedding vectors so a Quiver server can answer approximate
9
+ // nearest-neighbour queries over the ciphertexts **without ever holding the
10
+ // plaintext vectors or the key** — Euclidean distance comparison is preserved up
11
+ // to a tunable margin.
12
+ //
13
+ // This is **cipher v2** (ADR-0035): it adds the paper's two hardening steps — a
14
+ // key-derived component **shuffle** (an exact L2 isometry, so zero recall cost)
15
+ // and an optional ordering-preserving global affine **normalisation**
16
+ // (`Normalization`). v2 is a breaking change from v1 (v1 ciphertexts are not
17
+ // v2-decryptable); the cipher is client-side, so there is no on-disk format change.
18
+ //
19
+ // This module lives at the `quiver-client/dcpe` subpath so the core client stays
20
+ // dependency-free. The primitives come from audited `@stablelib` packages, installed
21
+ // as optional peer dependencies only to use these helpers:
22
+ //
23
+ // pnpm add @stablelib/chacha @stablelib/hkdf @stablelib/hmac @stablelib/sha256
24
+ //
25
+ // import { DcpeCipher } from "quiver-client/dcpe";
26
+ // const cipher = DcpeCipher.fromHex("…64 hex chars…", 0.02);
27
+ // // encrypt vectors before upsert, and queries before search, with the same cipher:
28
+ // const sealed = cipher.encrypt([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]);
29
+ // // upsert sealed.ciphertext; later: cipher.encryptQuery(myQuery) for search.
30
+ //
31
+ // **DCPE is experimental and is _not_ semantically secure.** It leaks the
32
+ // approximate distance-comparison relation by design (that is what makes encrypted
33
+ // search work), is L2-only, and is broken by known-plaintext or strong-prior
34
+ // adversaries. It complements — does not replace — encryption at rest. Use a
35
+ // dedicated key, and encrypt and query from the same client. See ADR-0031, ADR-0035,
36
+ // and docs/security/dcpe.md.
37
+ //
38
+ // Because the ciphertext is float-valued and uses transcendental functions,
39
+ // bit-exact reproduction against the Rust reference is not guaranteed (libm ULP
40
+ // differences); interop is validated within a tolerance. The Rust module is canonical.
41
+ import { stream } from "@stablelib/chacha";
42
+ import { HKDF } from "@stablelib/hkdf";
43
+ import { hmac } from "@stablelib/hmac";
44
+ import { SHA256 } from "@stablelib/sha256";
45
+ /** DCPE initialisation-vector length in bytes (a 96-bit ChaCha20 nonce). */
46
+ export const IV_LEN = 12;
47
+ /** DCPE integrity-tag length in bytes (full HMAC-SHA256 output). */
48
+ export const TAG_LEN = 32;
49
+ const TE = new TextEncoder();
50
+ // HKDF-SHA256 `info` strings: distinct sub-keys from one master secret. The
51
+ // scale/prf/auth derivations are unchanged from v1; `shuffle` is new in v2, and
52
+ // the tag domain is bumped to v2 so a v1 ciphertext fails a v2 integrity check.
53
+ const INFO_SCALE = TE.encode("quiver/dcpe/v1/scale");
54
+ const INFO_PRF = TE.encode("quiver/dcpe/v1/prf");
55
+ const INFO_SHUFFLE = TE.encode("quiver/dcpe/v2/shuffle");
56
+ const INFO_AUTH = TE.encode("quiver/dcpe/v1/auth");
57
+ const AUTH_DOMAIN = TE.encode("quiver/dcpe/v2/tag");
58
+ const TWO_POW_53 = 2 ** 53;
59
+ /** An error from DCPE encryption, decryption, or construction. */
60
+ export class DcpeError extends Error {
61
+ constructor(message) {
62
+ super(message);
63
+ this.name = "DcpeError";
64
+ }
65
+ }
66
+ /** A fixed, ordering-preserving global affine normalisation (ADR-0035).
67
+ *
68
+ * Maps a plaintext `m` to `(m - shift) * scale` before encryption, where `shift`
69
+ * is a per-dimension translation and `scale` is a **single** positive scalar. Both
70
+ * steps preserve the L2 distance-comparison ordering (a uniform shift cancels in
71
+ * any difference; a single positive scalar scales every distance by the same
72
+ * factor) and are invertible. Supply it once from a one-time measurement of your
73
+ * corpus and reuse it for the data *and* the queries.
74
+ *
75
+ * Per-axis variance whitening (a different scale per dimension) is anisotropic,
76
+ * re-weights the L2 distance, and so breaks the ordering — it is intentionally not
77
+ * expressible here. See ADR-0035. */
78
+ export class Normalization {
79
+ shift;
80
+ scale;
81
+ constructor(shift, scale) {
82
+ this.shift = shift;
83
+ this.scale = scale;
84
+ }
85
+ /** Build a normalisation from a per-dimension shift and a single positive scale. */
86
+ static create(shift, scale) {
87
+ const shiftArr = Array.from(shift, Number);
88
+ if (!Number.isFinite(scale) || scale <= 0 || shiftArr.some((x) => !Number.isFinite(x))) {
89
+ throw new DcpeError("invalid normalisation: scale must be finite and > 0 and shifts finite");
90
+ }
91
+ return new Normalization(shiftArr, scale);
92
+ }
93
+ }
94
+ /** A client-held DCPE key bound to one approximation factor (ADR-0031/0035).
95
+ *
96
+ * Construct one cipher per `(key, approximationFactor[, normalization])` and reuse
97
+ * it; the same factor (and normalisation) must be used for the data and the queries
98
+ * searched against it. */
99
+ export class DcpeCipher {
100
+ #scale;
101
+ #prfKey;
102
+ #shuffleKey;
103
+ #authKey;
104
+ #beta;
105
+ #normalization;
106
+ constructor(key, approximationFactor, normalization) {
107
+ if (!Number.isFinite(approximationFactor) || approximationFactor < 0) {
108
+ throw new DcpeError("approximation factor must be finite and >= 0");
109
+ }
110
+ // Match the Rust f32 approximation factor exactly (it is bound into the tag).
111
+ this.#beta = Math.fround(approximationFactor);
112
+ const salt = new Uint8Array(32);
113
+ const scaleBytes = new HKDF(SHA256, key, salt, INFO_SCALE).expand(8);
114
+ this.#scale = 1 + Number(leU64(scaleBytes, 0) >> 11n) / TWO_POW_53;
115
+ this.#prfKey = new HKDF(SHA256, key, salt, INFO_PRF).expand(32);
116
+ this.#shuffleKey = new HKDF(SHA256, key, salt, INFO_SHUFFLE).expand(32);
117
+ this.#authKey = new HKDF(SHA256, key, salt, INFO_AUTH).expand(32);
118
+ this.#normalization = normalization;
119
+ }
120
+ /** Build a cipher from a raw 256-bit (32-byte) key. */
121
+ static fromBytes(key, approximationFactor, normalization = null) {
122
+ if (key.length !== 32) {
123
+ throw new DcpeError(`DCPE key must be 32 bytes, got ${key.length}`);
124
+ }
125
+ return new DcpeCipher(Uint8Array.from(key), approximationFactor, normalization);
126
+ }
127
+ /** Build a cipher from a 64-character hex-encoded 256-bit key. */
128
+ static fromHex(hex, approximationFactor, normalization = null) {
129
+ const clean = hex.trim();
130
+ if (!/^[0-9a-fA-F]{64}$/.test(clean)) {
131
+ throw new DcpeError(`DCPE key must be 64 hex characters, got ${clean.length}`);
132
+ }
133
+ const key = new Uint8Array(32);
134
+ for (let i = 0; i < 32; i++) {
135
+ key[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
136
+ }
137
+ return new DcpeCipher(key, approximationFactor, normalization);
138
+ }
139
+ /** The secret, key-derived scaling factor `s ∈ [1, 2)`. Part of the key. */
140
+ get scale() {
141
+ return this.#scale;
142
+ }
143
+ /** The approximation factor this cipher was built with. */
144
+ get approximationFactor() {
145
+ return this.#beta;
146
+ }
147
+ /** Encrypt a vector for storage with a fresh random IV. */
148
+ encrypt(vector) {
149
+ if (vector.length === 0) {
150
+ throw new DcpeError("empty vector: DCPE needs at least one dimension");
151
+ }
152
+ const pre = this.#pretransform(vector);
153
+ const iv = randomBytes(IV_LEN);
154
+ const ciphertext = this.#scaleAndPerturb(pre, iv);
155
+ return { ciphertext, iv, tag: this.#tag(iv, ciphertext) };
156
+ }
157
+ /** Encrypt a query vector for searching against DCPE-encrypted data. */
158
+ encryptQuery(vector) {
159
+ if (vector.length === 0) {
160
+ throw new DcpeError("empty vector: DCPE needs at least one dimension");
161
+ }
162
+ return this.#scaleAndPerturb(this.#pretransform(vector), randomBytes(IV_LEN));
163
+ }
164
+ /** Verify the integrity tag (constant-time) and recover the plaintext. */
165
+ decrypt(sealed) {
166
+ if (sealed.ciphertext.length === 0) {
167
+ throw new DcpeError("empty vector: DCPE needs at least one dimension");
168
+ }
169
+ const expected = this.#tag(sealed.iv, sealed.ciphertext);
170
+ if (!constantTimeEqual(expected, sealed.tag)) {
171
+ throw new DcpeError("integrity check failed: wrong key or tampered ciphertext");
172
+ }
173
+ const lambda = this.#perturbation(sealed.iv, sealed.ciphertext.length);
174
+ // Recover the shuffled, normalised vector (c - lambda)/s, then reverse the
175
+ // pipeline: un-shuffle, then un-normalise.
176
+ const shuffled = sealed.ciphertext.map((c, i) => (c - lambda[i]) / this.#scale);
177
+ return this.#denormalize(this.#unshuffle(shuffled));
178
+ }
179
+ // --- internals (match the Rust/Python references byte-for-byte) ---
180
+ #pretransform(vector) {
181
+ const normalized = this.#normalize(vector);
182
+ const perm = this.#permutation(vector.length);
183
+ return perm.map((p) => normalized[p]);
184
+ }
185
+ #normalize(vector) {
186
+ const n = this.#normalization;
187
+ if (n === null) {
188
+ return Array.from(vector, Number);
189
+ }
190
+ if (n.shift.length !== vector.length) {
191
+ throw new DcpeError(`dimension mismatch: vector has ${vector.length} dims, normalisation has ${n.shift.length}`);
192
+ }
193
+ return Array.from(vector, (m, i) => (Number(m) - n.shift[i]) * n.scale);
194
+ }
195
+ #unshuffle(shuffled) {
196
+ const perm = this.#permutation(shuffled.length);
197
+ const out = new Array(shuffled.length).fill(0);
198
+ for (let i = 0; i < perm.length; i++) {
199
+ out[perm[i]] = shuffled[i];
200
+ }
201
+ return out;
202
+ }
203
+ #denormalize(normalized) {
204
+ const n = this.#normalization;
205
+ if (n === null) {
206
+ return normalized.map((x) => Math.fround(x));
207
+ }
208
+ if (n.shift.length !== normalized.length) {
209
+ throw new DcpeError(`dimension mismatch: vector has ${normalized.length} dims, normalisation has ${n.shift.length}`);
210
+ }
211
+ return normalized.map((x, i) => Math.fround(x / n.scale + n.shift[i]));
212
+ }
213
+ // The key-derived permutation of [0, d) (Fisher-Yates from the top over the
214
+ // shuffle keystream with a fixed zero IV), identical for every vector and query
215
+ // so all pairwise L2 distances are preserved. The `% (i + 1)` reduction's modulo
216
+ // bias is cryptographically negligible and is fixed for cross-language parity.
217
+ #permutation(d) {
218
+ const perm = Array.from({ length: d }, (_, i) => i);
219
+ if (d <= 1) {
220
+ return perm;
221
+ }
222
+ const ks = new KeyStream(this.#shuffleKey, new Uint8Array(IV_LEN), d * 8);
223
+ for (let i = d - 1; i >= 1; i--) {
224
+ const j = Number(ks.nextU64() % BigInt(i + 1));
225
+ const tmp = perm[i];
226
+ perm[i] = perm[j];
227
+ perm[j] = tmp;
228
+ }
229
+ return perm;
230
+ }
231
+ // Compute c = s·x + λ (f64), stored as f32. `x` is the normalised+shuffled vector.
232
+ #scaleAndPerturb(x, iv) {
233
+ const lambda = this.#perturbation(iv, x.length);
234
+ return x.map((m, i) => Math.fround(this.#scale * m + lambda[i]));
235
+ }
236
+ // The perturbation λ: a uniform point in the d-ball of radius (s/4)·β. The CSPRNG
237
+ // draws the d normal components first, then one uniform for the radius.
238
+ #perturbation(iv, d) {
239
+ const ks = new KeyStream(this.#prfKey, iv, (d + 4) * 8);
240
+ const direction = Array.from({ length: d }, () => ks.nextNormal());
241
+ let norm = 0;
242
+ for (const v of direction) {
243
+ norm += v * v;
244
+ }
245
+ norm = Math.sqrt(norm);
246
+ const u = ks.nextUnit();
247
+ const radius = (this.#scale / 4) * this.#beta * Math.pow(u, 1 / d);
248
+ if (norm === 0) {
249
+ return new Array(d).fill(0);
250
+ }
251
+ return direction.map((v) => (v / norm) * radius);
252
+ }
253
+ // HMAC-SHA256 over (domain ‖ β as f32 LE ‖ iv ‖ ciphertext as f32 LE).
254
+ #tag(iv, ciphertext) {
255
+ const d = ciphertext.length;
256
+ const msg = new Uint8Array(AUTH_DOMAIN.length + 4 + IV_LEN + 4 * d);
257
+ const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
258
+ let off = 0;
259
+ msg.set(AUTH_DOMAIN, off);
260
+ off += AUTH_DOMAIN.length;
261
+ dv.setFloat32(off, this.#beta, true);
262
+ off += 4;
263
+ msg.set(iv, off);
264
+ off += IV_LEN;
265
+ for (let i = 0; i < d; i++) {
266
+ dv.setFloat32(off, ciphertext[i], true);
267
+ off += 4;
268
+ }
269
+ return hmac(SHA256, this.#authKey, msg);
270
+ }
271
+ }
272
+ /** A deterministic CSPRNG: the raw ChaCha20 keystream from `(key, iv)`, read as
273
+ * little-endian u64s. Standard normals come from Box-Muller, caching the paired
274
+ * value. The layout matches the Rust/Python references byte-for-byte. The keystream
275
+ * is materialised up front to `capacity` bytes (sized to the caller's exact need). */
276
+ class KeyStream {
277
+ #buf;
278
+ #pos = 0;
279
+ #spare = null;
280
+ constructor(key, iv, capacity) {
281
+ this.#buf = new Uint8Array(capacity);
282
+ stream(key, iv, this.#buf);
283
+ }
284
+ nextU64() {
285
+ if (this.#pos + 8 > this.#buf.length) {
286
+ throw new DcpeError("DCPE keystream exhausted");
287
+ }
288
+ const w = leU64(this.#buf, this.#pos);
289
+ this.#pos += 8;
290
+ return w;
291
+ }
292
+ // A uniform in [0, 1) with 53-bit resolution (the f64 mantissa width).
293
+ nextUnit() {
294
+ return Number(this.nextU64() >> 11n) / TWO_POW_53;
295
+ }
296
+ // A standard normal via Box-Muller; u1 ∈ (0, 1] so log is finite.
297
+ nextNormal() {
298
+ if (this.#spare !== null) {
299
+ const z = this.#spare;
300
+ this.#spare = null;
301
+ return z;
302
+ }
303
+ const u1 = 1 - this.nextUnit();
304
+ const u2 = this.nextUnit();
305
+ const r = Math.sqrt(-2 * Math.log(u1));
306
+ const theta = 2 * Math.PI * u2;
307
+ this.#spare = r * Math.sin(theta);
308
+ return r * Math.cos(theta);
309
+ }
310
+ }
311
+ /** Read 8 bytes of `buf` at `off` as a little-endian u64. */
312
+ function leU64(buf, off) {
313
+ let w = 0n;
314
+ for (let i = 7; i >= 0; i--) {
315
+ w = (w << 8n) | BigInt(buf[off + i]);
316
+ }
317
+ return w;
318
+ }
319
+ /** A constant-time byte-string comparison (not a crypto primitive; a comparison). */
320
+ function constantTimeEqual(a, b) {
321
+ if (a.length !== b.length) {
322
+ return false;
323
+ }
324
+ let diff = 0;
325
+ for (let i = 0; i < a.length; i++) {
326
+ diff |= a[i] ^ b[i];
327
+ }
328
+ return diff === 0;
329
+ }
330
+ function randomBytes(length) {
331
+ const webcrypto = globalThis.crypto;
332
+ if (!webcrypto?.getRandomValues) {
333
+ throw new DcpeError("a Web Crypto getRandomValues implementation is required");
334
+ }
335
+ return webcrypto.getRandomValues(new Uint8Array(length));
336
+ }
@@ -0,0 +1,25 @@
1
+ /** The reserved payload key under which a sealed envelope is stored. */
2
+ export declare const ENVELOPE_KEY = "__quiver_enc__";
3
+ /** An error sealing or opening a client-side payload envelope. */
4
+ export declare class PayloadError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /** A client-held key for sealing and opening payload envelopes (ADR-0012). */
8
+ export declare class PayloadCipher {
9
+ #private;
10
+ private constructor();
11
+ /** Build a cipher from a raw 256-bit (32-byte) key. */
12
+ static fromBytes(key: Uint8Array): PayloadCipher;
13
+ /** Build a cipher from a 64-character hex-encoded 256-bit key. */
14
+ static fromHex(hex: string): PayloadCipher;
15
+ /** Seal `plaintext` into a one-key envelope `{ [ENVELOPE_KEY]: { … } }`. Each
16
+ * call uses a fresh random nonce, so the same value seals to different
17
+ * ciphertext. */
18
+ seal(plaintext: unknown): Record<string, unknown>;
19
+ /** Open an envelope sealed by {@link seal}, returning the plaintext. `sealed`
20
+ * may carry cleartext sibling fields; only the reserved key is read. A wrong
21
+ * key or any tampering throws {@link PayloadError}. */
22
+ open(sealed: unknown): unknown;
23
+ }
24
+ /** Whether `value` carries a Quiver payload envelope. */
25
+ export declare function isSealed(value: unknown): boolean;
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ //! Client-side payload encryption (ADR-0012) for the Quiver TypeScript SDK.
3
+ //
4
+ // Mirrors the reference envelope in `quiver_crypto::payload` byte-for-byte: a
5
+ // caller seals a JSON payload with a 256-bit key Quiver never sees, and the
6
+ // server stores and returns it as an opaque blob it cannot read. Sealing uses
7
+ // XChaCha20-Poly1305 (the audited `@stablelib/xchacha20poly1305`, which
8
+ // interoperates with the Rust and Python implementations) with a fresh random
9
+ // 192-bit nonce.
10
+ //
11
+ // This module lives at the `quiver-client/encryption` subpath so the core
12
+ // client stays dependency-free; `@stablelib/xchacha20poly1305` is an optional
13
+ // peer dependency you install only to use these helpers:
14
+ //
15
+ // pnpm add @stablelib/xchacha20poly1305
16
+ //
17
+ // Keep fields server-filterable by leaving them in cleartext and merging the
18
+ // sealed envelope alongside them — `open` reads only the reserved key:
19
+ //
20
+ // import { PayloadCipher } from "quiver-client/encryption";
21
+ // const cipher = PayloadCipher.fromHex("…64 hex chars…");
22
+ // const payload = { tier: "gold", ...cipher.seal({ ssn: "078-05-1120" }) };
23
+ // // ... upsert `payload`; the server only ever sees ciphertext for `ssn`.
24
+ // const secret = cipher.open(payload); // -> { ssn: "078-05-1120" }
25
+ //
26
+ // Use a dedicated key for payload encryption; never reuse your at-rest
27
+ // `QUIVER_ENCRYPTION_KEY`. The client owns the key — losing it means the data
28
+ // is unrecoverable.
29
+ import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
30
+ /** The reserved payload key under which a sealed envelope is stored. */
31
+ export const ENVELOPE_KEY = "__quiver_enc__";
32
+ const VERSION = 1;
33
+ const ALG = "xchacha20poly1305";
34
+ const NONCE_LEN = 24;
35
+ const TAG_LEN = 16;
36
+ const AAD = new TextEncoder().encode("quiver/payload/v1");
37
+ /** An error sealing or opening a client-side payload envelope. */
38
+ export class PayloadError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "PayloadError";
42
+ }
43
+ }
44
+ /** A client-held key for sealing and opening payload envelopes (ADR-0012). */
45
+ export class PayloadCipher {
46
+ #key;
47
+ constructor(key) {
48
+ this.#key = key;
49
+ }
50
+ /** Build a cipher from a raw 256-bit (32-byte) key. */
51
+ static fromBytes(key) {
52
+ if (key.length !== 32) {
53
+ throw new PayloadError(`payload key must be 32 bytes, got ${key.length}`);
54
+ }
55
+ return new PayloadCipher(Uint8Array.from(key));
56
+ }
57
+ /** Build a cipher from a 64-character hex-encoded 256-bit key. */
58
+ static fromHex(hex) {
59
+ const clean = hex.trim();
60
+ if (!/^[0-9a-fA-F]{64}$/.test(clean)) {
61
+ throw new PayloadError(`payload key must be 64 hex characters, got ${clean.length}`);
62
+ }
63
+ const key = new Uint8Array(32);
64
+ for (let i = 0; i < 32; i++) {
65
+ key[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
66
+ }
67
+ return new PayloadCipher(key);
68
+ }
69
+ /** Seal `plaintext` into a one-key envelope `{ [ENVELOPE_KEY]: { … } }`. Each
70
+ * call uses a fresh random nonce, so the same value seals to different
71
+ * ciphertext. */
72
+ seal(plaintext) {
73
+ const json = JSON.stringify(plaintext);
74
+ if (json === undefined) {
75
+ throw new PayloadError("cannot seal a value that is not JSON-serializable");
76
+ }
77
+ const nonce = randomBytes(NONCE_LEN);
78
+ const ciphertext = new XChaCha20Poly1305(this.#key).seal(nonce, new TextEncoder().encode(json), AAD);
79
+ return {
80
+ [ENVELOPE_KEY]: {
81
+ v: VERSION,
82
+ alg: ALG,
83
+ n: toBase64(nonce),
84
+ ct: toBase64(ciphertext),
85
+ },
86
+ };
87
+ }
88
+ /** Open an envelope sealed by {@link seal}, returning the plaintext. `sealed`
89
+ * may carry cleartext sibling fields; only the reserved key is read. A wrong
90
+ * key or any tampering throws {@link PayloadError}. */
91
+ open(sealed) {
92
+ if (!isSealed(sealed)) {
93
+ throw new PayloadError("payload is not a quiver-encrypted envelope");
94
+ }
95
+ const envelope = sealed[ENVELOPE_KEY];
96
+ if (typeof envelope !== "object" || envelope === null) {
97
+ throw new PayloadError("envelope is not an object");
98
+ }
99
+ const env = envelope;
100
+ if (env.v !== VERSION) {
101
+ throw new PayloadError(`unsupported envelope version: ${String(env.v)}`);
102
+ }
103
+ if (env.alg !== ALG) {
104
+ throw new PayloadError(`unsupported envelope algorithm: ${String(env.alg)}`);
105
+ }
106
+ const nonce = decodeField(env, "n");
107
+ if (nonce.length !== NONCE_LEN) {
108
+ throw new PayloadError(`nonce is ${nonce.length} bytes, expected ${NONCE_LEN}`);
109
+ }
110
+ const ciphertext = decodeField(env, "ct");
111
+ if (ciphertext.length < TAG_LEN) {
112
+ throw new PayloadError(`ciphertext is ${ciphertext.length} bytes, shorter than the ${TAG_LEN}-byte tag`);
113
+ }
114
+ const message = new XChaCha20Poly1305(this.#key).open(nonce, ciphertext, AAD);
115
+ if (message === null) {
116
+ throw new PayloadError("wrong key or tampered ciphertext");
117
+ }
118
+ return JSON.parse(new TextDecoder().decode(message));
119
+ }
120
+ }
121
+ /** Whether `value` carries a Quiver payload envelope. */
122
+ export function isSealed(value) {
123
+ return typeof value === "object" && value !== null && ENVELOPE_KEY in value;
124
+ }
125
+ function decodeField(envelope, field) {
126
+ const raw = envelope[field];
127
+ if (typeof raw !== "string") {
128
+ throw new PayloadError(`missing envelope field '${field}'`);
129
+ }
130
+ return fromBase64(raw);
131
+ }
132
+ function randomBytes(length) {
133
+ const webcrypto = globalThis.crypto;
134
+ if (!webcrypto?.getRandomValues) {
135
+ throw new PayloadError("a Web Crypto getRandomValues implementation is required");
136
+ }
137
+ return webcrypto.getRandomValues(new Uint8Array(length));
138
+ }
139
+ function toBase64(bytes) {
140
+ let binary = "";
141
+ const chunk = 0x8000;
142
+ for (let i = 0; i < bytes.length; i += chunk) {
143
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
144
+ }
145
+ return btoa(binary);
146
+ }
147
+ function fromBase64(text) {
148
+ const binary = atob(text);
149
+ const bytes = new Uint8Array(binary.length);
150
+ for (let i = 0; i < binary.length; i++) {
151
+ bytes[i] = binary.charCodeAt(i);
152
+ }
153
+ return bytes;
154
+ }
@@ -0,0 +1 @@
1
+ export { Client, QuiverError, type Point, type Match, type Document, type DocumentMatch, type CollectionInfo, type IndexKind, type Metric, type ClientOptions, type CreateCollectionOptions, type SearchOptions, type SparseVector, type HybridSearchOptions, type TextPoint, type SearchTextOptions, type SnapshotInfo, } from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ export { Client, QuiverError, } from "./client.js";
@@ -0,0 +1,26 @@
1
+ /** The reserved payload key under which a sealed vector envelope is stored. */
2
+ export declare const VECTOR_ENVELOPE_KEY = "__quiver_vec__";
3
+ /** An error sealing or opening a client-side vector envelope. */
4
+ export declare class VectorError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /** A client-held key for sealing and opening vector envelopes (ADR-0032). */
8
+ export declare class VectorCipher {
9
+ #private;
10
+ private constructor();
11
+ /** Build a cipher from a raw 256-bit (32-byte) key. */
12
+ static fromBytes(key: Uint8Array): VectorCipher;
13
+ /** Build a cipher from a 64-character hex-encoded 256-bit key. */
14
+ static fromHex(hex: string): VectorCipher;
15
+ /** Seal `vector` into a one-key envelope `{ [VECTOR_ENVELOPE_KEY]: { … } }`. Each
16
+ * call uses a fresh random nonce, so the same vector seals to different
17
+ * ciphertext. The vector's f32 components are written little-endian to match the
18
+ * Rust reference regardless of host endianness. */
19
+ seal(vector: ArrayLike<number>): Record<string, unknown>;
20
+ /** Open an envelope sealed by {@link seal}, returning the vector. `sealed` may
21
+ * carry cleartext sibling fields; only the reserved key is read. A wrong key or
22
+ * any tampering throws {@link VectorError}. */
23
+ open(sealed: unknown): number[];
24
+ }
25
+ /** Whether `value` carries a Quiver vector envelope. */
26
+ export declare function isSealedVector(value: unknown): boolean;