@optimystic/quereus-plugin-crypto 0.13.5 → 0.16.2

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
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { sha256, sha512 } from '@noble/hashes/sha2.js';
9
9
  import { blake3 } from '@noble/hashes/blake3.js';
10
- import { randomBytes as nobleRandomBytes, utf8ToBytes } from '@noble/hashes/utils.js';
10
+ import { randomBytes as nobleRandomBytes, utf8ToBytes, concatBytes } from '@noble/hashes/utils.js';
11
11
  import { secp256k1 } from '@noble/curves/secp256k1.js';
12
12
  import { p256 } from '@noble/curves/nist.js';
13
13
  import { ed25519 } from '@noble/curves/ed25519.js';
@@ -19,6 +19,27 @@ export type HashAlgorithm = 'sha256' | 'sha512' | 'blake3';
19
19
  export type CurveType = 'secp256k1' | 'p256' | 'ed25519';
20
20
  export type Encoding = 'base64url' | 'base64' | 'hex' | 'utf8' | 'bytes';
21
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
+
22
43
  /**
23
44
  * Convert input to Uint8Array, handling various encodings
24
45
  */
@@ -69,51 +90,247 @@ function fromBytes(bytes: Uint8Array, encoding: Encoding = 'base64url'): string
69
90
  }
70
91
  }
71
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
+
72
284
  /**
73
- * Compute hash digest of input data
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.
74
300
  *
75
- * @param data - Data to hash (base64url string or Uint8Array)
301
+ * @param fields - Ordered tuple of values to hash (any SQL value type)
76
302
  * @param algorithm - Hash algorithm (default: 'sha256')
77
- * @param inputEncoding - Encoding of input string (default: 'base64url')
78
- * @param outputEncoding - Encoding of output (default: 'base64url')
79
- * @returns Hash digest in specified encoding
303
+ * @param encoding - Output encoding (default: 'base64url')
304
+ * @returns Hash digest in the specified encoding
80
305
  *
81
306
  * @example
82
307
  * ```typescript
83
- * // Hash UTF-8 text, output as base64url
84
- * const hash = digest('hello world', 'sha256', 'utf8');
308
+ * // Hash a tuple of fields — distinct tuples never collide
309
+ * const h = digest(['alice', 42, null, true]);
85
310
  *
86
- * // Hash base64url data with SHA-512
87
- * const hash2 = digest('SGVsbG8', 'sha512');
88
- *
89
- * // Get raw bytes
90
- * const bytes = digest('data', 'blake3', 'utf8', 'bytes');
311
+ * // Pick algorithm / output encoding
312
+ * const h512 = digest(['a', 'b'], 'sha512', 'hex');
91
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.
92
318
  */
93
319
  export function digest(
94
- data: string | Uint8Array,
320
+ fields: readonly DigestField[],
95
321
  algorithm: HashAlgorithm = 'sha256',
96
- inputEncoding: Encoding = 'base64url',
97
- outputEncoding: Encoding = 'base64url'
322
+ encoding: OutputEncoding = 'base64url'
98
323
  ): string | Uint8Array {
99
- const bytes = toBytes(data, inputEncoding);
100
-
101
- let hashBytes: Uint8Array;
102
- switch (algorithm) {
103
- case 'sha256':
104
- hashBytes = sha256(bytes);
105
- break;
106
- case 'sha512':
107
- hashBytes = sha512(bytes);
108
- break;
109
- case 'blake3':
110
- hashBytes = blake3(bytes);
111
- break;
112
- default:
113
- throw new Error(`Unsupported hash algorithm: ${algorithm}`);
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
+ );
114
332
  }
115
-
116
- return fromBytes(hashBytes, outputEncoding);
333
+ return digestFields(fields, resolveHasher(algorithm), resolveOutputEncoder(encoding));
117
334
  }
118
335
 
