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/vector.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
//! Client-side opaque vector encryption (ADR-0032) for the Quiver TypeScript SDK.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors `quiver_crypto::vector` byte-for-byte: seals a vector's raw
|
|
5
|
+
// little-endian f32 bytes with XChaCha20-Poly1305 (the audited
|
|
6
|
+
// `@stablelib/xchacha20poly1305`, interoperating with the Rust and Python
|
|
7
|
+
// implementations) under the reserved `__quiver_vec__` key. The server stores the
|
|
8
|
+
// blob (in the payload) plus a zero placeholder vector, does no distance math, and
|
|
9
|
+
// never sees the key; the client fetches the entitled set, decrypts, and ranks
|
|
10
|
+
// locally. It is the semantically secure end of Quiver's encrypted-search
|
|
11
|
+
// spectrum: unlike DCPE it leaks nothing about the vectors. Because the sealed
|
|
12
|
+
// message is raw bytes, interop with the Rust/Python impls is bit-exact.
|
|
13
|
+
//
|
|
14
|
+
// This module lives at the `quiver-client/vector` subpath so the core client
|
|
15
|
+
// stays dependency-free; `@stablelib/xchacha20poly1305` is an optional peer
|
|
16
|
+
// dependency you install only to use these helpers:
|
|
17
|
+
//
|
|
18
|
+
// pnpm add @stablelib/xchacha20poly1305
|
|
19
|
+
//
|
|
20
|
+
// import { VectorCipher } from "quiver-client/vector";
|
|
21
|
+
// const cipher = VectorCipher.fromHex("…64 hex chars…");
|
|
22
|
+
// const payload = { tier: "gold", ...cipher.seal([0.1, -0.2, 0.3, 0.4]) };
|
|
23
|
+
// // ... upsert with a zero placeholder vector + this payload; later:
|
|
24
|
+
// const vector = cipher.open(payload); // -> [0.1, -0.2, 0.3, 0.4] (bit-exact)
|
|
25
|
+
//
|
|
26
|
+
// Use a dedicated key for vector encryption; never reuse your at-rest
|
|
27
|
+
// `QUIVER_ENCRYPTION_KEY`. The client owns the key — losing it means the vectors
|
|
28
|
+
// are unrecoverable.
|
|
29
|
+
import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
|
|
30
|
+
/** The reserved payload key under which a sealed vector envelope is stored. */
|
|
31
|
+
export const VECTOR_ENVELOPE_KEY = "__quiver_vec__";
|
|
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/vector/v1");
|
|
37
|
+
/** An error sealing or opening a client-side vector envelope. */
|
|
38
|
+
export class VectorError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "VectorError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** A client-held key for sealing and opening vector envelopes (ADR-0032). */
|
|
45
|
+
export class VectorCipher {
|
|
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 VectorError(`vector key must be 32 bytes, got ${key.length}`);
|
|
54
|
+
}
|
|
55
|
+
return new VectorCipher(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 VectorError(`vector 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 VectorCipher(key);
|
|
68
|
+
}
|
|
69
|
+
/** Seal `vector` into a one-key envelope `{ [VECTOR_ENVELOPE_KEY]: { … } }`. Each
|
|
70
|
+
* call uses a fresh random nonce, so the same vector seals to different
|
|
71
|
+
* ciphertext. The vector's f32 components are written little-endian to match the
|
|
72
|
+
* Rust reference regardless of host endianness. */
|
|
73
|
+
seal(vector) {
|
|
74
|
+
const dim = vector.length;
|
|
75
|
+
const buffer = new ArrayBuffer(dim * 4);
|
|
76
|
+
const view = new DataView(buffer);
|
|
77
|
+
for (let i = 0; i < dim; i++) {
|
|
78
|
+
view.setFloat32(i * 4, Number(vector[i]), true);
|
|
79
|
+
}
|
|
80
|
+
const nonce = randomBytes(NONCE_LEN);
|
|
81
|
+
const ciphertext = new XChaCha20Poly1305(this.#key).seal(nonce, new Uint8Array(buffer), AAD);
|
|
82
|
+
return {
|
|
83
|
+
[VECTOR_ENVELOPE_KEY]: {
|
|
84
|
+
v: VERSION,
|
|
85
|
+
alg: ALG,
|
|
86
|
+
dim,
|
|
87
|
+
n: toBase64(nonce),
|
|
88
|
+
ct: toBase64(ciphertext),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Open an envelope sealed by {@link seal}, returning the vector. `sealed` may
|
|
93
|
+
* carry cleartext sibling fields; only the reserved key is read. A wrong key or
|
|
94
|
+
* any tampering throws {@link VectorError}. */
|
|
95
|
+
open(sealed) {
|
|
96
|
+
if (!isSealedVector(sealed)) {
|
|
97
|
+
throw new VectorError("value is not a quiver-encrypted vector envelope");
|
|
98
|
+
}
|
|
99
|
+
const envelope = sealed[VECTOR_ENVELOPE_KEY];
|
|
100
|
+
if (typeof envelope !== "object" || envelope === null) {
|
|
101
|
+
throw new VectorError("envelope is not an object");
|
|
102
|
+
}
|
|
103
|
+
const env = envelope;
|
|
104
|
+
if (env.v !== VERSION) {
|
|
105
|
+
throw new VectorError(`unsupported envelope version: ${String(env.v)}`);
|
|
106
|
+
}
|
|
107
|
+
if (env.alg !== ALG) {
|
|
108
|
+
throw new VectorError(`unsupported envelope algorithm: ${String(env.alg)}`);
|
|
109
|
+
}
|
|
110
|
+
const dim = env.dim;
|
|
111
|
+
if (typeof dim !== "number" || !Number.isInteger(dim) || dim < 0) {
|
|
112
|
+
throw new VectorError(`missing or invalid dim: ${String(dim)}`);
|
|
113
|
+
}
|
|
114
|
+
const nonce = decodeField(env, "n");
|
|
115
|
+
if (nonce.length !== NONCE_LEN) {
|
|
116
|
+
throw new VectorError(`nonce is ${nonce.length} bytes, expected ${NONCE_LEN}`);
|
|
117
|
+
}
|
|
118
|
+
const ciphertext = decodeField(env, "ct");
|
|
119
|
+
if (ciphertext.length < TAG_LEN) {
|
|
120
|
+
throw new VectorError(`ciphertext is ${ciphertext.length} bytes, shorter than the ${TAG_LEN}-byte tag`);
|
|
121
|
+
}
|
|
122
|
+
const message = new XChaCha20Poly1305(this.#key).open(nonce, ciphertext, AAD);
|
|
123
|
+
if (message === null) {
|
|
124
|
+
throw new VectorError("wrong key or tampered ciphertext");
|
|
125
|
+
}
|
|
126
|
+
if (message.length !== dim * 4) {
|
|
127
|
+
throw new VectorError(`decrypted ${message.length} bytes, expected ${dim * 4} for dim ${dim}`);
|
|
128
|
+
}
|
|
129
|
+
const view = new DataView(message.buffer, message.byteOffset, message.byteLength);
|
|
130
|
+
const out = [];
|
|
131
|
+
for (let i = 0; i < dim; i++) {
|
|
132
|
+
out.push(view.getFloat32(i * 4, true));
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Whether `value` carries a Quiver vector envelope. */
|
|
138
|
+
export function isSealedVector(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && VECTOR_ENVELOPE_KEY in value;
|
|
140
|
+
}
|
|
141
|
+
function decodeField(envelope, field) {
|
|
142
|
+
const raw = envelope[field];
|
|
143
|
+
if (typeof raw !== "string") {
|
|
144
|
+
throw new VectorError(`missing envelope field '${field}'`);
|
|
145
|
+
}
|
|
146
|
+
return fromBase64(raw);
|
|
147
|
+
}
|
|
148
|
+
function randomBytes(length) {
|
|
149
|
+
const webcrypto = globalThis.crypto;
|
|
150
|
+
if (!webcrypto?.getRandomValues) {
|
|
151
|
+
throw new VectorError("a Web Crypto getRandomValues implementation is required");
|
|
152
|
+
}
|
|
153
|
+
return webcrypto.getRandomValues(new Uint8Array(length));
|
|
154
|
+
}
|
|
155
|
+
function toBase64(bytes) {
|
|
156
|
+
let binary = "";
|
|
157
|
+
const chunk = 0x8000;
|
|
158
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
159
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
160
|
+
}
|
|
161
|
+
return btoa(binary);
|
|
162
|
+
}
|
|
163
|
+
function fromBase64(text) {
|
|
164
|
+
const binary = atob(text);
|
|
165
|
+
const bytes = new Uint8Array(binary.length);
|
|
166
|
+
for (let i = 0; i < binary.length; i++) {
|
|
167
|
+
bytes[i] = binary.charCodeAt(i);
|
|
168
|
+
}
|
|
169
|
+
return bytes;
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quiver-client",
|
|
3
|
+
"version": "0.22.0",
|
|
4
|
+
"description": "TypeScript client for Quiver — a security-first, memory-frugal vector database.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"packageManager": "pnpm@11.6.0",
|
|
7
|
+
"author": "Achref Soua <achref.soua@outlook.com>",
|
|
8
|
+
"homepage": "https://github.com/achref-soua/quiver",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"vector-database",
|
|
11
|
+
"embeddings",
|
|
12
|
+
"search",
|
|
13
|
+
"ann",
|
|
14
|
+
"quiver"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./encryption": {
|
|
25
|
+
"types": "./dist/encryption.d.ts",
|
|
26
|
+
"import": "./dist/encryption.js"
|
|
27
|
+
},
|
|
28
|
+
"./vector": {
|
|
29
|
+
"types": "./dist/vector.d.ts",
|
|
30
|
+
"import": "./dist/vector.js"
|
|
31
|
+
},
|
|
32
|
+
"./dcpe": {
|
|
33
|
+
"types": "./dist/dcpe.d.ts",
|
|
34
|
+
"import": "./dist/dcpe.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@stablelib/chacha": "^2.0.0",
|
|
51
|
+
"@stablelib/hkdf": "^2.0.0",
|
|
52
|
+
"@stablelib/hmac": "^2.0.0",
|
|
53
|
+
"@stablelib/sha256": "^2.0.0",
|
|
54
|
+
"@stablelib/xchacha20poly1305": "^2.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@stablelib/chacha": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"@stablelib/hkdf": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@stablelib/hmac": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@stablelib/sha256": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@stablelib/xchacha20poly1305": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@stablelib/chacha": "^2.0.1",
|
|
75
|
+
"@stablelib/hkdf": "^2.0.1",
|
|
76
|
+
"@stablelib/hmac": "^2.0.1",
|
|
77
|
+
"@stablelib/sha256": "^2.0.1",
|
|
78
|
+
"@stablelib/xchacha20poly1305": "^2.0.1",
|
|
79
|
+
"@types/node": "^25.0.0",
|
|
80
|
+
"typescript": "^6.0.0",
|
|
81
|
+
"vitest": "^4.1.0"
|
|
82
|
+
},
|
|
83
|
+
"repository": {
|
|
84
|
+
"type": "git",
|
|
85
|
+
"url": "https://github.com/achref-soua/quiver"
|
|
86
|
+
}
|
|
87
|
+
}
|