@optimystic/quereus-plugin-crypto 0.13.5 → 0.14.1

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 CHANGED
@@ -5,6 +5,8 @@ Quereus plugin providing cryptographic functions for SQL queries with base64url
5
5
  ## Features
6
6
 
7
7
  - **Hash Functions**: SHA-256, SHA-512, BLAKE3 hashing with base64url output
8
+ - **Content Identifiers (CIDv1)**: Self-describing, interoperable IPFS/IPLD content addresses
9
+ - **Selective Disclosure**: Salted-leaf set commitment — commit to a whole attribute set with one value, later reveal only a chosen subset with proof of authenticity
8
10
  - **Hash Modulo**: Fixed-size hash values (16-bit, 32-bit, etc.) for sharding and partitioning
9
11
  - **Random Bytes**: Generate cryptographically secure random bytes (default: 256 bits)
10
12
  - **Signature Functions**: secp256k1, P-256, Ed25519 signing with base64url encoding
@@ -28,6 +30,13 @@ import { loadPlugin } from '@quereus/quereus/util/plugin-loader.js';
28
30
 
29
31
  const db = new Database();
30
32
  await loadPlugin('npm:@optimystic/quereus-plugin-crypto', db);
33
+
34
+ // Or configure the digest algorithm / output encoding once, at load time
35
+ // (see "Digest configuration" below):
36
+ await loadPlugin('npm:@optimystic/quereus-plugin-crypto', db, {
37
+ algorithm: 'sha256', // 'sha256' (default) | 'sha512' | 'blake3'
38
+ encoding: 'base64url', // 'base64url' (default) | 'base64' | 'hex'
39
+ });
31
40
  ```
32
41
 
33
42
  ### Direct Import (for JavaScript/TypeScript code)
@@ -45,8 +54,8 @@ import {
45
54
  getPublicKey
46
55
  } from '@optimystic/quereus-plugin-crypto';
47
56
 
48
- // Hash data (base64url by default)
49
- const hash = digest('hello world', 'sha256', 'utf8', 'base64url');
57
+ // Hash an ordered tuple of fields (injective distinct tuples never collide)
58
+ const hash = digest(['hello world', 42, null], 'sha256', 'base64url');
50
59
 
51
60
  // Get a 16-bit hash for sharding
52
61
  const shard = hashMod('user@example.com', 16, 'sha256', 'utf8');
@@ -68,24 +77,164 @@ const isValid = verify(hash, signature, publicKey, 'secp256k1', 'base64url', 'ba
68
77
 
69
78
  All SQL functions use **base64url encoding by default** for inputs and outputs. This is URL-safe and SQL-friendly.
70
79
 
71
- ### digest(data, algorithm?, inputEncoding?, outputEncoding?)
80
+ ### digest(field1, field2, ..., fieldN)
81
+
82
+ Hash an **ordered tuple of fields** into a single digest. `digest` is variadic over
83
+ *data* — every argument is a field, not a configuration option. The algorithm and
84
+ output encoding are chosen once, at load time (see [Digest configuration](#digest-configuration)),
85
+ so they never have to be passed per call.
86
+
87
+ ```sql
88
+ -- Hash a single value
89
+ SELECT digest(Id) as hash;
90
+
91
+ -- Hash a tuple of columns — distinct tuples never collide,
92
+ -- NULL is distinguished from '', and 123 from '123'
93
+ SELECT digest(Tid, Name, ImageRef, NumberRequiredTSAs) as commitment;
94
+ ```
95
+
96
+ **Why variadic + injective?** Hashing several fields by joining them
97
+ (`a || '|' || b`) or by `String()`-concatenation is not injective: `('a|b','c')`
98
+ collides with `('a','b|c')`, `NULL` collides with `''`, and `123` collides with
99
+ `'123'`. `digest` instead applies a canonical, length-prefixed, type-tagged framing
100
+ to each field, so distinct tuples always produce distinct digests. This matters when
101
+ the digest is signed or persisted as a commitment.
102
+
103
+ The framing is also why `digest(x)` is **not** a bare `sha256(x)` — it is a framed
104
+ digest of a one-element tuple. For sharding a single value, use `hash_mod`.
72
105
 
73
- Hash data using SHA-256 (default), SHA-512, or BLAKE3.
106
+ ### cid(data, codec?, hash?, base?) / cid_v1(digest, hash, codec?, base?) / cid_decode(cid)
107
+
108
+ `digest` returns a **bare** hash — raw digest bytes in some text encoding, with no
109
+ record of which base, content type, or hash algorithm produced it. `cid` instead
110
+ produces a **self-describing CIDv1**, the same interoperable content address an
111
+ IPFS/IPLD store computes for the same bytes:
112
+
113
+ ```
114
+ CIDv1 = multibase( version ‖ multicodec(content-type) ‖ multihash )
115
+ multihash = hashFnCode ‖ digestLength ‖ digestBytes
116
+ ```
117
+
118
+ Because the multibase, content codec, and hash code all travel *inside* the value,
119
+ a consumer can validate it without out-of-band knowledge, and an algorithm
120
+ migration (e.g. sha2-256 → another hash) is unambiguous rather than a silent
121
+ reinterpretation. The framing comes entirely from the audited
122
+ [`multiformats`](https://github.com/multiformats/js-multiformats) library.
74
123
 
75
124
  ```sql
