@plane31/blind-relay 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/codec.ts ADDED
@@ -0,0 +1,642 @@
1
+ /**
2
+ * The wire documents of Kavach v1, protocol sections 3 to 12.
3
+ *
4
+ * Parse within bounds first (sizes, ranges, sort orders, counts, nonzero rules), then verify the
5
+ * signature under the key the caller's trust state names, then anything else. Every parser here
6
+ * refuses trailing bytes and never touches a signature. Type 5 is retired and rejected.
7
+ */
8
+ import {
9
+ MAX_DOCUMENT_BYTES,
10
+ MAX_PAYLOAD_BYTES,
11
+ Reader,
12
+ Writer,
13
+ compareBytes,
14
+ concat,
15
+ equalBytes,
16
+ isAllZero,
17
+ u32
18
+ } from "./bytes.js";
19
+ import {
20
+ C,
21
+ DEVICE_ID_BYTES,
22
+ HASH_BYTES,
23
+ KEY_BYTES,
24
+ NONCE_BYTES,
25
+ PUBLIC_KEY_BYTES,
26
+ RECOVERY_CODE_PREFIX,
27
+ SEED_BYTES,
28
+ SIGNATURE_BYTES,
29
+ TAG_BYTES,
30
+ WIRE_VERSION,
31
+ accountKeypair,
32
+ aeadOpen,
33
+ aeadSeal,
34
+ assertValidBoxKey,
35
+ assertValidSigningKey,
36
+ bodyHash,
37
+ sign,
38
+ verify,
39
+ type Label
40
+ } from "./crypto.js";
41
+ import { KavachError } from "./errors.js";
42
+
43
+ export const MAX_MEMBERS = 100;
44
+ export const MAX_RESET_KEYS = 8;
45
+ export const MAX_RESET_KEY_LIST_LIFETIME_MS = 2_592_000_000n; // 30 days
46
+ /** The encrypted envelope in front of every payload (revision 8): version, kind, event time. */
47
+ export const ENVELOPE_VERSION = 1;
48
+ export const ENVELOPE_BYTES = 1 + 1 + 8;
49
+ export const MIN_RECORD_CIPHERTEXT = ENVELOPE_BYTES + TAG_BYTES;
50
+ /** The size of a sealed record on the wire for a payload of this length: header, nonce, length, envelope, payload, tag, signature. */
51
+ export function recordWireBytes(payloadLength: number): number {
52
+ return RECORD_HEADER_BYTES + NONCE_BYTES + 4 + ENVELOPE_BYTES + payloadLength + TAG_BYTES + SIGNATURE_BYTES;
53
+ }
54
+ export const MAX_RECORD_CIPHERTEXT = ENVELOPE_BYTES + MAX_PAYLOAD_BYTES + TAG_BYTES;
55
+ export const MIN_VAULT_CIPHERTEXT = 90;
56
+ export const MAX_VAULT_CIPHERTEXT = 4_990;
57
+ export const VAULT_ENTRY_BYTES = 49;
58
+ export const RECORD_HEADER_BYTES = 51;
59
+ /** Header flags: bits 1 and 2 carry the record's header class (0 to 3), the one thing about a record the relay may see; bit 0 and bits 3 to 7 are reserved. */
60
+ export const HEADER_CLASS_SHIFT = 1;
61
+ export const HEADER_CLASS_MASK = 0x06;
62
+ const RESERVED_FLAGS = ~HEADER_CLASS_MASK & 0xff;
63
+ export type HeaderClass = 0 | 1 | 2 | 3;
64
+
65
+ export enum DocumentType {
66
+ AccountDevice = 1,
67
+ CircleSnapshot = 2,
68
+ EpochManifest = 3,
69
+ EncryptedRecord = 4,
70
+ AccountVault = 6,
71
+ AccountResetReceipt = 7,
72
+ ResetKeyList = 8
73
+ }
74
+
75
+ /** A record kind: an opaque small integer the product declares (section 9). It travels inside the encrypted envelope. */
76
+ export type RecordKind = number;
77
+ export const MAX_KIND = 255;
78
+
79
+ export interface AccountDevice {
80
+ readonly type: DocumentType.AccountDevice;
81
+ readonly accountName: string;
82
+ readonly accountKey: Uint8Array;
83
+ readonly generation: bigint;
84
+ readonly deviceId: Uint8Array;
85
+ readonly signingKey: Uint8Array;
86
+ readonly boxKey: Uint8Array;
87
+ }
88
+
89
+ export interface CircleSnapshot {
90
+ readonly type: DocumentType.CircleSnapshot;
91
+ readonly circleId: Uint8Array;
92
+ readonly ownerIdentityId: Uint8Array;
93
+ readonly revision: bigint;
94
+ readonly members: readonly Uint8Array[];
95
+ }
96
+
97
+ export interface AudienceRow {
98
+ readonly identityId: Uint8Array;
99
+ readonly certificateHash: Uint8Array;
100
+ readonly wrapHash: Uint8Array;
101
+ }
102
+
103
+ export interface EpochManifest {
104
+ readonly type: DocumentType.EpochManifest;
105
+ readonly streamId: Uint8Array;
106
+ readonly authorIdentityId: Uint8Array;
107
+ readonly authorCertificateHash: Uint8Array;
108
+ readonly epoch: number;
109
+ readonly createdAt: bigint;
110
+ readonly keyCommitment: Uint8Array;
111
+ readonly audience: readonly AudienceRow[];
112
+ }
113
+
114
+ export interface EncryptedRecord {
115
+ readonly type: DocumentType.EncryptedRecord;
116
+ readonly streamId: Uint8Array;
117
+ readonly epoch: number;
118
+ readonly counter: number;
119
+ readonly sentAt: bigint;
120
+ readonly flags: number;
121
+ /** What the relay may see of the record's kind: the product's declared header class, or 0. */
122
+ readonly headerClass: HeaderClass;
123
+ /** The 51-byte header, which the AEAD authenticates as `C("record-aad") || header`. */
124
+ readonly header: Uint8Array;
125
+ readonly nonce: Uint8Array;
126
+ readonly ciphertext: Uint8Array;
127
+ }
128
+
129
+ export interface AccountResetReceipt {
130
+ readonly type: DocumentType.AccountResetReceipt;
131
+ readonly accountName: string;
132
+ readonly oldIdentityId: Uint8Array;
133
+ readonly newIdentityId: Uint8Array;
134
+ readonly resetId: Uint8Array;
135
+ readonly issuedAt: bigint;
136
+ readonly authorityKeyId: Uint8Array;
137
+ }
138
+
139
+ export interface ResetKeyEntry {
140
+ readonly keyId: Uint8Array;
141
+ readonly publicKey: Uint8Array;
142
+ }
143
+
144
+ export interface ResetKeyList {
145
+ readonly type: DocumentType.ResetKeyList;
146
+ readonly version: bigint;
147
+ readonly issuedAt: bigint;
148
+ readonly expiresAt: bigint;
149
+ readonly keys: readonly ResetKeyEntry[];
150
+ }
151
+
152
+ export type SignedDocument = AccountDevice | CircleSnapshot | EpochManifest | EncryptedRecord | AccountResetReceipt | ResetKeyList;
153
+
154
+ /** A parsed signed document together with the exact body and signature bytes. */
155
+ export type Parsed<T extends SignedDocument = SignedDocument> = T & {
156
+ readonly body: Uint8Array;
157
+ readonly signature: Uint8Array;
158
+ readonly bodyHash: Uint8Array;
159
+ };
160
+
161
+ export const LABEL_BY_TYPE: Readonly<Record<number, Label>> = {
162
+ [DocumentType.AccountDevice]: "account-sig",
163
+ [DocumentType.CircleSnapshot]: "snapshot-sig",
164
+ [DocumentType.EpochManifest]: "epoch-sig",
165
+ [DocumentType.EncryptedRecord]: "record-sig",
166
+ [DocumentType.AccountResetReceipt]: "reset-sig",
167
+ [DocumentType.ResetKeyList]: "reset-keys-sig"
168
+ };
169
+
170
+ // Parsing --------------------------------------------------------------------------------------
171
+
172
+ function strictlySortedUnique(items: readonly Uint8Array[]): boolean {
173
+ for (let i = 1; i < items.length; i++) {
174
+ const a = items[i - 1];
175
+ const b = items[i];
176
+ if (!a || !b || compareBytes(a, b) >= 0) return false;
177
+ }
178
+ return true;
179
+ }
180
+
181
+ function malformed(stage: string, why: string): KavachError {
182
+ return new KavachError("MALFORMED", stage, why);
183
+ }
184
+
185
+ /**
186
+ * Parse a signed document (types 1 to 4, 7, 8). The signature is split off but not checked;
187
+ * call `verifyDocument` for that. Type 6 is not a signed document and type 5 is retired.
188
+ */
189
+ export function parseDocument(wire: Uint8Array): Parsed {
190
+ const stage = "parse";
191
+ if (wire.length > MAX_DOCUMENT_BYTES) throw new KavachError("SIZE_LIMIT", stage, "document over 16,384 bytes");
192
+ const r = new Reader(wire, stage);
193
+ if (r.u8() !== WIRE_VERSION) throw new KavachError("UNSUPPORTED_VERSION", stage);
194
+ const type = r.u8();
195
+ let fields: SignedDocument;
196
+ switch (type) {
197
+ case DocumentType.AccountDevice: {
198
+ const accountName = r.name();
199
+ const accountKey = r.take(PUBLIC_KEY_BYTES);
200
+ const generation = r.u64();
201
+ const deviceId = r.take(DEVICE_ID_BYTES);
202
+ const signingKey = r.take(PUBLIC_KEY_BYTES);
203
+ const boxKey = r.take(PUBLIC_KEY_BYTES);
204
+ if (generation === 0n) throw malformed(stage, "generation zero");
205
+ if (isAllZero(deviceId)) throw malformed(stage, "device id zero");
206
+ fields = { type, accountName, accountKey, generation, deviceId, signingKey, boxKey };
207
+ break;
208
+ }
209
+ case DocumentType.CircleSnapshot: {
210
+ const circleId = r.take(16);
211
+ const ownerIdentityId = r.take(HASH_BYTES);
212
+ const revision = r.u64();
213
+ const count = r.u16();
214
+ if (revision === 0n) throw malformed(stage, "revision zero");
215
+ if (count < 1 || count > MAX_MEMBERS) throw malformed(stage, "member count");
216
+ const members: Uint8Array[] = [];
217
+ for (let i = 0; i < count; i++) members.push(r.take(HASH_BYTES));
218
+ if (!strictlySortedUnique(members)) throw malformed(stage, "members not strictly sorted");
219
+ if (!members.some((m) => equalBytes(m, ownerIdentityId))) throw malformed(stage, "owner missing");
220
+ fields = { type, circleId, ownerIdentityId, revision, members };
221
+ break;
222
+ }
223
+ case DocumentType.EpochManifest: {
224
+ const streamId = r.take(HASH_BYTES);
225
+ const authorIdentityId = r.take(HASH_BYTES);
226
+ const authorCertificateHash = r.take(HASH_BYTES);
227
+ const epoch = r.u32();
228
+ const createdAt = r.u64();
229
+ const keyCommitment = r.take(HASH_BYTES);
230
+ const count = r.u16();
231
+ if (epoch === 0) throw malformed(stage, "epoch zero");
232
+ if (count < 1 || count > MAX_MEMBERS) throw malformed(stage, "audience count");
233
+ const audience: AudienceRow[] = [];
234
+ for (let i = 0; i < count; i++) {
235
+ audience.push({ identityId: r.take(HASH_BYTES), certificateHash: r.take(HASH_BYTES), wrapHash: r.take(HASH_BYTES) });
236
+ }
237
+ if (!strictlySortedUnique(audience.map((a) => a.identityId))) throw malformed(stage, "audience not strictly sorted");
238
+ if (!audience.some((a) => equalBytes(a.identityId, authorIdentityId))) throw malformed(stage, "author missing from audience");
239
+ fields = { type, streamId, authorIdentityId, authorCertificateHash, epoch, createdAt, keyCommitment, audience };
240
+ break;
241
+ }
242
+ case DocumentType.EncryptedRecord: {
243
+ const streamId = r.take(HASH_BYTES);
244
+ const epoch = r.u32();
245
+ const counter = r.u32();
246
+ const sentAt = r.u64();
247
+ const flags = r.u8();
248
+ if (epoch === 0) throw malformed(stage, "epoch zero");
249
+ if ((flags & RESERVED_FLAGS) !== 0) throw malformed(stage, "reserved flag bit set");
250
+ const header = r.consumed();
251
+ const nonce = r.take(NONCE_BYTES);
252
+ const length = r.u32();
253
+ if (length < MIN_RECORD_CIPHERTEXT || length > MAX_RECORD_CIPHERTEXT) {
254
+ throw new KavachError("SIZE_LIMIT", stage, "record ciphertext length");
255
+ }
256
+ const ciphertext = r.take(length);
257
+ fields = { type, streamId, epoch, counter, sentAt, flags, headerClass: ((flags & HEADER_CLASS_MASK) >> HEADER_CLASS_SHIFT) as HeaderClass, header, nonce, ciphertext };
258
+ break;
259
+ }
260
+ case DocumentType.AccountResetReceipt: {
261
+ const accountName = r.name();
262
+ const oldIdentityId = r.take(HASH_BYTES);
263
+ const newIdentityId = r.take(HASH_BYTES);
264
+ const resetId = r.take(16);
265
+ const issuedAt = r.u64();
266
+ const authorityKeyId = r.take(16);
267
+ if (equalBytes(oldIdentityId, newIdentityId)) throw malformed(stage, "old and new identity equal");
268
+ fields = { type, accountName, oldIdentityId, newIdentityId, resetId, issuedAt, authorityKeyId };
269
+ break;
270
+ }
271
+ case DocumentType.ResetKeyList: {
272
+ const version = r.u64();
273
+ const issuedAt = r.u64();
274
+ const expiresAt = r.u64();
275
+ const count = r.u8();
276
+ if (version === 0n) throw malformed(stage, "version zero");
277
+ if (count > MAX_RESET_KEYS) throw malformed(stage, "too many keys");
278
+ const lifetime = expiresAt - issuedAt;
279
+ if (lifetime <= 0n || lifetime > MAX_RESET_KEY_LIST_LIFETIME_MS) throw malformed(stage, "key-list lifetime");
280
+ const keys: ResetKeyEntry[] = [];
281
+ for (let i = 0; i < count; i++) keys.push({ keyId: r.take(16), publicKey: r.take(PUBLIC_KEY_BYTES) });
282
+ if (!strictlySortedUnique(keys.map((k) => k.keyId))) throw malformed(stage, "key ids not strictly sorted");
283
+ if (keys.some((k) => isAllZero(k.keyId))) throw malformed(stage, "zero key id");
284
+ for (let i = 0; i < keys.length; i++) {
285
+ for (let j = i + 1; j < keys.length; j++) {
286
+ const a = keys[i];
287
+ const b = keys[j];
288
+ if (a && b && equalBytes(a.publicKey, b.publicKey)) throw malformed(stage, "duplicate key");
289
+ }
290
+ }
291
+ for (const k of keys) assertValidSigningKey(k.publicKey, stage);
292
+ fields = { type, version, issuedAt, expiresAt, keys };
293
+ break;
294
+ }
295
+ default:
296
+ // 5 is retired; 6 is not a signed document; everything else is unknown.
297
+ throw new KavachError("UNSUPPORTED_TYPE", stage, `type ${type}`);
298
+ }
299
+ const body = r.consumed();
300
+ const signature = r.take(SIGNATURE_BYTES);
301
+ r.end();
302
+ return { ...fields, body, signature, bodyHash: bodyHash(body) };
303
+ }
304
+
305
+ /**
306
+ * Parse and verify. A type 1 certificate is verified under the key it carries, which establishes
307
+ * key possession only. Every other type needs the key the caller's trust state names.
308
+ */
309
+ export function verifyDocument(wire: Uint8Array, publicKey?: Uint8Array): Parsed {
310
+ const doc = parseDocument(wire);
311
+ const label = LABEL_BY_TYPE[doc.type];
312
+ if (!label) throw new KavachError("UNSUPPORTED_TYPE", "verify");
313
+ let key = publicKey;
314
+ if (doc.type === DocumentType.AccountDevice) key = doc.accountKey;
315
+ if (!key) throw new KavachError("METADATA_REQUIRED", "verify", "verification key required");
316
+ verify(key, label, doc.body, doc.signature);
317
+ if (doc.type === DocumentType.AccountDevice) {
318
+ assertValidSigningKey(doc.signingKey);
319
+ assertValidBoxKey(doc.boxKey);
320
+ }
321
+ return doc;
322
+ }
323
+
324
+ /** Narrow a parsed document to one type, or fail as MALFORMED. */
325
+ export function expectType<T extends SignedDocument>(doc: Parsed, type: T["type"], stage = "verify"): Parsed<T> {
326
+ if (doc.type !== type) throw malformed(stage, `expected type ${type}, got ${doc.type}`);
327
+ return doc as unknown as Parsed<T>;
328
+ }
329
+
330
+ // Building -------------------------------------------------------------------------------------
331
+
332
+ function signed(body: Uint8Array, label: Label, secretKey: Uint8Array): Uint8Array {
333
+ return concat(body, sign(secretKey, label, body));
334
+ }
335
+
336
+ export function encodeAccountDeviceBody(f: Omit<AccountDevice, "type">): Uint8Array {
337
+ return new Writer()
338
+ .u8(WIRE_VERSION)
339
+ .u8(DocumentType.AccountDevice)
340
+ .name(f.accountName)
341
+ .bytes(f.accountKey)
342
+ .u64(f.generation)
343
+ .bytes(f.deviceId)
344
+ .bytes(f.signingKey)
345
+ .bytes(f.boxKey)
346
+ .finish();
347
+ }
348
+
349
+ export function buildAccountDevice(f: Omit<AccountDevice, "type">, accountSecretKey: Uint8Array): Uint8Array {
350
+ return signed(encodeAccountDeviceBody(f), "account-sig", accountSecretKey);
351
+ }
352
+
353
+ export function encodeCircleSnapshotBody(f: Omit<CircleSnapshot, "type">): Uint8Array {
354
+ const members = [...f.members].sort(compareBytes);
355
+ const w = new Writer().u8(WIRE_VERSION).u8(DocumentType.CircleSnapshot).bytes(f.circleId).bytes(f.ownerIdentityId).u64(f.revision).u16(members.length);
356
+ for (const m of members) w.bytes(m);
357
+ return w.finish();
358
+ }
359
+
360
+ export function buildCircleSnapshot(f: Omit<CircleSnapshot, "type">, ownerAccountSecretKey: Uint8Array): Uint8Array {
361
+ return signed(encodeCircleSnapshotBody(f), "snapshot-sig", ownerAccountSecretKey);
362
+ }
363
+
364
+ export function encodeEpochManifestBody(f: Omit<EpochManifest, "type">): Uint8Array {
365
+ const rows = [...f.audience].sort((a, b) => compareBytes(a.identityId, b.identityId));
366
+ const w = new Writer()
367
+ .u8(WIRE_VERSION)
368
+ .u8(DocumentType.EpochManifest)
369
+ .bytes(f.streamId)
370
+ .bytes(f.authorIdentityId)
371
+ .bytes(f.authorCertificateHash)
372
+ .u32(f.epoch)
373
+ .u64(f.createdAt)
374
+ .bytes(f.keyCommitment)
375
+ .u16(rows.length);
376
+ for (const row of rows) w.bytes(row.identityId).bytes(row.certificateHash).bytes(row.wrapHash);
377
+ return w.finish();
378
+ }
379
+
380
+ export function buildEpochManifest(f: Omit<EpochManifest, "type">, deviceSigningSecretKey: Uint8Array): Uint8Array {
381
+ return signed(encodeEpochManifestBody(f), "epoch-sig", deviceSigningSecretKey);
382
+ }
383
+
384
+ export interface RecordHeaderFields {
385
+ readonly streamId: Uint8Array;
386
+ readonly epoch: number;
387
+ readonly counter: number;
388
+ readonly sentAt: bigint;
389
+ readonly headerClass: HeaderClass;
390
+ }
391
+
392
+ export function encodeRecordHeader(f: RecordHeaderFields): Uint8Array {
393
+ if (f.epoch === 0) throw malformed("encode", "epoch zero");
394
+ return new Writer()
395
+ .u8(WIRE_VERSION)
396
+ .u8(DocumentType.EncryptedRecord)
397
+ .bytes(f.streamId)
398
+ .u32(f.epoch)
399
+ .u32(f.counter)
400
+ .u64(f.sentAt)
401
+ .u8((f.headerClass << HEADER_CLASS_SHIFT) & HEADER_CLASS_MASK)
402
+ .finish();
403
+ }
404
+
405
+ /** What the envelope carries about a record, readable only by a holder of the epoch key. */
406
+ export interface Envelope {
407
+ readonly kind: RecordKind;
408
+ /** The event time the product supplied, if any; a reader clamps it to `sentAt` before ordering by it. */
409
+ readonly eventTime?: bigint | undefined;
410
+ }
411
+
412
+ /** `u8(1) || u8(kind) || u64(eventTime or 0) || payload`: the plaintext under the AEAD. */
413
+ export function encodeEnvelope(e: Envelope, payload: Uint8Array): Uint8Array {
414
+ if (!Number.isInteger(e.kind) || e.kind < 0 || e.kind > MAX_KIND) throw malformed("encode", "kind out of range");
415
+ if (payload.length > MAX_PAYLOAD_BYTES) throw new KavachError("SIZE_LIMIT", "encode", "payload over 4,096 bytes");
416
+ if (e.eventTime !== undefined && e.eventTime < 0n) throw malformed("encode", "event time before the epoch");
417
+ return new Writer().u8(ENVELOPE_VERSION).u8(e.kind).u64(e.eventTime ?? 0n).bytes(payload).finish();
418
+ }
419
+
420
+ export function decodeEnvelope(plaintext: Uint8Array): Envelope & { readonly payload: Uint8Array } {
421
+ const stage = "envelope";
422
+ if (plaintext.length < ENVELOPE_BYTES) throw malformed(stage, "envelope too short");
423
+ const r = new Reader(plaintext, stage);
424
+ if (r.u8() !== ENVELOPE_VERSION) throw malformed(stage, "envelope version");
425
+ const kind = r.u8();
426
+ const t = r.u64();
427
+ const payload = plaintext.subarray(ENVELOPE_BYTES);
428
+ if (payload.length > MAX_PAYLOAD_BYTES) throw new KavachError("SIZE_LIMIT", stage, "payload over 4,096 bytes");
429
+ return { kind, eventTime: t === 0n ? undefined : t, payload };
430
+ }
431
+
432
+ export function recordAad(header: Uint8Array): Uint8Array {
433
+ return concat(C("record-aad"), header);
434
+ }
435
+
436
+ /** Encrypt and sign one record from its complete plaintext (the envelope with the payload). The caller supplies a fresh random nonce and never reuses it. */
437
+ export function buildEncryptedRecord(f: RecordHeaderFields, plaintext: Uint8Array, epochKey: Uint8Array, nonce: Uint8Array, deviceSigningSecretKey: Uint8Array): Uint8Array {
438
+ if (plaintext.length < ENVELOPE_BYTES || plaintext.length > ENVELOPE_BYTES + MAX_PAYLOAD_BYTES) throw new KavachError("SIZE_LIMIT", "encode", "record plaintext length");
439
+ const header = encodeRecordHeader(f);
440
+ const ciphertext = aeadSeal(epochKey, nonce, recordAad(header), plaintext);
441
+ const body = concat(header, nonce, u32(ciphertext.length), ciphertext);
442
+ return signed(body, "record-sig", deviceSigningSecretKey);
443
+ }
444
+
445
+ /** The same from a kind, an optional event time and a payload: the envelope is encoded here. */
446
+ export function buildRecordWire(f: RecordHeaderFields & Envelope, payload: Uint8Array, epochKey: Uint8Array, nonce: Uint8Array, deviceSigningSecretKey: Uint8Array): Uint8Array {
447
+ return buildEncryptedRecord(f, encodeEnvelope(f, payload), epochKey, nonce, deviceSigningSecretKey);
448
+ }
449
+
450
+ /** Decrypt a parsed record under its epoch key to its plaintext (the envelope with the payload). Verify the signature first; this does not. */
451
+ export function openRecord(record: EncryptedRecord, epochKey: Uint8Array): Uint8Array {
452
+ return aeadOpen(epochKey, record.nonce, recordAad(record.header), record.ciphertext);
453
+ }
454
+
455
+ export function encodeResetReceiptBody(f: Omit<AccountResetReceipt, "type">): Uint8Array {
456
+ return new Writer()
457
+ .u8(WIRE_VERSION)
458
+ .u8(DocumentType.AccountResetReceipt)
459
+ .name(f.accountName)
460
+ .bytes(f.oldIdentityId)
461
+ .bytes(f.newIdentityId)
462
+ .bytes(f.resetId)
463
+ .u64(f.issuedAt)
464
+ .bytes(f.authorityKeyId)
465
+ .finish();
466
+ }
467
+
468
+ export function buildResetReceipt(f: Omit<AccountResetReceipt, "type">, resetSecretKey: Uint8Array): Uint8Array {
469
+ return signed(encodeResetReceiptBody(f), "reset-sig", resetSecretKey);
470
+ }
471
+
472
+ export function encodeResetKeyListBody(f: Omit<ResetKeyList, "type">): Uint8Array {
473
+ const keys = [...f.keys].sort((a, b) => compareBytes(a.keyId, b.keyId));
474
+ const w = new Writer().u8(WIRE_VERSION).u8(DocumentType.ResetKeyList).u64(f.version).u64(f.issuedAt).u64(f.expiresAt).u8(keys.length);
475
+ for (const k of keys) w.bytes(k.keyId).bytes(k.publicKey);
476
+ return w.finish();
477
+ }
478
+
479
+ export function buildResetKeyList(f: Omit<ResetKeyList, "type">, managementSecretKey: Uint8Array): Uint8Array {
480
+ return signed(encodeResetKeyListBody(f), "reset-keys-sig", managementSecretKey);
481
+ }
482
+
483
+ // Account vault (type 6) -----------------------------------------------------------------------
484
+
485
+ export interface VaultEntry {
486
+ readonly circleId: Uint8Array;
487
+ readonly ownerIdentityId: Uint8Array;
488
+ readonly sharingEnabled: boolean;
489
+ }
490
+
491
+ export interface VaultContents {
492
+ readonly accountSeed: Uint8Array;
493
+ readonly lastDeviceGeneration: bigint;
494
+ readonly lastCertificateHash: Uint8Array;
495
+ readonly entries: readonly VaultEntry[];
496
+ }
497
+
498
+ export interface VaultHeader {
499
+ readonly accountName: string;
500
+ readonly accountKey: Uint8Array;
501
+ readonly vaultRevision: bigint;
502
+ }
503
+
504
+ export function encodeVaultHeader(h: VaultHeader): Uint8Array {
505
+ return new Writer().u8(WIRE_VERSION).u8(DocumentType.AccountVault).name(h.accountName).bytes(h.accountKey).u64(h.vaultRevision).finish();
506
+ }
507
+
508
+ export function encodeVaultPlaintext(v: VaultContents): Uint8Array {
509
+ if (v.accountSeed.length !== SEED_BYTES) throw malformed("encodeVault", "seed length");
510
+ if (v.entries.length > MAX_MEMBERS) throw malformed("encodeVault", "too many entries");
511
+ const entries = [...v.entries].sort((a, b) => compareBytes(a.circleId, b.circleId));
512
+ if (!strictlySortedUnique(entries.map((e) => e.circleId))) throw malformed("encodeVault", "duplicate circle");
513
+ const w = new Writer().bytes(v.accountSeed).u64(v.lastDeviceGeneration).bytes(v.lastCertificateHash).u16(entries.length);
514
+ for (const e of entries) w.bytes(e.circleId).bytes(e.ownerIdentityId).u8(e.sharingEnabled ? 1 : 0);
515
+ return w.finish();
516
+ }
517
+
518
+ export function decodeVaultPlaintext(plain: Uint8Array): VaultContents {
519
+ const stage = "decodeVault";
520
+ const r = new Reader(plain, stage);
521
+ const accountSeed = r.take(SEED_BYTES);
522
+ const lastDeviceGeneration = r.u64();
523
+ const lastCertificateHash = r.take(HASH_BYTES);
524
+ const count = r.u16();
525
+ if (lastDeviceGeneration === 0n) throw malformed(stage, "generation zero");
526
+ if (count > MAX_MEMBERS) throw malformed(stage, "too many entries");
527
+ const entries: VaultEntry[] = [];
528
+ for (let i = 0; i < count; i++) {
529
+ const circleId = r.take(16);
530
+ const ownerIdentityId = r.take(HASH_BYTES);
531
+ const flag = r.u8();
532
+ if (flag !== 0 && flag !== 1) throw malformed(stage, "sharing flag");
533
+ entries.push({ circleId, ownerIdentityId, sharingEnabled: flag === 1 });
534
+ }
535
+ r.end();
536
+ if (!strictlySortedUnique(entries.map((e) => e.circleId))) throw malformed(stage, "entries not strictly sorted");
537
+ return { accountSeed, lastDeviceGeneration, lastCertificateHash, entries };
538
+ }
539
+
540
+ /** Encrypt a vault. The caller supplies a fresh random nonce. */
541
+ export function sealVault(header: VaultHeader, contents: VaultContents, recoveryKey: Uint8Array, nonce: Uint8Array): Uint8Array {
542
+ if (header.vaultRevision === 0n) throw malformed("sealVault", "revision zero");
543
+ const h = encodeVaultHeader(header);
544
+ const ciphertext = aeadSeal(recoveryKey, nonce, concat(C("vault-aad"), h), encodeVaultPlaintext(contents));
545
+ return concat(h, nonce, u32(ciphertext.length), ciphertext);
546
+ }
547
+
548
+ /** Read a vault's public header without opening it (the host's compare-and-swap uses it). */
549
+ export function parseVaultHeader(wire: Uint8Array): VaultHeader & { readonly hash: Uint8Array } {
550
+ const stage = "vaultHeader";
551
+ if (wire.length > MAX_DOCUMENT_BYTES) throw new KavachError("SIZE_LIMIT", stage);
552
+ const r = new Reader(wire, stage);
553
+ if (r.u8() !== WIRE_VERSION) throw new KavachError("UNSUPPORTED_VERSION", stage);
554
+ if (r.u8() !== DocumentType.AccountVault) throw new KavachError("UNSUPPORTED_TYPE", stage);
555
+ const accountName = r.name();
556
+ const accountKey = r.take(PUBLIC_KEY_BYTES);
557
+ const vaultRevision = r.u64();
558
+ if (vaultRevision === 0n) throw malformed(stage, "revision zero");
559
+ return { accountName, accountKey, vaultRevision, hash: bodyHash(wire) };
560
+ }
561
+
562
+ export interface OpenedVault extends VaultContents {
563
+ readonly header: VaultHeader;
564
+ }
565
+
566
+ /**
567
+ * Open a vault under the recovery key. Binds the header to the expected account before decrypting
568
+ * and checks that `AK` derives from the recovered seed before trusting the entries.
569
+ */
570
+ export function openVault(wire: Uint8Array, recoveryKey: Uint8Array, expectedName: string, expectedAccountKey: Uint8Array): OpenedVault {
571
+ const stage = "openVault";
572
+ if (wire.length > MAX_DOCUMENT_BYTES) throw new KavachError("SIZE_LIMIT", stage);
573
+ const r = new Reader(wire, stage);
574
+ if (r.u8() !== WIRE_VERSION) throw new KavachError("UNSUPPORTED_VERSION", stage);
575
+ if (r.u8() !== DocumentType.AccountVault) throw new KavachError("UNSUPPORTED_TYPE", stage);
576
+ const accountName = r.name();
577
+ const accountKey = r.take(PUBLIC_KEY_BYTES);
578
+ const vaultRevision = r.u64();
579
+ if (accountName !== expectedName || !equalBytes(accountKey, expectedAccountKey) || vaultRevision === 0n) {
580
+ throw new KavachError("VAULT_BINDING", stage);
581
+ }
582
+ const header = r.consumed();
583
+ const nonce = r.take(NONCE_BYTES);
584
+ const length = r.u32();
585
+ if (length < MIN_VAULT_CIPHERTEXT || length > MAX_VAULT_CIPHERTEXT) throw new KavachError("SIZE_LIMIT", stage, "vault ciphertext length");
586
+ const ciphertext = r.take(length);
587
+ r.end();
588
+ if (recoveryKey.length !== KEY_BYTES) throw new KavachError("INVALID_KEY", stage);
589
+ const plain = aeadOpen(recoveryKey, nonce, concat(C("vault-aad"), header), ciphertext, stage);
590
+ const contents = decodeVaultPlaintext(plain);
591
+ if (!equalBytes(accountKeypair(contents.accountSeed).publicKey, accountKey)) throw new KavachError("VAULT_BINDING", stage, "seed does not derive AK");
592
+ return { ...contents, header: { accountName, accountKey, vaultRevision } };
593
+ }
594
+
595
+ // Recovery code --------------------------------------------------------------------------------
596
+
597
+ const B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
598
+
599
+ function base64url(bytes: Uint8Array): string {
600
+ let out = "";
601
+ for (let i = 0; i < bytes.length; i += 3) {
602
+ const a = bytes[i] ?? 0;
603
+ const b = bytes[i + 1];
604
+ const c = bytes[i + 2];
605
+ out += B64URL[a >> 2];
606
+ out += B64URL[((a & 3) << 4) | ((b ?? 0) >> 4)];
607
+ if (b !== undefined) out += B64URL[((b & 15) << 2) | ((c ?? 0) >> 6)];
608
+ if (c !== undefined) out += B64URL[c & 63];
609
+ }
610
+ return out;
611
+ }
612
+
613
+ function unbase64url(text: string): Uint8Array | undefined {
614
+ if (!/^[A-Za-z0-9_-]+$/.test(text) || text.length % 4 === 1) return undefined;
615
+ const out: number[] = [];
616
+ let buffer = 0;
617
+ let bits = 0;
618
+ for (const ch of text) {
619
+ buffer = (buffer << 6) | B64URL.indexOf(ch);
620
+ bits += 6;
621
+ if (bits >= 8) {
622
+ bits -= 8;
623
+ out.push((buffer >> bits) & 0xff);
624
+ }
625
+ }
626
+ return Uint8Array.from(out);
627
+ }
628
+
629
+ /** `"KV1R-" || base64url(recoveryKey)`, 48 characters, no padding. */
630
+ export function encodeRecoveryCode(recoveryKey: Uint8Array): string {
631
+ if (recoveryKey.length !== KEY_BYTES) throw new KavachError("INVALID_KEY", "recoveryCode");
632
+ return RECOVERY_CODE_PREFIX + base64url(recoveryKey);
633
+ }
634
+
635
+ export function decodeRecoveryCode(text: string): Uint8Array {
636
+ const stage = "recoveryCode";
637
+ if (!text.startsWith(RECOVERY_CODE_PREFIX)) throw malformed(stage, "prefix");
638
+ const value = text.slice(RECOVERY_CODE_PREFIX.length);
639
+ const raw = unbase64url(value);
640
+ if (!raw || raw.length !== KEY_BYTES || base64url(raw) !== value) throw malformed(stage, "not a recovery key");
641
+ return raw;
642
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The primitive backend behind `crypto.ts`. Two implementations exist: `noble` (pure
3
+ * JavaScript, runs on Hermes and in workerd, the default) and `sodium` (libsodium's wasm, for
4
+ * Node and tests). Both are checked against the same vectors; the protocol never sees which one
5
+ * is loaded.
6
+ */
7
+ export interface SignKeypair {
8
+ readonly publicKey: Uint8Array; // 32
9
+ readonly secretKey: Uint8Array; // 64, libsodium layout: seed || public
10
+ }
11
+
12
+ export interface BoxKeypair {
13
+ readonly publicKey: Uint8Array; // 32
14
+ readonly secretKey: Uint8Array; // 32
15
+ }
16
+
17
+ export interface CryptoBackend {
18
+ readonly name: "noble" | "sodium";
19
+ blake2b(data: Uint8Array, outLen: number, key?: Uint8Array): Uint8Array;
20
+ randomBytes(length: number): Uint8Array;
21
+ signKeypairFromSeed(seed: Uint8Array): SignKeypair;
22
+ signDetached(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
23
+ verifyDetached(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean;
24
+ /** Canonical, on the curve, on the main subgroup, not the identity. */
25
+ isValidSignPoint(publicKey: Uint8Array): boolean;
26
+ /** `crypto_box_seed_keypair`: scalar = SHA-512(seed)[0..32], public = X25519 base point multiple. */
27
+ boxKeypairFromSeed(seed: Uint8Array): BoxKeypair;
28
+ boxKeypair(): BoxKeypair;
29
+ /** X25519; throws on an all-zero shared secret. */
30
+ scalarMult(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
31
+ /** `crypto_box_easy`: XSalsa20-Poly1305 under HSalsa20(X25519(sk, pk)); output mac || ciphertext. */
32
+ boxEasy(message: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
33
+ boxOpenEasy(box: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
34
+ /** XChaCha20-Poly1305 with the tag appended. */
35
+ aeadEncrypt(plaintext: Uint8Array, aad: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
36
+ aeadDecrypt(ciphertextAndTag: Uint8Array, aad: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
37
+ memzero(bytes: Uint8Array): void;
38
+ }
39
+
40
+ let current: CryptoBackend | undefined;
41
+
42
+ export function setCryptoBackend(backend: CryptoBackend): void {
43
+ current = backend;
44
+ }
45
+
46
+ export function currentBackend(): CryptoBackend | undefined {
47
+ return current;
48
+ }