@optimystic/quereus-plugin-crypto 0.21.0 → 0.24.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/src/crypto.ts CHANGED
@@ -1,547 +1,547 @@
1
- /**
2
- * Cryptographic Functions for Quereus
3
- *
4
- * Idiomatic ES module exports with base64url as default encoding.
5
- * All functions accept and return base64url strings by default for SQL compatibility.
6
- */
7
-
8
- import { sha256, sha512 } from '@noble/hashes/sha2.js';
9
- import { blake3 } from '@noble/hashes/blake3.js';
10
- import { randomBytes as nobleRandomBytes, utf8ToBytes, concatBytes } from '@noble/hashes/utils.js';
11
- import { secp256k1 } from '@noble/curves/secp256k1.js';
12
- import { p256 } from '@noble/curves/nist.js';
13
- import { ed25519 } from '@noble/curves/ed25519.js';
14
- import { hexToBytes, bytesToHex } from '@noble/curves/utils.js';
15
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
16
-
17
- // Type definitions
18
- export type HashAlgorithm = 'sha256' | 'sha512' | 'blake3';
19
- export type CurveType = 'secp256k1' | 'p256' | 'ed25519';
20
- export type Encoding = 'base64url' | 'base64' | 'hex' | 'utf8' | 'bytes';
21
-
22
- /** Encodings valid for hash *output* (no 'utf8' — a digest is not UTF-8 text). */
23
- export type OutputEncoding = 'base64url' | 'base64' | 'hex' | 'bytes';
24
-
25
- /** A single value in a multi-field digest. Mirrors the SQL value space. */
26
- export type DigestField =
27
- | string
28
- | number
29
- | bigint
30
- | boolean
31
- | Uint8Array
32
- | null
33
- | undefined
34
- | { readonly [key: string]: unknown }
35
- | readonly unknown[];
36
-
37
- /** A resolved hash function: raw bytes in, digest bytes out. */
38
- export type DigestHasher = (input: Uint8Array) => Uint8Array;
39
-
40
- /** A resolved output encoder: digest bytes in, encoded form out. */
41
- export type OutputEncoder = (bytes: Uint8Array) => string | Uint8Array;
42
-
43
- /**
44
- * Convert input to Uint8Array, handling various encodings
45
- */
46
- function toBytes(input: string | Uint8Array | null | undefined, encoding: Encoding = 'base64url'): Uint8Array {
47
- if (input === null || input === undefined) {
48
- return new Uint8Array(0);
49
- }
50
-
51
- if (input instanceof Uint8Array) {
52
- return input;
53
- }
54
-
55
- if (typeof input === 'string') {
56
- switch (encoding) {
57
- case 'base64url':
58
- return uint8ArrayFromString(input, 'base64url');
59
- case 'base64':
60
- return uint8ArrayFromString(input, 'base64');
61
- case 'hex':
62
- return hexToBytes(input);
63
- case 'utf8':
64
- return utf8ToBytes(input);
65
- default:
66
- return uint8ArrayFromString(input, 'base64url');
67
- }
68
- }
69
-
70
- throw new Error('Invalid input type');
71
- }
72
-
73
- /**
74
- * Convert Uint8Array to string in specified encoding
75
- */
76
- function fromBytes(bytes: Uint8Array, encoding: Encoding = 'base64url'): string | Uint8Array {
77
- switch (encoding) {
78
- case 'base64url':
79
- return uint8ArrayToString(bytes, 'base64url');
80
- case 'base64':
81
- return uint8ArrayToString(bytes, 'base64');
82
- case 'hex':
83
- return bytesToHex(bytes);
84
- case 'utf8':
85
- return uint8ArrayToString(bytes, 'utf8');
86
- case 'bytes':
87
- return bytes;
88
- default:
89
- return uint8ArrayToString(bytes, 'base64url');
90
- }
91
- }
92
-
93
- // --- Algorithm / encoding resolution (done once, no per-call switching) --- //
94
-
95
- /** Hash algorithm → noble hasher. Keyed lookup so the digest hot path never branches. */
96
- const HASHERS: Record<HashAlgorithm, DigestHasher> = {
97
- sha256,
98
- sha512,
99
- blake3,
100
- };
101
-
102
- /** Output encoding → encoder closure. */
103
- const OUTPUT_ENCODERS: Record<OutputEncoding, OutputEncoder> = {
104
- base64url: (bytes) => uint8ArrayToString(bytes, 'base64url'),
105
- base64: (bytes) => uint8ArrayToString(bytes, 'base64'),
106
- hex: (bytes) => bytesToHex(bytes),
107
- bytes: (bytes) => bytes,
108
- };
109
-
110
- /**
111
- * Resolve a hash algorithm name to its hasher. Throws on unknown algorithm.
112
- * Call once (e.g. at plugin registration) and capture the result so the digest
113
- * hot path performs no per-call algorithm branching.
114
- */
115
- export function resolveHasher(algorithm: HashAlgorithm): DigestHasher {
116
- const hasher = HASHERS[algorithm];
117
- if (!hasher) {
118
- throw new Error(`Unsupported hash algorithm: ${algorithm}`);
119
- }
120
- return hasher;
121
- }
122
-
123
- /**
124
- * Resolve an output encoding name to its encoder. Throws on unknown encoding.
125
- */
126
- export function resolveOutputEncoder(encoding: OutputEncoding): OutputEncoder {
127
- const encoder = OUTPUT_ENCODERS[encoding];
128
- if (!encoder) {
129
- throw new Error(`Unsupported output encoding: ${encoding}`);
130
- }
131
- return encoder;
132
- }
133
-
134
- // --- Canonical, injective multi-field encoding --- //
135
-
136
- /**
137
- * Format version for {@link encodeFields}. Prepended to every encoding so the
138
- * framing can evolve, and so a framed digest is domain-separated from a bare
139
- * hash of the same bytes. Bump only with a deliberate, breaking format change.
140
- */
141
- const DIGEST_FORMAT_V1 = 0x01;
142
-
143
- // Per-field type tags. Distinct tags keep distinct SQL types from colliding
144
- // (e.g. INTEGER 123 vs TEXT '123' vs BOOL true).
145
- //
146
- // Note on INT vs REAL: the tag is derived from the JS value, not from SQL
147
- // affinity (a scalar function does not receive affinity). An integer-VALUED
148
- // number — including a REAL like 2.0, which reaches JS as the number 2 — is
149
- // encoded as INTEGER. So INTEGER 2 and REAL 2.0 produce the same digest. This is
150
- // replicable (every peer sees the same JS value) but not int/real-distinguishing.
151
- const TAG_NULL = 0x00; // bare tag, no length/payload
152
- const TAG_INT = 0x01; // payload: canonical decimal string (number-integer & bigint unified via BigInt)
153
- const TAG_REAL = 0x02; // payload: ECMAScript Number::toString (non-integer numbers only)
154
- const TAG_TEXT = 0x03; // payload: UTF-8 bytes
155
- const TAG_BOOL = 0x04; // payload: single 0x00/0x01 byte
156
- const TAG_BLOB = 0x05; // payload: raw bytes
157
- const TAG_JSON = 0x06; // payload: UTF-8 of key-sorted canonical JSON
158
-
159
- /** Append an unsigned LEB128 varint (safe for lengths up to MAX_SAFE_INTEGER). */
160
- function writeVarint(out: number[], value: number): void {
161
- if (!Number.isInteger(value) || value < 0) {
162
- throw new Error(`varint expects a non-negative integer, got ${value}`);
163
- }
164
- let v = value;
165
- while (v >= 0x80) {
166
- out.push((v & 0x7f) | 0x80);
167
- v = Math.floor(v / 128);
168
- }
169
- out.push(v);
170
- }
171
-
172
- /** tag ‖ varint(len) ‖ payload */
173
- function framed(tag: number, payload: Uint8Array): Uint8Array {
174
- const header: number[] = [tag];
175
- writeVarint(header, payload.length);
176
- return concatBytes(Uint8Array.from(header), payload);
177
- }
178
-
179
- /**
180
- * Strict, deterministic JSON canonicalization for a native object/array field:
181
- * object keys recursively sorted, no incidental whitespace. Unlike `JSON.stringify`,
182
- * it THROWS rather than silently collapsing non-JSON inputs (`undefined`, non-finite
183
- * numbers, `bigint`, non-plain objects like `Date`/`Map`) — silent collapse would
184
- * break injectivity (`{a:undefined}` vs `{}`, `NaN` vs `null`, `new Date(0)` vs `{}`).
185
- */
186
- function canonicalJson(value: unknown): string {
187
- if (value === null) return 'null';
188
- const t = typeof value;
189
- if (t === 'string') return JSON.stringify(value);
190
- if (t === 'boolean') return value ? 'true' : 'false';
191
- if (t === 'number') {
192
- if (!Number.isFinite(value)) {
193
- throw new Error('digest: cannot encode a non-finite number inside a JSON field');
194
- }
195
- return JSON.stringify(value) as string; // deterministic Number::toString
196
- }
197
- if (t === 'bigint') {
198
- throw new Error('digest: bigint is not representable inside a JSON field');
199
- }
200
- if (Array.isArray(value)) {
201
- return `[${value.map((el) => {
202
- if (el === undefined) {
203
- throw new Error('digest: undefined / sparse element inside a JSON field');
204
- }
205
- return canonicalJson(el);
206
- }).join(',')}]`;
207
- }
208
- if (t === 'object') {
209
- const proto = Object.getPrototypeOf(value);
210
- if (proto !== Object.prototype && proto !== null) {
211
- throw new Error('digest: only plain objects are allowed inside a JSON field');
212
- }
213
- const obj = value as Record<string, unknown>;
214
- const keys = Object.keys(obj).sort();
215
- return `{${keys.map((k) => {
216
- if (obj[k] === undefined) {
217
- throw new Error(`digest: undefined value for JSON key '${k}'`);
218
- }
219
- return `${JSON.stringify(k)}:${canonicalJson(obj[k])}`;
220
- }).join(',')}}`;
221
- }
222
- throw new Error(`digest: unsupported value of type '${t}' inside a JSON field`);
223
- }
224
-
225
- /** Encode one field as tag (‖ length ‖ payload). NULL/undefined is a bare tag. */
226
- function encodeField(field: DigestField): Uint8Array {
227
- if (field === null || field === undefined) {
228
- return Uint8Array.of(TAG_NULL);
229
- }
230
- switch (typeof field) {
231
- case 'boolean':
232
- return Uint8Array.of(TAG_BOOL, field ? 1 : 0);
233
- case 'bigint':
234
- return framed(TAG_INT, utf8ToBytes(field.toString()));
235
- case 'number':
236
- if (!Number.isFinite(field)) {
237
- throw new Error('digest: cannot encode a non-finite number');
238
- }
239
- // Integer-valued numbers go through BigInt so they encode identically to
240
- // the equal-valued bigint (e.g. 1e21 → full digits, not "1e+21").
241
- return Number.isInteger(field)
242
- ? framed(TAG_INT, utf8ToBytes(BigInt(field).toString()))
243
- : framed(TAG_REAL, utf8ToBytes(field.toString()));
244
- case 'string':
245
- return framed(TAG_TEXT, utf8ToBytes(field));
246
- case 'object':
247
- if (field instanceof Uint8Array) {
248
- return framed(TAG_BLOB, field);
249
- }
250
- return framed(TAG_JSON, utf8ToBytes(canonicalJson(field)));
251
- default:
252
- throw new Error(`digest: unsupported field type '${typeof field}'`);
253
- }
254
- }
255
-
256
- /**
257
- * Canonically encode an ordered tuple of fields into bytes such that distinct
258
- * tuples never collide (injective framing).
259
- *
260
- * Layout: `version ‖ field*` where each field is `tag ‖ varint(len) ‖ payload`
261
- * (NULL is a bare tag). Properties:
262
- * - order-preserving and arity-safe (self-delimiting fields → uniquely decodable),
263
- * - NULL distinguishable from empty string,
264
- * - type distinguishable (INTEGER 123 ≠ TEXT '123' ≠ BOOL true ≠ BLOB),
265
- * - delimiter-safe (a separator inside a string is just payload under its length).
266
- *
267
- * Replicability notes:
268
- * - Integer `number` and `bigint` of equal value encode identically (both via
269
- * `BigInt(...).toString()`); a non-integer REAL uses ECMAScript `Number::toString`
270
- * (deterministic across JS engines, but not guaranteed across other languages).
271
- * - INT vs REAL is derived from the JS value, not SQL affinity: an integer-valued
272
- * REAL (e.g. 2.0 → number 2) encodes as INTEGER, so INTEGER 2 and REAL 2.0 collide.
273
- * - A native JSON object/array field must contain only valid JSON (no `undefined`,
274
- * non-finite numbers, `bigint`, or non-plain objects) — otherwise it throws.
275
- */
276
- export function encodeFields(fields: readonly DigestField[]): Uint8Array {
277
- const chunks: Uint8Array[] = [Uint8Array.of(DIGEST_FORMAT_V1)];
278
- for (const field of fields) {
279
- chunks.push(encodeField(field));
280
- }
281
- return concatBytes(...chunks);
282
- }
283
-
284
- /**
285
- * Low-level multi-field digest: canonically encode the fields, then hash and
286
- * encode with the supplied (pre-resolved) hasher/encoder. No per-call branching
287
- * on algorithm or encoding — resolve once via {@link resolveHasher} /
288
- * {@link resolveOutputEncoder} and reuse.
289
- */
290
- export function digestFields(
291
- fields: readonly DigestField[],
292
- hasher: DigestHasher,
293
- encode: OutputEncoder
294
- ): string | Uint8Array {
295
- return encode(hasher(encodeFields(fields)));
296
- }
297
-
298
- /**
299
- * Compute an injective digest over an ordered tuple of fields.
300
- *
301
- * @param fields - Ordered tuple of values to hash (any SQL value type)
302
- * @param algorithm - Hash algorithm (default: 'sha256')
303
- * @param encoding - Output encoding (default: 'base64url')
304
- * @returns Hash digest in the specified encoding
305
- *
306
- * @example
307
- * ```typescript
308
- * // Hash a tuple of fields — distinct tuples never collide
309
- * const h = digest(['alice', 42, null, true]);
310
- *
311
- * // Pick algorithm / output encoding
312
- * const h512 = digest(['a', 'b'], 'sha512', 'hex');
313
- * ```
314
- *
315
- * Note: this is a *framed* digest, not a bare hash of raw bytes —
316
- * `digest(['hello'])` is not `sha256("hello")`. Use `hashMod` for sharding a
317
- * single value.
318
- */
319
- export function digest(
320
- fields: readonly DigestField[],
321
- algorithm: HashAlgorithm = 'sha256',
322
- encoding: OutputEncoding = 'base64url'
323
- ): string | Uint8Array {
324
- if (!Array.isArray(fields)) {
325
- throw new Error(
326
- `digest(fields, algorithm?, encoding?): 'fields' must be an array of values. ` +
327
- `The digest API changed in v0.14: it is now variadic/injective over fields, ` +
328
- `the per-call inputEncoding was removed, and algorithm + output encoding are bound at plugin load time. ` +
329
- `Migrate digest(value, algo, inputEncoding, outputEncoding) → digest([value], algo, outputEncoding) — ` +
330
- `note the result is now a *framed* digest, not a bare hash of the bytes.`
331
- );
332
- }
333
- return digestFields(fields, resolveHasher(algorithm), resolveOutputEncoder(encoding));
334
- }
335
-
336
- /**
337
- * Hash data and return modulo of specified bit length
338
- * Useful for generating fixed-size hash values (e.g., 16-bit, 32-bit)
339
- *
340
- * @param data - Data to hash
341
- * @param bits - Number of bits for the result (e.g., 16 for 16-bit hash)
342
- * @param algorithm - Hash algorithm (default: 'sha256')
343
- * @param inputEncoding - Encoding of input string (default: 'base64url')
344
- * @returns Integer hash value modulo 2^bits
345
- *
346
- * @example
347
- * ```typescript
348
- * // Get 16-bit hash (0-65535)
349
- * const hash16 = hashMod('hello', 16, 'sha256', 'utf8');
350
- *
351
- * // Get 32-bit hash
352
- * const hash32 = hashMod('world', 32, 'sha256', 'utf8');
353
- * ```
354
- */
355
- export function hashMod(
356
- data: string | Uint8Array,
357
- bits: number,
358
- algorithm: HashAlgorithm = 'sha256',
359
- inputEncoding: Encoding = 'base64url'
360
- ): number {
361
- if (bits <= 0 || bits > 53) {
362
- throw new Error('Bits must be between 1 and 53 (JavaScript safe integer limit)');
363
- }
364
-
365
- // Single-blob hash for sharding (not the field-framed digest).
366
- const hashBytes = resolveHasher(algorithm)(toBytes(data, inputEncoding));
367
-
368
- // Take first 8 bytes and convert to number
369
- const view = new DataView(hashBytes.buffer, hashBytes.byteOffset, Math.min(8, hashBytes.length));
370
- const fullHash = view.getBigUint64(0, false); // big-endian
371
-
372
- // Modulo by 2^bits
373
- const modulus = BigInt(2) ** BigInt(bits);
374
- const result = fullHash % modulus;
375
-
376
- return Number(result);
377
- }
378
-
379
- /**
380
- * Sign data with a private key
381
- *
382
- * @param data - Data to sign (typically a hash)
383
- * @param privateKey - Private key (base64url string or Uint8Array)
384
- * @param curve - Elliptic curve (default: 'secp256k1')
385
- * @param inputEncoding - Encoding of data input (default: 'base64url')
386
- * @param keyEncoding - Encoding of private key (default: 'base64url')
387
- * @param outputEncoding - Encoding of signature output (default: 'base64url')
388
- * @returns Signature in specified encoding
389
- *
390
- * @example
391
- * ```typescript
392
- * // Sign a hash with secp256k1
393
- * const sig = sign(hashData, privateKey);
394
- *
395
- * // Sign with Ed25519
396
- * const sig2 = sign(hashData, privateKey, 'ed25519');
397
- * ```
398
- */
399
- export function sign(
400
- data: string | Uint8Array,
401
- privateKey: string | Uint8Array,
402
- curve: CurveType = 'secp256k1',
403
- inputEncoding: Encoding = 'base64url',
404
- keyEncoding: Encoding = 'base64url',
405
- outputEncoding: Encoding = 'base64url'
406
- ): string | Uint8Array {
407
- const dataBytes = toBytes(data, inputEncoding);
408
- const keyBytes = toBytes(privateKey, keyEncoding);
409
-
410
- let sigBytes: Uint8Array;
411
-
412
- switch (curve) {
413
- case 'secp256k1':
414
- sigBytes = secp256k1.sign(dataBytes, keyBytes, { lowS: true });
415
- break;
416
- case 'p256':
417
- sigBytes = p256.sign(dataBytes, keyBytes, { lowS: true });
418
- break;
419
- case 'ed25519':
420
- sigBytes = ed25519.sign(dataBytes, keyBytes);
421
- break;
422
- default:
423
- throw new Error(`Unsupported curve: ${curve}`);
424
- }
425
-
426
- return fromBytes(sigBytes, outputEncoding);
427
- }
428
-
429
- /**
430
- * Verify a signature
431
- *
432
- * @param data - Data that was signed
433
- * @param signature - Signature to verify
434
- * @param publicKey - Public key
435
- * @param curve - Elliptic curve (default: 'secp256k1')
436
- * @param inputEncoding - Encoding of data input (default: 'base64url')
437
- * @param sigEncoding - Encoding of signature (default: 'base64url')
438
- * @param keyEncoding - Encoding of public key (default: 'base64url')
439
- * @returns true if signature is valid, false otherwise
440
- *
441
- * @example
442
- * ```typescript
443
- * // Verify a signature
444
- * const isValid = verify(hashData, signature, publicKey);
445
- *
446
- * // Verify with Ed25519
447
- * const isValid2 = verify(hashData, signature, publicKey, 'ed25519');
448
- * ```
449
- */
450
- export function verify(
451
- data: string | Uint8Array,
452
- signature: string | Uint8Array,
453
- publicKey: string | Uint8Array,
454
- curve: CurveType = 'secp256k1',
455
- inputEncoding: Encoding = 'base64url',
456
- sigEncoding: Encoding = 'base64url',
457
- keyEncoding: Encoding = 'base64url'
458
- ): boolean {
459
- try {
460
- const dataBytes = toBytes(data, inputEncoding);
461
- const sigBytes = toBytes(signature, sigEncoding);
462
- const keyBytes = toBytes(publicKey, keyEncoding);
463
-
464
- switch (curve) {
465
- case 'secp256k1': {
466
- return secp256k1.verify(sigBytes, dataBytes, keyBytes);
467
- }
468
- case 'p256': {
469
- return p256.verify(sigBytes, dataBytes, keyBytes);
470
- }
471
- case 'ed25519': {
472
- return ed25519.verify(sigBytes, dataBytes, keyBytes);
473
- }
474
- default:
475
- throw new Error(`Unsupported curve: ${curve}`);
476
- }
477
- } catch {
478
- return false;
479
- }
480
- }
481
-
482
- /**
483
- * Generate cryptographically secure random bytes
484
- *
485
- * @param bits - Number of bits to generate (default: 256)
486
- * @param encoding - Output encoding (default: 'base64url')
487
- * @returns Random bytes in the specified encoding
488
- */
489
- export function randomBytes(bits: number = 256, encoding: Encoding = 'base64url'): string | Uint8Array {
490
- const bytes = Math.ceil(bits / 8);
491
- const randomBytesArray = nobleRandomBytes(bytes);
492
- return fromBytes(randomBytesArray, encoding);
493
- }
494
-
495
- /**
496
- * Generate a random private key
497
- */
498
- export function generatePrivateKey(curve: CurveType = 'secp256k1', encoding: Encoding = 'base64url'): string | Uint8Array {
499
- let keyBytes: Uint8Array;
500
-
501
- switch (curve) {
502
- case 'secp256k1':
503
- keyBytes = secp256k1.utils.randomSecretKey();
504
- break;
505
- case 'p256':
506
- keyBytes = p256.utils.randomSecretKey();
507
- break;
508
- case 'ed25519':
509
- keyBytes = ed25519.utils.randomSecretKey();
510
- break;
511
- default:
512
- throw new Error(`Unsupported curve: ${curve}`);
513
- }
514
-
515
- return fromBytes(keyBytes, encoding);
516
- }
517
-
518
- /**
519
- * Get public key from private key
520
- */
521
- export function getPublicKey(
522
- privateKey: string | Uint8Array,
523
- curve: CurveType = 'secp256k1',
524
- keyEncoding: Encoding = 'base64url',
525
- outputEncoding: Encoding = 'base64url'
526
- ): string | Uint8Array {
527
- const keyBytes = toBytes(privateKey, keyEncoding);
528
-
529
- let pubBytes: Uint8Array;
530
-
531
- switch (curve) {
532
- case 'secp256k1':
533
- pubBytes = secp256k1.getPublicKey(keyBytes);
534
- break;
535
- case 'p256':
536
- pubBytes = p256.getPublicKey(keyBytes);
537
- break;
538
- case 'ed25519':
539
- pubBytes = ed25519.getPublicKey(keyBytes);
540
- break;
541
- default:
542
- throw new Error(`Unsupported curve: ${curve}`);
543
- }
544
-
545
- return fromBytes(pubBytes, outputEncoding);
546
- }
547
-
1
+ /**
2
+ * Cryptographic Functions for Quereus
3
+ *
4
+ * Idiomatic ES module exports with base64url as default encoding.
5
+ * All functions accept and return base64url strings by default for SQL compatibility.
6
+ */
7
+
8
+ import { sha256, sha512 } from '@noble/hashes/sha2.js';
9
+ import { blake3 } from '@noble/hashes/blake3.js';
10
+ import { randomBytes as nobleRandomBytes, utf8ToBytes, concatBytes } from '@noble/hashes/utils.js';
11
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
12
+ import { p256 } from '@noble/curves/nist.js';
13
+ import { ed25519 } from '@noble/curves/ed25519.js';
14
+ import { hexToBytes, bytesToHex } from '@noble/curves/utils.js';
15
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
16
+
17
+ // Type definitions
18
+ export type HashAlgorithm = 'sha256' | 'sha512' | 'blake3';
19
+ export type CurveType = 'secp256k1' | 'p256' | 'ed25519';
20
+ export type Encoding = 'base64url' | 'base64' | 'hex' | 'utf8' | 'bytes';
21
+
22
+ /** Encodings valid for hash *output* (no 'utf8' — a digest is not UTF-8 text). */
23
+ export type OutputEncoding = 'base64url' | 'base64' | 'hex' | 'bytes';
24
+
25
+ /** A single value in a multi-field digest. Mirrors the SQL value space. */
26
+ export type DigestField =
27
+ | string
28
+ | number
29
+ | bigint
30
+ | boolean
31
+ | Uint8Array
32
+ | null
33
+ | undefined
34
+ | { readonly [key: string]: unknown }
35
+ | readonly unknown[];
36
+
37
+ /** A resolved hash function: raw bytes in, digest bytes out. */
38
+ export type DigestHasher = (input: Uint8Array) => Uint8Array;
39
+
40
+ /** A resolved output encoder: digest bytes in, encoded form out. */
41
+ export type OutputEncoder = (bytes: Uint8Array) => string | Uint8Array;
42
+
43
+ /**
44
+ * Convert input to Uint8Array, handling various encodings
45
+ */
46
+ function toBytes(input: string | Uint8Array | null | undefined, encoding: Encoding = 'base64url'): Uint8Array {
47
+ if (input === null || input === undefined) {
48
+ return new Uint8Array(0);
49
+ }
50
+
51
+ if (input instanceof Uint8Array) {
52
+ return input;
53
+ }
54
+
55
+ if (typeof input === 'string') {
56
+ switch (encoding) {
57
+ case 'base64url':
58
+ return uint8ArrayFromString(input, 'base64url');
59
+ case 'base64':
60
+ return uint8ArrayFromString(input, 'base64');
61
+ case 'hex':
62
+ return hexToBytes(input);
63
+ case 'utf8':
64
+ return utf8ToBytes(input);
65
+ default:
66
+ return uint8ArrayFromString(input, 'base64url');
67
+ }
68
+ }
69
+
70
+ throw new Error('Invalid input type');
71
+ }
72
+
73
+ /**
74
+ * Convert Uint8Array to string in specified encoding
75
+ */
76
+ function fromBytes(bytes: Uint8Array, encoding: Encoding = 'base64url'): string | Uint8Array {
77
+ switch (encoding) {
78
+ case 'base64url':
79
+ return uint8ArrayToString(bytes, 'base64url');
80
+ case 'base64':
81
+ return uint8ArrayToString(bytes, 'base64');
82
+ case 'hex':
83
+ return bytesToHex(bytes);
84
+ case 'utf8':
85
+ return uint8ArrayToString(bytes, 'utf8');
86
+ case 'bytes':
87
+ return bytes;
88
+ default:
89
+ return uint8ArrayToString(bytes, 'base64url');
90
+ }
91
+ }
92
+
93
+ // --- Algorithm / encoding resolution (done once, no per-call switching) --- //
94
+
95
+ /** Hash algorithm → noble hasher. Keyed lookup so the digest hot path never branches. */
96
+ const HASHERS: Record<HashAlgorithm, DigestHasher> = {
97
+ sha256,
98
+ sha512,
99
+ blake3,
100
+ };
101
+
102
+ /** Output encoding → encoder closure. */
103
+ const OUTPUT_ENCODERS: Record<OutputEncoding, OutputEncoder> = {
104
+ base64url: (bytes) => uint8ArrayToString(bytes, 'base64url'),
105
+ base64: (bytes) => uint8ArrayToString(bytes, 'base64'),
106
+ hex: (bytes) => bytesToHex(bytes),
107
+ bytes: (bytes) => bytes,
108
+ };
109
+
110
+ /**
111
+ * Resolve a hash algorithm name to its hasher. Throws on unknown algorithm.
112
+ * Call once (e.g. at plugin registration) and capture the result so the digest
113
+ * hot path performs no per-call algorithm branching.
114
+ */
115
+ export function resolveHasher(algorithm: HashAlgorithm): DigestHasher {
116
+ const hasher = HASHERS[algorithm];
117
+ if (!hasher) {
118
+ throw new Error(`Unsupported hash algorithm: ${algorithm}`);
119
+ }
120
+ return hasher;
121
+ }
122
+
123
+ /**
124
+ * Resolve an output encoding name to its encoder. Throws on unknown encoding.
125
+ */
126
+ export function resolveOutputEncoder(encoding: OutputEncoding): OutputEncoder {
127
+ const encoder = OUTPUT_ENCODERS[encoding];
128
+ if (!encoder) {
129
+ throw new Error(`Unsupported output encoding: ${encoding}`);
130
+ }
131
+ return encoder;
132
+ }
133
+
134
+ // --- Canonical, injective multi-field encoding --- //
135
+
136
+ /**
137
+ * Format version for {@link encodeFields}. Prepended to every encoding so the
138
+ * framing can evolve, and so a framed digest is domain-separated from a bare
139
+ * hash of the same bytes. Bump only with a deliberate, breaking format change.
140
+ */
141
+ const DIGEST_FORMAT_V1 = 0x01;
142
+
143
+ // Per-field type tags. Distinct tags keep distinct SQL types from colliding
144
+ // (e.g. INTEGER 123 vs TEXT '123' vs BOOL true).
145
+ //
146
+ // Note on INT vs REAL: the tag is derived from the JS value, not from SQL
147
+ // affinity (a scalar function does not receive affinity). An integer-VALUED
148
+ // number — including a REAL like 2.0, which reaches JS as the number 2 — is
149
+ // encoded as INTEGER. So INTEGER 2 and REAL 2.0 produce the same digest. This is
150
+ // replicable (every peer sees the same JS value) but not int/real-distinguishing.
151
+ const TAG_NULL = 0x00; // bare tag, no length/payload
152
+ const TAG_INT = 0x01; // payload: canonical decimal string (number-integer & bigint unified via BigInt)
153
+ const TAG_REAL = 0x02; // payload: ECMAScript Number::toString (non-integer numbers only)
154
+ const TAG_TEXT = 0x03; // payload: UTF-8 bytes
155
+ const TAG_BOOL = 0x04; // payload: single 0x00/0x01 byte
156
+ const TAG_BLOB = 0x05; // payload: raw bytes
157
+ const TAG_JSON = 0x06; // payload: UTF-8 of key-sorted canonical JSON
158
+
159
+ /** Append an unsigned LEB128 varint (safe for lengths up to MAX_SAFE_INTEGER). */
160
+ function writeVarint(out: number[], value: number): void {
161
+ if (!Number.isInteger(value) || value < 0) {
162
+ throw new Error(`varint expects a non-negative integer, got ${value}`);
163
+ }
164
+ let v = value;
165
+ while (v >= 0x80) {
166
+ out.push((v & 0x7f) | 0x80);
167
+ v = Math.floor(v / 128);
168
+ }
169
+ out.push(v);
170
+ }
171
+
172
+ /** tag ‖ varint(len) ‖ payload */
173
+ function framed(tag: number, payload: Uint8Array): Uint8Array {
174
+ const header: number[] = [tag];
175
+ writeVarint(header, payload.length);
176
+ return concatBytes(Uint8Array.from(header), payload);
177
+ }
178
+
179
+ /**
180
+ * Strict, deterministic JSON canonicalization for a native object/array field:
181
+ * object keys recursively sorted, no incidental whitespace. Unlike `JSON.stringify`,
182
+ * it THROWS rather than silently collapsing non-JSON inputs (`undefined`, non-finite
183
+ * numbers, `bigint`, non-plain objects like `Date`/`Map`) — silent collapse would
184
+ * break injectivity (`{a:undefined}` vs `{}`, `NaN` vs `null`, `new Date(0)` vs `{}`).
185
+ */
186
+ function canonicalJson(value: unknown): string {
187
+ if (value === null) return 'null';
188
+ const t = typeof value;
189
+ if (t === 'string') return JSON.stringify(value);
190
+ if (t === 'boolean') return value ? 'true' : 'false';
191
+ if (t === 'number') {
192
+ if (!Number.isFinite(value)) {
193
+ throw new Error('digest: cannot encode a non-finite number inside a JSON field');
194
+ }
195
+ return JSON.stringify(value) as string; // deterministic Number::toString
196
+ }
197
+ if (t === 'bigint') {
198
+ throw new Error('digest: bigint is not representable inside a JSON field');
199
+ }
200
+ if (Array.isArray(value)) {
201
+ return `[${value.map((el) => {
202
+ if (el === undefined) {
203
+ throw new Error('digest: undefined / sparse element inside a JSON field');
204
+ }
205
+ return canonicalJson(el);
206
+ }).join(',')}]`;
207
+ }
208
+ if (t === 'object') {
209
+ const proto = Object.getPrototypeOf(value);
210
+ if (proto !== Object.prototype && proto !== null) {
211
+ throw new Error('digest: only plain objects are allowed inside a JSON field');
212
+ }
213
+ const obj = value as Record<string, unknown>;
214
+ const keys = Object.keys(obj).sort();
215
+ return `{${keys.map((k) => {
216
+ if (obj[k] === undefined) {
217
+ throw new Error(`digest: undefined value for JSON key '${k}'`);
218
+ }
219
+ return `${JSON.stringify(k)}:${canonicalJson(obj[k])}`;
220
+ }).join(',')}}`;
221
+ }
222
+ throw new Error(`digest: unsupported value of type '${t}' inside a JSON field`);
223
+ }
224
+
225
+ /** Encode one field as tag (‖ length ‖ payload). NULL/undefined is a bare tag. */
226
+ function encodeField(field: DigestField): Uint8Array {
227
+ if (field === null || field === undefined) {
228
+ return Uint8Array.of(TAG_NULL);
229
+ }
230
+ switch (typeof field) {
231
+ case 'boolean':
232
+ return Uint8Array.of(TAG_BOOL, field ? 1 : 0);
233
+ case 'bigint':
234
+ return framed(TAG_INT, utf8ToBytes(field.toString()));
235
+ case 'number':
236
+ if (!Number.isFinite(field)) {
237
+ throw new Error('digest: cannot encode a non-finite number');
238
+ }
239
+ // Integer-valued numbers go through BigInt so they encode identically to
240
+ // the equal-valued bigint (e.g. 1e21 → full digits, not "1e+21").
241
+ return Number.isInteger(field)
242
+ ? framed(TAG_INT, utf8ToBytes(BigInt(field).toString()))
243
+ : framed(TAG_REAL, utf8ToBytes(field.toString()));
244
+ case 'string':
245
+ return framed(TAG_TEXT, utf8ToBytes(field));
246
+ case 'object':
247
+ if (field instanceof Uint8Array) {
248
+ return framed(TAG_BLOB, field);
249
+ }
250
+ return framed(TAG_JSON, utf8ToBytes(canonicalJson(field)));
251
+ default:
252
+ throw new Error(`digest: unsupported field type '${typeof field}'`);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Canonically encode an ordered tuple of fields into bytes such that distinct
258
+ * tuples never collide (injective framing).
259
+ *
260
+ * Layout: `version ‖ field*` where each field is `tag ‖ varint(len) ‖ payload`
261
+ * (NULL is a bare tag). Properties:
262
+ * - order-preserving and arity-safe (self-delimiting fields → uniquely decodable),
263
+ * - NULL distinguishable from empty string,
264
+ * - type distinguishable (INTEGER 123 ≠ TEXT '123' ≠ BOOL true ≠ BLOB),
265
+ * - delimiter-safe (a separator inside a string is just payload under its length).
266
+ *
267
+ * Replicability notes:
268
+ * - Integer `number` and `bigint` of equal value encode identically (both via
269
+ * `BigInt(...).toString()`); a non-integer REAL uses ECMAScript `Number::toString`
270
+ * (deterministic across JS engines, but not guaranteed across other languages).
271
+ * - INT vs REAL is derived from the JS value, not SQL affinity: an integer-valued
272
+ * REAL (e.g. 2.0 → number 2) encodes as INTEGER, so INTEGER 2 and REAL 2.0 collide.
273
+ * - A native JSON object/array field must contain only valid JSON (no `undefined`,
274
+ * non-finite numbers, `bigint`, or non-plain objects) — otherwise it throws.
275
+ */
276
+ export function encodeFields(fields: readonly DigestField[]): Uint8Array {
277
+ const chunks: Uint8Array[] = [Uint8Array.of(DIGEST_FORMAT_V1)];
278
+ for (const field of fields) {
279
+ chunks.push(encodeField(field));
280
+ }
281
+ return concatBytes(...chunks);
282
+ }
283
+
284
+ /**
285
+ * Low-level multi-field digest: canonically encode the fields, then hash and
286
+ * encode with the supplied (pre-resolved) hasher/encoder. No per-call branching
287
+ * on algorithm or encoding — resolve once via {@link resolveHasher} /
288
+ * {@link resolveOutputEncoder} and reuse.
289
+ */
290
+ export function digestFields(
291
+ fields: readonly DigestField[],
292
+ hasher: DigestHasher,
293
+ encode: OutputEncoder
294
+ ): string | Uint8Array {
295
+ return encode(hasher(encodeFields(fields)));
296
+ }
297
+
298
+ /**
299
+ * Compute an injective digest over an ordered tuple of fields.
300
+ *
301
+ * @param fields - Ordered tuple of values to hash (any SQL value type)
302
+ * @param algorithm - Hash algorithm (default: 'sha256')
303
+ * @param encoding - Output encoding (default: 'base64url')
304
+ * @returns Hash digest in the specified encoding
305
+ *
306
+ * @example
307
+ * ```typescript
308
+ * // Hash a tuple of fields — distinct tuples never collide
309
+ * const h = digest(['alice', 42, null, true]);
310
+ *
311
+ * // Pick algorithm / output encoding
312
+ * const h512 = digest(['a', 'b'], 'sha512', 'hex');
313
+ * ```
314
+ *
315
+ * Note: this is a *framed* digest, not a bare hash of raw bytes —
316
+ * `digest(['hello'])` is not `sha256("hello")`. Use `hashMod` for sharding a
317
+ * single value.
318
+ */
319
+ export function digest(
320
+ fields: readonly DigestField[],
321
+ algorithm: HashAlgorithm = 'sha256',
322
+ encoding: OutputEncoding = 'base64url'
323
+ ): string | Uint8Array {
324
+ if (!Array.isArray(fields)) {
325
+ throw new Error(
326
+ `digest(fields, algorithm?, encoding?): 'fields' must be an array of values. ` +
327
+ `The digest API changed in v0.14: it is now variadic/injective over fields, ` +
328
+ `the per-call inputEncoding was removed, and algorithm + output encoding are bound at plugin load time. ` +
329
+ `Migrate digest(value, algo, inputEncoding, outputEncoding) → digest([value], algo, outputEncoding) — ` +
330
+ `note the result is now a *framed* digest, not a bare hash of the bytes.`
331
+ );
332
+ }
333
+ return digestFields(fields, resolveHasher(algorithm), resolveOutputEncoder(encoding));
334
+ }
335
+
336
+ /**
337
+ * Hash data and return modulo of specified bit length
338
+ * Useful for generating fixed-size hash values (e.g., 16-bit, 32-bit)
339
+ *
340
+ * @param data - Data to hash
341
+ * @param bits - Number of bits for the result (e.g., 16 for 16-bit hash)
342
+ * @param algorithm - Hash algorithm (default: 'sha256')
343
+ * @param inputEncoding - Encoding of input string (default: 'base64url')
344
+ * @returns Integer hash value modulo 2^bits
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * // Get 16-bit hash (0-65535)
349
+ * const hash16 = hashMod('hello', 16, 'sha256', 'utf8');
350
+ *
351
+ * // Get 32-bit hash
352
+ * const hash32 = hashMod('world', 32, 'sha256', 'utf8');
353
+ * ```
354
+ */
355
+ export function hashMod(
356
+ data: string | Uint8Array,
357
+ bits: number,
358
+ algorithm: HashAlgorithm = 'sha256',
359
+ inputEncoding: Encoding = 'base64url'
360
+ ): number {
361
+ if (bits <= 0 || bits > 53) {
362
+ throw new Error('Bits must be between 1 and 53 (JavaScript safe integer limit)');
363
+ }
364
+
365
+ // Single-blob hash for sharding (not the field-framed digest).
366
+ const hashBytes = resolveHasher(algorithm)(toBytes(data, inputEncoding));
367
+
368
+ // Take first 8 bytes and convert to number
369
+ const view = new DataView(hashBytes.buffer, hashBytes.byteOffset, Math.min(8, hashBytes.length));
370
+ const fullHash = view.getBigUint64(0, false); // big-endian
371
+
372
+ // Modulo by 2^bits
373
+ const modulus = BigInt(2) ** BigInt(bits);
374
+ const result = fullHash % modulus;
375
+
376
+ return Number(result);
377
+ }
378
+
379
+ /**
380
+ * Sign data with a private key
381
+ *
382
+ * @param data - Data to sign (typically a hash)
383
+ * @param privateKey - Private key (base64url string or Uint8Array)
384
+ * @param curve - Elliptic curve (default: 'secp256k1')
385
+ * @param inputEncoding - Encoding of data input (default: 'base64url')
386
+ * @param keyEncoding - Encoding of private key (default: 'base64url')
387
+ * @param outputEncoding - Encoding of signature output (default: 'base64url')
388
+ * @returns Signature in specified encoding
389
+ *
390
+ * @example
391
+ * ```typescript
392
+ * // Sign a hash with secp256k1
393
+ * const sig = sign(hashData, privateKey);
394
+ *
395
+ * // Sign with Ed25519
396
+ * const sig2 = sign(hashData, privateKey, 'ed25519');
397
+ * ```
398
+ */
399
+ export function sign(
400
+ data: string | Uint8Array,
401
+ privateKey: string | Uint8Array,
402
+ curve: CurveType = 'secp256k1',
403
+ inputEncoding: Encoding = 'base64url',
404
+ keyEncoding: Encoding = 'base64url',
405
+ outputEncoding: Encoding = 'base64url'
406
+ ): string | Uint8Array {
407
+ const dataBytes = toBytes(data, inputEncoding);
408
+ const keyBytes = toBytes(privateKey, keyEncoding);
409
+
410
+ let sigBytes: Uint8Array;
411
+
412
+ switch (curve) {
413
+ case 'secp256k1':
414
+ sigBytes = secp256k1.sign(dataBytes, keyBytes, { lowS: true });
415
+ break;
416
+ case 'p256':
417
+ sigBytes = p256.sign(dataBytes, keyBytes, { lowS: true });
418
+ break;
419
+ case 'ed25519':
420
+ sigBytes = ed25519.sign(dataBytes, keyBytes);
421
+ break;
422
+ default:
423
+ throw new Error(`Unsupported curve: ${curve}`);
424
+ }
425
+
426
+ return fromBytes(sigBytes, outputEncoding);
427
+ }
428
+
429
+ /**
430
+ * Verify a signature
431
+ *
432
+ * @param data - Data that was signed
433
+ * @param signature - Signature to verify
434
+ * @param publicKey - Public key
435
+ * @param curve - Elliptic curve (default: 'secp256k1')
436
+ * @param inputEncoding - Encoding of data input (default: 'base64url')
437
+ * @param sigEncoding - Encoding of signature (default: 'base64url')
438
+ * @param keyEncoding - Encoding of public key (default: 'base64url')
439
+ * @returns true if signature is valid, false otherwise
440
+ *
441
+ * @example
442
+ * ```typescript
443
+ * // Verify a signature
444
+ * const isValid = verify(hashData, signature, publicKey);
445
+ *
446
+ * // Verify with Ed25519
447
+ * const isValid2 = verify(hashData, signature, publicKey, 'ed25519');
448
+ * ```
449
+ */
450
+ export function verify(
451
+ data: string | Uint8Array,
452
+ signature: string | Uint8Array,
453
+ publicKey: string | Uint8Array,
454
+ curve: CurveType = 'secp256k1',
455
+ inputEncoding: Encoding = 'base64url',
456
+ sigEncoding: Encoding = 'base64url',
457
+ keyEncoding: Encoding = 'base64url'
458
+ ): boolean {
459
+ try {
460
+ const dataBytes = toBytes(data, inputEncoding);
461
+ const sigBytes = toBytes(signature, sigEncoding);
462
+ const keyBytes = toBytes(publicKey, keyEncoding);
463
+
464
+ switch (curve) {
465
+ case 'secp256k1': {
466
+ return secp256k1.verify(sigBytes, dataBytes, keyBytes);
467
+ }
468
+ case 'p256': {
469
+ return p256.verify(sigBytes, dataBytes, keyBytes);
470
+ }
471
+ case 'ed25519': {
472
+ return ed25519.verify(sigBytes, dataBytes, keyBytes);
473
+ }
474
+ default:
475
+ throw new Error(`Unsupported curve: ${curve}`);
476
+ }
477
+ } catch {
478
+ return false;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Generate cryptographically secure random bytes
484
+ *
485
+ * @param bits - Number of bits to generate (default: 256)
486
+ * @param encoding - Output encoding (default: 'base64url')
487
+ * @returns Random bytes in the specified encoding
488
+ */
489
+ export function randomBytes(bits: number = 256, encoding: Encoding = 'base64url'): string | Uint8Array {
490
+ const bytes = Math.ceil(bits / 8);
491
+ const randomBytesArray = nobleRandomBytes(bytes);
492
+ return fromBytes(randomBytesArray, encoding);
493
+ }
494
+
495
+ /**
496
+ * Generate a random private key
497
+ */
498
+ export function generatePrivateKey(curve: CurveType = 'secp256k1', encoding: Encoding = 'base64url'): string | Uint8Array {
499
+ let keyBytes: Uint8Array;
500
+
501
+ switch (curve) {
502
+ case 'secp256k1':
503
+ keyBytes = secp256k1.utils.randomSecretKey();
504
+ break;
505
+ case 'p256':
506
+ keyBytes = p256.utils.randomSecretKey();
507
+ break;
508
+ case 'ed25519':
509
+ keyBytes = ed25519.utils.randomSecretKey();
510
+ break;
511
+ default:
512
+ throw new Error(`Unsupported curve: ${curve}`);
513
+ }
514
+
515
+ return fromBytes(keyBytes, encoding);
516
+ }
517
+
518
+ /**
519
+ * Get public key from private key
520
+ */
521
+ export function getPublicKey(
522
+ privateKey: string | Uint8Array,
523
+ curve: CurveType = 'secp256k1',
524
+ keyEncoding: Encoding = 'base64url',
525
+ outputEncoding: Encoding = 'base64url'
526
+ ): string | Uint8Array {
527
+ const keyBytes = toBytes(privateKey, keyEncoding);
528
+
529
+ let pubBytes: Uint8Array;
530
+
531
+ switch (curve) {
532
+ case 'secp256k1':
533
+ pubBytes = secp256k1.getPublicKey(keyBytes);
534
+ break;
535
+ case 'p256':
536
+ pubBytes = p256.getPublicKey(keyBytes);
537
+ break;
538
+ case 'ed25519':
539
+ pubBytes = ed25519.getPublicKey(keyBytes);
540
+ break;
541
+ default:
542
+ throw new Error(`Unsupported curve: ${curve}`);
543
+ }
544
+
545
+ return fromBytes(pubBytes, outputEncoding);
546
+ }
547
+