76
- -- Hash base64url data (default)
77
- SELECT digest('aGVsbG8gd29ybGQ') as hash;
125
+ -- Hash a blob and frame it as the canonical raw/sha2-256/base32 CID
126
+ -- (== the CID IPFS shows for the same bytes)
127
+ SELECT cid(SomeBlob) AS Cid;
128
+
129
+ -- Pick content codec / hash / base
130
+ SELECT cid(SomeBlob, 'dag-cbor', 'sha2-512', 'base58btc') AS Cid;
131
+
132
+ -- A self-describing content address over a field tuple: digest() canonically
133
+ -- frames + hashes the fields; cid_v1() wraps that exact digest as a CIDv1
134
+ -- (no double-hash). The asserted hash must match digest()'s configured algorithm.
135
+ SELECT cid_v1(digest(ColA, ColB, ColC), 'sha2-256') AS Cid;
136
+
137
+ -- Validate / inspect a stored CID (returns JSON: { version, codec, hashCode, digest })
138
+ SELECT cid_decode(Cid) ->> 'codec' AS codec FROM T;
139
+ ```
140
+
141
+ - **`cid(data, codec?, hash?, base?)`** — hash `data` then frame. `data` is a BLOB,
142
+ or a base64url-encoded TEXT digest/blob (the plugin's canonical text encoding).
143
+ Defaults: `codec='raw'`, `hash='sha2-256'`, `base='base32'`.
144
+ - **`cid_v1(digest, hash, codec?, base?)`** — wrap an **already-computed** digest
145
+ without re-hashing. `hash` is required and asserts which algorithm produced the
146
+ digest; the digest length is checked against it (sha2-256/blake3 = 32 bytes,
147
+ sha2-512 = 64) and a mismatch is rejected.
148
+ - **`cid_decode(cid) → JSON`** — parse a CID back to `{ version, codec, hashCode,
149
+ digest }` (digest as base64url). Throws cleanly on malformed input.
150
+
151
+ Selectable values: `codec` ∈ `raw`, `dag-cbor`; `hash` ∈ `sha2-256`, `sha2-512`,
152
+ `blake3`; `base` ∈ `base32` (default), `base58btc`, `base64url`, `base16`.
153
+
154
+ **Why `base32` by default?** A CID's whole purpose is to match what an external
155
+ content-addressed store computes, and IPFS renders CIDv1 canonically in base32 (the
156
+ `b…` prefix). This is deliberately different from the plugin's `digest`/`random_bytes`
157
+ default of base64url: base64url is compact for *internal* values that live in memory,
158
+ on the wire, or in JSON, whereas base32 is case-insensitive and DNS/URL/filename-safe
159
+ where interoperable addresses are copied and read. Two audiences, two defaults — pass
160
+ `'base64url'` explicitly if you want the compact form.
161
+
162
+ ### set_commit(leaves_json) / set_verify(root, disclosed_json, hidden_json)
163
+
164
+ **Per-attribute selective disclosure.** An authority commits to a registrant's whole
165
+ attribute set as a single root (which it signs / persists), then later reveals only a
166
+ chosen *subset* to a recipient — with a proof the revealed values are genuinely the
167
+ committed ones — **without** leaking the withheld attribute values. A flat
168
+ `digest(whole set)` can't do this (verifying one field needs the whole pre-image, so
169
+ it's all-or-nothing); `set_commit` supports *partial opening*.
170
+
171
+ Each attribute becomes a salted leaf, and the root is the digest of all leaf digests in
172
+ canonical (sort-by-leaf-digest-bytes) order:
78
173
 
79
- -- Hash UTF-8 text
80
- SELECT digest('hello world', 'sha256', 'utf8', 'base64url') as hash;
174
+ ```
175
+ leafDigest = digest([SD_LEAF_DOMAIN_V1, name, value, salt]) -- raw digest bytes
176
+ root = digest([SD_SET_DOMAIN_V1, sortedLeaf_0, sortedLeaf_1, ...])
177
+ ```
81
178
 
82
- -- Hash with SHA-512
83
- SELECT digest('data', 'sha512', 'utf8', 'base64url') as sha512_hash;
179
+ This is the same flat salted-hash shape the IETF SD-JWT standard
180
+ (`draft-ietf-oauth-selective-disclosure-jwt`) settled on rather than a Merkle tree —
181
+ cited as conceptual precedent only; the framing here is Optimystic's own `digest`
182
+ `encodeFields` (not SD-JWT wire-compatible). The `name` is hashed into the leaf so a
183
+ `(value, salt)` proof can't be replayed under another attribute; the `salt` is per-leaf
184
+ and **mandatory** (low-entropy values like a DOB or a boolean are brute-forceable from a
185
+ bare hash, and independent salts defeat cross-registrant correlation).
84
186
 
85
- -- Hash with BLAKE3, output as hex
86
- SELECT digest('data', 'blake3', 'utf8', 'hex') as blake3_hex;
187
+ ```sql
188
+ -- Commit to a JSON array of [name, value, salt] leaves -> single root.
189
+ -- Pair with cid() for the self-describing persisted/signed column representation:
190
+ SELECT cid(set_commit(SelectiveDetails)) AS SelectiveCid;
191
+
192
+ -- A schema CHECK makes a forged root impossible to store: the root is recomputed from
193
+ -- the stored attribute triples, so SelectiveCid must equal the genuine commitment.
194
+ CREATE TABLE Registrant (
195
+ ...,
196
+ SelectiveDetails TEXT, -- JSON array of [name, value, salt] triples
197
+ SelectiveCid TEXT,
198
+ CHECK (SelectiveCid = cid(set_commit(SelectiveDetails)))
199
+ );
200
+
201
+ -- A recipient verifies a disclosure: the disclosed [name, value, salt] triples plus
202
+ -- the opaque leaf digests of the withheld leaves reconstruct the entire root.
203
+ SELECT set_verify(root, disclosed_json, hidden_json) AS ok;
87
204
  ```
88
205
 
206
+ - **`set_commit(leaves_json) → TEXT`** — `leaves_json` is a JSON array; each element is a
207
+ leaf `[name, value, salt]` *or* `{ "name", "value", "salt" }`. Values follow `digest`'s
208
+ rules as parsed from JSON (INTEGER vs REAL by JS value, TEXT, BOOL, null, nested
209
+ object/array). Each leaf must carry all three fields — a missing `value` (object form) or
210
+ a `[name, value]` array (under three elements) throws; pass `value: null` for a null-valued
211
+ attribute. The salt is base64url TEXT (e.g. from `random_bytes`). **THROWS** on a duplicate
212
+ name, a missing value, a missing/empty salt, or unparseable/non-array JSON (invalid states
213
+ made impossible). `replicable` — the root is signed and persisted, same bar as `digest`.
214
+ - **`set_verify(root, disclosed_json, hidden_json) → BOOLEAN`** — `disclosed_json` is the
215
+ opened triples (same leaf shape), `hidden_json` a JSON array of the withheld leaves'
216
+ opaque base64url digests. Reconstructs the **entire** root and compares — so the holder
217
+ cannot add, drop, or swap a leaf. Returns `false` on any mismatch or malformed input
218
+ (forgiving, like `verify`).
219
+
220
+ > **Disclosure generation is JS-only** (`setDisclose`, below) — there's no SQL
221
+ > `set_disclose`, because building a disclosure requires the full attribute set including
222
+ > the secret salts, which lives engine-side, not in a query.
223
+
224
+ **BLOB-valued attributes via SQL:** JSON has no blob type, so a blob attribute passed
225
+ through `set_commit` is committed as its base64url TEXT. Callers needing a *true* BLOB
226
+ value (committed as a BLOB field, `TAG_BLOB`) must use the JS `setCommit` with a
227
+ `Uint8Array` value.
228
+
229
+ **Privacy note:** a fixed commitment disclosed to two audiences exposes the same hidden
230
+ digests and field count to both, so they can correlate that it's the same record.
231
+ Re-randomizing per disclosure would require fresh salts → a new root → a new signature
232
+ (out of scope here).
233
+
234
+ **Framing coupling:** leaf and root reuse `digest`'s `encodeFields`, so a future digest
235
+ framing-version bump changes `set_commit` output too — intentional (one canonical
236
+ framing), but it means the pinned set-commitment vectors move with the digest vectors.
237
+
89
238
  ### hash_mod(data, bits, algorithm?, inputEncoding?)
90
239
 
91
240
  Hash data and return modulo 2^bits for fixed-size hash values.
@@ -152,6 +301,34 @@ SELECT verify('hello', 'c2lnbmF0dXJl', 'cHVibGljS2V5', 'secp256k1', 'utf8') as i
152
301
  SELECT verify('data', 'c2lnbmF0dXJl', 'cHVibGljS2V5', 'p256', 'utf8') as is_valid;
153
302
  ```
