@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/CHANGELOG.md +91 -0
- package/README.md +259 -16
- package/dist/index.d.ts +224 -198
- package/dist/index.js +272 -272
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +17 -1
- package/dist/plugin.js +435 -23
- package/dist/plugin.js.map +1 -1
- package/package.json +32 -6
- package/src/cid.ts +200 -0
- package/src/crypto.ts +252 -34
- package/src/index.ts +30 -5
- package/src/plugin.ts +202 -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/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
|
-
*
|
|
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
|
|
301
|
+
* @param fields - Ordered tuple of values to hash (any SQL value type)
|
|
76
302
|
* @param algorithm - Hash algorithm (default: 'sha256')
|
|
77
|
-
* @param
|
|
78
|
-
* @
|
|
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
|
|
84
|
-
* const
|
|
308
|
+
* // Hash a tuple of fields — distinct tuples never collide
|
|
309
|
+
* const h = digest(['alice', 42, null, true]);
|
|
85
310
|
*
|
|
86
|
-
* //
|
|
87
|
-
* const
|
|
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
|
-
|
|
320
|
+
fields: readonly DigestField[],
|
|
95
321
|
algorithm: HashAlgorithm = 'sha256',
|
|
96
|
-
|
|
97
|
-
outputEncoding: Encoding = 'base64url'
|
|
322
|
+
encoding: OutputEncoding = 'base64url'
|
|
98
323
|
): string | Uint8Array {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
29
|
-
export {
|
|
30
|
-
|
|
31
|
-
|
|
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 {
|
|
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,
|
|
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, //
|
|
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,
|
|
32
|
-
return
|
|
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;
|