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