154
303
 
304
+ ## Digest configuration
305
+
306
+ Because `digest` is variadic over data, its **algorithm** and **output encoding** are
307
+ not call arguments — they are bound once when the plugin is loaded, via the plugin
308
+ config object:
309
+
310
+ ```ts
311
+ import { registerPlugin } from '@quereus/quereus';
312
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
313
+
314
+ await registerPlugin(db, cryptoPlugin, {
315
+ algorithm: 'sha256', // 'sha256' (default) | 'sha512' | 'blake3'
316
+ encoding: 'base64url', // 'base64url' (default) | 'base64' | 'hex'
317
+ });
318
+ ```
319
+
320
+ An unknown `algorithm` or a non-text `encoding` throws at registration (fail fast),
321
+ and the algorithm/encoding are resolved once so the per-call path does no branching.
322
+
323
+ **Why load-time and not per-connection?** The SQL `digest` is registered as
324
+ `replicable` — its output must be bit-identical across peers, platforms, and app
325
+ versions, because these digests are signed and persisted as commitments. That holds
326
+ only if the configuration is fixed for every peer. *Mutable* per-connection
327
+ configuration (e.g. a runtime `SET`/PRAGMA) would let two peers disagree and silently
328
+ break signature validation, so it is intentionally not offered for this function. If a
329
+ single database genuinely needs two digest configurations, register the plugin twice
330
+ (or expose named variants) rather than flipping mutable session state.
331
+
155
332
  ## Supported Algorithms