119
336
  /**
@@ -145,7 +362,8 @@ export function hashMod(
145
362
  throw new Error('Bits must be between 1 and 53 (JavaScript safe integer limit)');
146
363
  }
147
364
 
148
- const hashBytes = toBytes(digest(data, algorithm, inputEncoding, 'base64url') as string, 'base64url');
365
+ // Single-blob hash for sharding (not the field-framed digest).
366
+ const hashBytes = resolveHasher(algorithm)(toBytes(data, inputEncoding));
149
367
 
150
368
  // Take first 8 bytes and convert to number
151
369
  const view = new DataView(hashBytes.buffer, hashBytes.byteOffset, Math.min(8, hashBytes.length));
package/src/index.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  * Quereus Crypto Functions Plugin
3
3
  *
4
4
  * Provides cryptographic functions for SQL queries and ES modules:
5
- * - digest: Hash functions (SHA-256, SHA-512, BLAKE3) with base64url encoding
5
+ * - digest: Injective multi-field hash (SHA-256, SHA-512, BLAKE3) with base64url encoding
6
+ * - cid / cidV1 / cidDecode: Self-describing, interoperable CIDv1 content identifiers
7
+ * - setCommit / setDisclose / setVerify: Salted-leaf set commitment for per-attribute selective disclosure
6
8
  * - sign: ECC signature generation (secp256k1, P-256, Ed25519)
7
9
  * - verify: ECC signature verification
8
10
  * - hashMod: Hash with modulo for fixed-size outputs
@@ -14,6 +16,10 @@
14
16
  // Export idiomatic lowercase functions (primary API)
15
17
  export {
16
18
  digest,
19
+ digestFields,
20
+ encodeFields,
21
+ resolveHasher,
22
+ resolveOutputEncoder,
17
23
  sign,
18
24
  verify,
19
25
  hashMod,
@@ -23,11 +29,30 @@ export {
23
29
  type HashAlgorithm,
24
30
  type CurveType,
25
31
  type Encoding,
32
+ type OutputEncoding,
33
+ type DigestField,
34
+ type DigestHasher,
35
+ type OutputEncoder,
26
36
  } from './crypto.js';
27
37
 
28
- // Legacy exports for backwards compatibility (if needed)
29
- export { Digest, type DigestInput, type DigestOptions } from './digest.js';
30
- export { Sign, type PrivateKeyInput, type SignOptions } from './sign.js';
31
- export { SignatureValid, type BytesInput, type VerifyOptions } from './signature-valid.js';
38
+ // Self-describing content identifiers (CIDv1) layered on top of digest.
39
+ export {
40
+ cid,
41
+ cidV1,
42
+ cidDecode,
43
+ type Multicodec,
44
+ type MultihashCode,
45
+ type Multibase,
46
+ type CidParts,
47
+ } from './cid.js';
32
48
 
49
+ // Salted-leaf set commitment for per-attribute selective disclosure, layered on digest.
50
+ export {
51
+ leafDigest,
52
+ setCommit,
53
+ setDisclose,
54
+ setVerify,
55
+ type SaltedLeaf,
56
+ type SetDisclosure,
57
+ } from './sd.js';
33
58
 
package/src/plugin.ts CHANGED
@@ -7,7 +7,101 @@
7
7
 
8
8
  import type { Database, SqlValue } from '@quereus/quereus';
9
9
  import { FunctionFlags, TEXT_TYPE, INTEGER_TYPE, BOOLEAN_TYPE } from '@quereus/quereus';
10
- import { digest, sign, verify, hashMod, randomBytes } from './crypto.js';
10
+ import { fromString as uint8ArrayFromString, toString as uint8ArrayToString } from 'uint8arrays';
11
+ import {
12
+ sign, verify, hashMod, randomBytes,
13
+ digestFields, resolveHasher, resolveOutputEncoder,
14
+ type HashAlgorithm, type OutputEncoding, type DigestField,
15
+ } from './crypto.js';
16
+ import {
17
+ cid, cidV1, cidDecode,
18
+ type Multicodec, type MultihashCode, type Multibase,
19
+ } from './cid.js';
20
+ import {
21
+ setCommit, setVerify,
22
+ type SaltedLeaf,
23
+ } from './sd.js';
24
+
25
+ const DIGEST_ALGORITHMS: readonly HashAlgorithm[] = ['sha256', 'sha512', 'blake3'];
26
+ // SQL digest returns TEXT, so only text-producing encodings are valid here ('bytes' is JS-only).
27
+ const DIGEST_TEXT_ENCODINGS: readonly OutputEncoding[] = ['base64url', 'base64', 'hex'];
28
+
29
+ function configAlgorithm(config: Record<string, SqlValue>): HashAlgorithm {
30
+ const value = config.algorithm == null ? 'sha256' : String(config.algorithm);
31
+ if (!DIGEST_ALGORITHMS.includes(value as HashAlgorithm)) {
32
+ throw new Error(`crypto plugin: unsupported digest algorithm '${value}' (expected one of ${DIGEST_ALGORITHMS.join(', ')})`);
33
+ }
34
+ return value as HashAlgorithm;
35
+ }
36
+
37
+ function configEncoding(config: Record<string, SqlValue>): OutputEncoding {
38
+ const value = config.encoding == null ? 'base64url' : String(config.encoding);
39
+ if (!DIGEST_TEXT_ENCODINGS.includes(value as OutputEncoding)) {
40
+ throw new Error(`crypto plugin: unsupported digest encoding '${value}' (expected one of ${DIGEST_TEXT_ENCODINGS.join(', ')})`);
41
+ }
42
+ return value as OutputEncoding;
43
+ }
44
+
45
+ /**
46
+ * Coerce a SQL `data`/`digest` argument to raw bytes. A BLOB arrives as a
47
+ * Uint8Array and is used directly; TEXT is interpreted as base64url — the
48
+ * plugin's canonical text encoding — so `cid_v1(digest(...))` composes with the
49
+ * base64url string `digest` returns, with no extra round-trip.
50
+ */
51
+ function toContentBytes(value: SqlValue | undefined, fnName: string): Uint8Array {
52
+ if (value instanceof Uint8Array) {
53
+ return value;
54
+ }
55
+ if (typeof value === 'string') {
56
+ return uint8ArrayFromString(value, 'base64url');
57
+ }
58
+ throw new Error(`${fnName}: expected a BLOB or base64url TEXT argument, got ${value == null ? 'NULL' : typeof value}`);
59
+ }
60
+
61
+ /**
62
+ * Parse one JSON leaf — either `[name, value, salt]` or `{ name, value, salt }` —
63
+ * into a {@link SaltedLeaf}. The JSON value maps directly to the `encodeFields` value
64
+ * space (INTEGER vs REAL by JS value, TEXT, BOOL, null, nested object/array → JSON).
65
+ *
66
+ * Note: a BLOB-valued attribute is passed as its base64url TEXT and committed AS TEXT
67
+ * (JSON has no blob type); callers needing a true BLOB value must use the JS API with a
68
+ * `Uint8Array`. The salt is likewise base64url TEXT (decoded to bytes by `set_commit`).
69
+ */
70
+ function leafFromJson(entry: unknown, fnName: string): SaltedLeaf {
71
+ if (Array.isArray(entry)) {
72
+ if (entry.length < 3) {
73
+ throw new Error(`${fnName}: a leaf array must be [name, value, salt]`);
74
+ }
75
+ const [name, value, salt] = entry;
76
+ if (typeof name !== 'string') {
77
+ throw new Error(`${fnName}: leaf name must be a string`);
78
+ }
79
+ return { name, value: value as DigestField, salt: salt as string };
80
+ }
81
+ if (entry !== null && typeof entry === 'object') {
82
+ const o = entry as Record<string, unknown>;
83
+ if (typeof o.name !== 'string') {
84
+ throw new Error(`${fnName}: leaf name must be a string`);
85
+ }
86
+ // Require an explicit `value` key (symmetric with the array form, which demands all
87
+ // three positions): a silently-absent value would commit NULL and mask a malformed
88
+ // leaf. To commit a null-valued attribute, pass `value: null` explicitly.
89
+ if (!('value' in o)) {
90
+ throw new Error(`${fnName}: leaf '${o.name}' is missing a value (pass value: null for a null-valued attribute)`);
91
+ }
92
+ return { name: o.name, value: o.value as DigestField, salt: o.salt as string };
93
+ }
94
+ throw new Error(`${fnName}: each leaf must be a [name, value, salt] array or { name, value, salt } object`);
95
+ }
96
+
97
+ /** Parse a JSON array of leaves. Throws on unparseable / non-array input. */
98
+ function parseLeaves(json: string, fnName: string): SaltedLeaf[] {
99
+ const parsed = JSON.parse(json);
100
+ if (!Array.isArray(parsed)) {
101
+ throw new Error(`${fnName}: expected a JSON array of leaves`);
102
+ }
103
+ return parsed.map((entry) => leafFromJson(entry, fnName));
104
+ }
11
105
 
