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/README.md +91 -0
- package/dist/client.d.ts +258 -0
- package/dist/client.js +431 -0
- package/dist/dcpe.d.ts +57 -0
- package/dist/dcpe.js +336 -0
- package/dist/encryption.d.ts +25 -0
- package/dist/encryption.js +154 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/vector.d.ts +26 -0
- package/dist/vector.js +170 -0
- package/package.json +87 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/vector.d.ts
ADDED
|
@@ -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;
|