156
333
 
157
334
  ### Hash Algorithms
@@ -216,7 +393,7 @@ const privateKey = generatePrivateKey('secp256k1', 'base64url');
216
393
  const publicKey = getPublicKey(privateKey);
217
394
 
218
395
  const message = 'Hello, World!';
219
- const hash = digest(message, 'sha256', 'utf8', 'base64url');
396
+ const hash = digest([message], 'sha256', 'base64url') as string;
220
397
  const signature = sign(hash, privateKey);
221
398
  const isValid = verify(hash, signature, publicKey);
222
399
 
@@ -229,9 +406,59 @@ console.log('Random nonce:', nonce);
229
406
 
230
407
  ## JavaScript API Reference
231
408
 
232
- ### digest(data, algorithm?, inputEncoding?, outputEncoding?)
233
- Hash data using SHA-256, SHA-512, or BLAKE3.
234
- - **Returns**: Hash as string (or Uint8Array if encoding is 'bytes')
409
+ ### digest(fields, algorithm?, encoding?)
410
+ Injective digest over an ordered tuple of fields. `fields` is an array of values
411
+ (any SQL value type). `algorithm` defaults to `'sha256'`, `encoding` to `'base64url'`.
412
+ - **Returns**: Hash as string (or Uint8Array if encoding is `'bytes'`)
413
+ - **Related**: `encodeFields(fields)` returns the canonical pre-hash byte framing;
414
+ `digestFields(fields, hasher, encode)` / `resolveHasher` / `resolveOutputEncoder`
415
+ are the building blocks the SQL function composes (resolve once, no per-call branching).
416
+
417
+ ### cid(data, codec?, hash?, base?)
418
+ Hash `data` (a `Uint8Array`) and frame it as a self-describing CIDv1 string. Defaults:
419
+ `codec='raw'`, `hash='sha2-256'`, `base='base32'`. Byte-identical to the CID an IPFS/IPLD
420
+ store computes for the same bytes.
421
+ - **Returns**: CIDv1 string
422
+
423
+ ### cidV1(digest, hash, codec?, base?)
424
+ Frame an **already-computed** `digest` (a `Uint8Array`) as a CIDv1 without re-hashing.
425
+ `hash` asserts which algorithm produced the digest; the digest length is validated
426
+ against it. Use to turn a `digest(...)` result into a CID: `cidV1(digest(fields, 'sha256', 'bytes'), 'sha2-256')`.
427
+ - **Returns**: CIDv1 string
428
+
429
+ ### cidDecode(cid)
430
+ Parse a CID string into `{ version, codec, hashCode, digest }` for validation/migration.
431
+ Recognized codec/hash codes are returned as names, otherwise as numbers; `digest` is a
432
+ `Uint8Array`. Throws on malformed input.
433
+ - **Returns**: `{ version: number, codec: Multicodec | number, hashCode: MultihashCode | number, digest: Uint8Array }`
434
+
435
+ ### setCommit(leaves, hasher?, encode?) / setDisclose(leaves, revealNames, hasher?) / setVerify(root, disclosure, hasher?, encode?)
436
+ Salted-leaf set commitment for selective disclosure. `leaves` is an array of
437
+ `{ name, value, salt }` (`salt` a base64url string or `Uint8Array`).
438
+ - **`setCommit`** → the root (`string`, or `Uint8Array` with a bytes encoder). Throws on a
439
+ duplicate name or a missing/empty salt; the empty set is well-defined, not an error.
440
+ - **`setDisclose`** → `{ disclosed, hidden }`: the revealed `{ name, value, salt }` triples
441
+ plus the opaque base64url leaf digests of the withheld leaves (withheld values/salts never
442
+ appear). This is the engine-side generator with no SQL equivalent.
443
+ - **`setVerify`** → `boolean`: reconstructs the entire root from `disclosure` and compares to
444
+ `root`. `false` on mismatch or malformed input. `encode` is how the signed root is rendered
445
+ (default base64url); a `Uint8Array` root is compared by raw bytes.
446
+ - **`leafDigest(leaf, hasher)`** → raw leaf digest bytes (the low-level building block).
447
+
448
+ ```typescript
449
+ import { setCommit, setDisclose, setVerify, randomBytes } from '@optimystic/quereus-plugin-crypto';
450
+
451
+ const leaves = [
452
+ { name: 'name', value: 'Alice', salt: randomBytes(256) as string },
453
+ { name: 'over18', value: true, salt: randomBytes(256) as string },
454
+ { name: 'zip', value: '90210', salt: randomBytes(256) as string },
455
+ ];
456
+ const root = setCommit(leaves); // sign / persist this (often as cid(root))
457
+
458
+ // Recipient gets only `over18`, with proof it belongs to the committed set:
459
+ const disclosure = setDisclose(leaves, ['over18']);
460
+ const ok = setVerify(root, disclosure); // true — withheld values never left the engine
461
+ ```
235
462
 
236
463
  ### hashMod(data, bits, algorithm?, inputEncoding?)
237
464
  Hash data and return modulo 2^bits for fixed-size hash values.