12
106
  // Flags for deterministic functions (UTF8 + DETERMINISTIC)
13
107
  const DETERMINISTIC_FLAGS = FunctionFlags.UTF8 | FunctionFlags.DETERMINISTIC;
@@ -18,18 +112,112 @@ const NON_DETERMINISTIC_FLAGS = FunctionFlags.UTF8;
18
112
  * Plugin registration function
19
113
  * This is called by Quereus when the plugin is loaded
20
114
  */
21
- export default function register(_db: Database, _config: Record<string, SqlValue> = {}) {
115
+ export default function register(_db: Database, config: Record<string, SqlValue> = {}) {
116
+ // Resolve the digest algorithm + output encoding ONCE, at registration, from
117
+ // load-time config — so the per-call path never branches on them, and so the
118
+ // digest is stable for the lifetime of the database (a precondition for
119
+ // `replicable`: every peer that loads the plugin with the same config agrees).
120
+ const digestHasher = resolveHasher(configAlgorithm(config));
121
+ const digestEncoder = resolveOutputEncoder(configEncoding(config));
122
+
22
123
  // Register crypto functions with Quereus
23
124
  const functions = [
24
125
  {
25
126
  schema: {
26
127
  name: 'digest',
27
- numArgs: -1, // Variable arguments: data, algorithm?, inputEncoding?, outputEncoding?
128
+ numArgs: -1, // Variadic over data fields: digest(f1, f2, ..., fN)
28
129
  flags: DETERMINISTIC_FLAGS, // digest is deterministic
130
+ // Bit-identical across peers/platforms — these digests are signed and persisted.
131
+ replicable: true,
132
+ returnType: { typeClass: 'scalar' as const, logicalType: TEXT_TYPE, nullable: false },
133
+ implementation: (...fields: SqlValue[]) =>
134
+ digestFields(fields as DigestField[], digestHasher, digestEncoder) as string,
135
+ },
136
+ },
137
+ {
138
+ schema: {
139
+ name: 'cid',
140
+ numArgs: -1, // cid(data, codec?, hash?, base?) — trailing args optional
141
+ flags: DETERMINISTIC_FLAGS,
142
+ // Self-describing content address; signed/persisted, so byte-identical across peers.
143
+ replicable: true,
29
144
  returnType: { typeClass: 'scalar' as const, logicalType: TEXT_TYPE, nullable: false },
30
145
  implementation: (...args: SqlValue[]) => {
31
- const [data, algorithm = 'sha256', inputEncoding = 'base64url', outputEncoding = 'base64url'] = args;
32
- return digest(data as string, algorithm as any, inputEncoding as any, outputEncoding as any);
146
+ const [data, codec = 'raw', hash = 'sha2-256', base = 'base32'] = args;
147
+ return cid(toContentBytes(data, 'cid'), codec as Multicodec, hash as MultihashCode, base as Multibase);
148
+ },
149
+ },
150
+ },
151
+ {
152
+ schema: {
153
+ name: 'cid_v1',
154
+ numArgs: -1, // cid_v1(digest, hash, codec?, base?) — hash required, trailing args optional
155
+ flags: DETERMINISTIC_FLAGS,
156
+ replicable: true,
157
+ returnType: { typeClass: 'scalar' as const, logicalType: TEXT_TYPE, nullable: false },
158
+ implementation: (...args: SqlValue[]) => {
159
+ const [digest, hash, codec = 'raw', base = 'base32'] = args;
160
+ if (hash == null) {
161
+ throw new Error("cid_v1: 'hash' argument is required (the multihash code asserting which algorithm produced the digest)");
162
+ }
163
+ return cidV1(toContentBytes(digest, 'cid_v1'), hash as MultihashCode, codec as Multicodec, base as Multibase);
164
+ },
165
+ },
166
+ },
167
+ {
168
+ schema: {
169
+ name: 'cid_decode',
170
+ numArgs: 1, // cid_decode(cid) -> JSON text { version, codec, hashCode, digest }
171
+ flags: DETERMINISTIC_FLAGS,
172
+ replicable: true,
173
+ returnType: { typeClass: 'scalar' as const, logicalType: TEXT_TYPE, nullable: false },
174
+ implementation: (value: SqlValue) => {
175
+ const parts = cidDecode(value as string);
176
+ // JSON object (Quereus has native JSON); digest is base64url, the plugin's canonical text encoding.
177
+ return JSON.stringify({
178
+ version: parts.version,
179
+ codec: parts.codec,
180
+ hashCode: parts.hashCode,
181
+ digest: uint8ArrayToString(parts.digest, 'base64url'),
182
+ });
183
+ },
184
+ },
185
+ },
186
+ {
187
+ schema: {
188
+ name: 'set_commit',
189
+ numArgs: 1, // set_commit(leaves_json) -> root over a JSON array of [name, value, salt] leaves
190
+ flags: DETERMINISTIC_FLAGS,
191
+ // The root is signed and persisted as a commitment, same bar as `digest`.
192
+ replicable: true,
193
+ returnType: { typeClass: 'scalar' as const, logicalType: TEXT_TYPE, nullable: false },
194
+ implementation: (leavesJson: SqlValue) => {
195
+ if (typeof leavesJson !== 'string') {
196
+ throw new Error('set_commit: expected a JSON TEXT array of [name, value, salt] leaves');
197
+ }
198
+ const leaves = parseLeaves(leavesJson, 'set_commit');
199
+ return setCommit(leaves, digestHasher, digestEncoder) as string;
200
+ },
201
+ },
202
+ },
203
+ {
204
+ schema: {
205
+ name: 'set_verify',
206
+ numArgs: 3, // set_verify(root, disclosed_json, hidden_json) -> BOOLEAN
207
+ flags: DETERMINISTIC_FLAGS, // pure, not persisted — matches `verify` (no `replicable`)
208
+ returnType: { typeClass: 'scalar' as const, logicalType: BOOLEAN_TYPE, nullable: false },
209
+ implementation: (root: SqlValue, disclosedJson: SqlValue, hiddenJson: SqlValue) => {
210
+ // Forgiving contract (mirrors `verify`): any malformed input → false, never throw.
211
+ try {
212
+ if (typeof root !== 'string' && !(root instanceof Uint8Array)) return false;
213
+ if (typeof disclosedJson !== 'string' || typeof hiddenJson !== 'string') return false;
214
+ const disclosed = parseLeaves(disclosedJson, 'set_verify');
215
+ const hidden = JSON.parse(hiddenJson);
216
+ if (!Array.isArray(hidden) || !hidden.every((h) => typeof h === 'string')) return false;
217
+ return setVerify(root, { disclosed, hidden: hidden as string[] }, digestHasher, digestEncoder);
218
+ } catch {
219
+ return false;
220
+ }
33
221
  },
34
222
  },
35
223
  },
@@ -38,6 +226,15 @@ export default function register(_db: Database, _config: Record<string, SqlValue
38
226
  name: 'sign',
39
227
  numArgs: -1, // Variable arguments: data, privateKey, curve?, inputEncoding?, keyEncoding?, outputEncoding?
40
228
  flags: DETERMINISTIC_FLAGS, // sign is deterministic (same key + data = same signature)
229
+ // Security: passing a private key as the second argument (literal or bound
230
+ // parameter) is SAFE with respect to replication. The Quereus engine rebuilds
231
+ // the replicated statement from evaluated column values, not from source SQL
232
+ // text, so the key argument is discarded before the record is written — peers
233
+ // re-execute `INSERT ... VALUES (<signature>)`, never `sign(..., key)`. What
234
+ // to avoid: storing a raw private key AS a column value, since any persisted
235
+ // column value is replicated. See docs/transactions.md § "Secrets and the
236
+ // replicated statement record" and the regression guard in
237
+ // quereus-plugin-optimystic/test/statement-secret-arg-redaction.spec.ts.
41
238
  returnType: { typeClass: 'scalar' as const, logicalType: TEXT_TYPE, nullable: false },
42
239
  implementation: (...args: SqlValue[]) => {
43
240
  const [data, privateKey, curve = 'secp256k1', inputEncoding = 'base64url', keyEncoding = 'base64url', outputEncoding = 'base64url'] = args;