@optimystic/quereus-plugin-crypto 0.13.4 → 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 +243 -16
- package/dist/index.d.ts +224 -198
- package/dist/index.js +267 -272
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +17 -1
- package/dist/plugin.js +426 -23
- package/dist/plugin.js.map +1 -1
- package/package.json +29 -3
- package/src/cid.ts +200 -0
- package/src/crypto.ts +244 -35
- package/src/index.ts +30 -5
- package/src/plugin.ts +193 -5
- package/src/sd.ts +250 -0
- package/src/digest.ts +0 -173
- package/src/sign.ts +0 -235
- package/src/signature-valid.ts +0 -262
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
|
|
49
|
-
const hash = digest('hello world',
|
|
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(
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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', '
|
|
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(
|
|
233
|
-
|
|
234
|
-
|
|
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.
|