@forgezero/runtime 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -814,7 +814,7 @@ Threshold-share parsing, validation and reconstruction. This is a supported entr
814
814
 
815
815
  ```text
816
816
  import {
817
- PROBE_BYTES,
817
+ CUSTODY_ENVELOPE_SUITE,
818
818
  } from '@forgezero/runtime/custody-share';
819
819
  ```
820
820
 
@@ -824,10 +824,10 @@ This minimal executable use imports one concrete value from this exact entry poi
824
824
 
825
825
  ```text
826
826
  import {
827
- PROBE_BYTES,
827
+ CUSTODY_ENVELOPE_SUITE,
828
828
  } from '@forgezero/runtime/custody-share';
829
829
 
830
- export const selectedCapability = PROBE_BYTES;
830
+ export const selectedCapability = CUSTODY_ENVELOPE_SUITE;
831
831
  ```
832
832
 
833
833
  <a id="forgezero-runtime-custody-crypto"></a>
@@ -1,8 +1,55 @@
1
1
  import { type CipherBox, type SealedToKey } from './custody-crypto';
2
+ export declare const CUSTODY_ENVELOPE_VERSION: 3;
3
+ export declare const CUSTODY_ENVELOPE_SUITE: "ml-kem-768+x25519+hkdf-sha256+aes-256-gcm+ml-dsa-65";
4
+ export declare const CUSTODY_FACTOR_PROOF_VERSION: 1;
5
+ export type CustodyCredentialPurpose = 'custody';
6
+ export type CustodyCredentialLifecycle = 'pending' | 'active' | 'retired';
7
+ export type CustodyFactor = 'ceremony_passkey' | 'recovery_phrase';
8
+ export type CustodyEnvelopeGeneration = number;
9
+ export type CustodyTransactionOutcome = 'completed' | 'factor_mismatch' | 'credential_mismatch' | 'replayed' | 'expired' | 'batch_mismatch' | 'seed_fingerprint_mismatch';
10
+ export interface CustodyFactorBinding {
11
+ version: typeof CUSTODY_FACTOR_PROOF_VERSION;
12
+ realmId: string;
13
+ ceremonyKey: string;
14
+ custodianKey: string;
15
+ factor: CustodyFactor;
16
+ requestKey: string;
17
+ method: 'POST';
18
+ path: string;
19
+ payloadDigest: string;
20
+ challenge: string;
21
+ expiresAtSec: number;
22
+ }
23
+ export interface CustodyPasskeyProofV1 {
24
+ version: typeof CUSTODY_FACTOR_PROOF_VERSION;
25
+ factor: 'ceremony_passkey';
26
+ credentialId: string;
27
+ signature: string;
28
+ }
29
+ export interface CustodyPhraseProofV1 {
30
+ version: typeof CUSTODY_FACTOR_PROOF_VERSION;
31
+ factor: 'recovery_phrase';
32
+ signature: string;
33
+ }
34
+ export type CustodyFactorProof = CustodyPasskeyProofV1 | CustodyPhraseProofV1;
35
+ export interface CustodyActionRequest {
36
+ _key: string;
37
+ ceremonyKey: string;
38
+ ownerUserKey: string;
39
+ operation: 'seal' | 'verify' | 'reshare' | 'unlock' | 'rotate' | 'verify_rotation';
40
+ allowedFactors: CustodyFactor[];
41
+ payloadDigest: string;
42
+ challenge: string;
43
+ status: 'open' | 'consumed' | 'expired';
44
+ expiresAtSec: number;
45
+ revision: number;
46
+ }
2
47
  export interface SealedShare {
3
48
  shareIndex: number;
4
- /** Account credential whose PRF deterministically derives this passkey key. */
49
+ /** Dedicated custody credential whose PRF deterministically derives this passkey key. */
5
50
  passkeyCredentialId?: string;
51
+ /** Base64url ceremony salt persisted with that exact custody credential. */
52
+ passkeyPrfSalt?: string;
6
53
  passkeyEnvelope: CipherBox;
7
54
  phraseEnvelope: CipherBox;
8
55
  /** base64 — the HKDF salt for the phrase key and the verifier. */
@@ -10,6 +57,22 @@ export interface SealedShare {
10
57
  /** hex — proves the right phrase without being able to rebuild it. */
11
58
  phraseVerifier: string;
12
59
  }
60
+ /** New writes use this shape. V2 is retained only for bounded unlock-and-rotate migration. */
61
+ export interface CustodyEnvelopeV3 extends SealedShare {
62
+ version: typeof CUSTODY_ENVELOPE_VERSION;
63
+ suite: typeof CUSTODY_ENVELOPE_SUITE;
64
+ generation: CustodyEnvelopeGeneration;
65
+ passkeyGeneration?: CustodyEnvelopeGeneration;
66
+ phraseGeneration?: CustodyEnvelopeGeneration;
67
+ realmId: string;
68
+ ceremonyKey: string;
69
+ custodianKey: string;
70
+ passkeyCredentialId: string;
71
+ passkeyPrfSalt: string;
72
+ passkeyActionPublicKey: string;
73
+ phraseActionPublicKey: string;
74
+ }
75
+ export declare function isCustodyEnvelopeV3(value: SealedShare): value is CustodyEnvelopeV3;
13
76
  type EnvelopeKind = 'passkey' | 'phrase';
14
77
  /** The x-coordinate a share was cut at — its custodian-facing share number. */
15
78
  export declare function shareIndexOf(share: Uint8Array): number;
@@ -44,12 +107,15 @@ export declare function openShareWithPhrase(sealed: SealedShare, custodianKey: s
44
107
  * ever sent.
45
108
  */
46
109
  export interface WrappingKeys {
47
- /** The verified, PRF-capable account credential selected by the browser. */
110
+ /** The verified, PRF-capable custody credential selected by the browser. */
48
111
  passkeyCredentialId: string;
112
+ passkeyPrfSalt: string;
49
113
  passkeyPublicKey: string;
50
114
  phrasePublicKey: string;
51
115
  phraseSalt: string;
52
116
  phraseVerifier: string;
117
+ passkeyActionPublicKey: string;
118
+ phraseActionPublicKey: string;
53
119
  }
54
120
  /** The private half for one factor. Never leaves the machine that made it. */
55
121
  export declare function passkeyWrappingKey(custodianKey: string, passkeyPrfOutput: Uint8Array): {
@@ -65,9 +131,25 @@ export declare function phraseWrappingKey(custodianKey: string, phraseWords: str
65
131
  export declare function wrappingKeysFor(args: {
66
132
  custodianKey: string;
67
133
  passkeyCredentialId: string;
134
+ passkeyPrfSalt: string;
68
135
  passkeyPrfOutput: Uint8Array;
69
136
  phraseWords: string[];
70
137
  }): WrappingKeys;
138
+ export declare function custodyFactorActionPublicKey(factor: CustodyFactor, custodianKey: string, factorMaterial: Uint8Array): string;
139
+ /** Stable browser/API digest used to bind a custody proof to one exact payload. */
140
+ export declare function custodyActionPayloadDigest(value: unknown): string;
141
+ export declare function createCustodyFactorProof(args: {
142
+ binding: CustodyFactorBinding;
143
+ factorMaterial: Uint8Array;
144
+ credentialId?: string;
145
+ }): CustodyFactorProof;
146
+ export declare function createCustodyPasskeyProof(binding: CustodyFactorBinding, passkeyPrfOutput: Uint8Array, credentialId: string): CustodyPasskeyProofV1;
147
+ export declare function createCustodyPhraseProof(binding: CustodyFactorBinding, phraseWords: string[], phraseSalt: string): CustodyPhraseProofV1;
148
+ export declare function verifyCustodyFactorProof(args: {
149
+ binding: CustodyFactorBinding;
150
+ proof: CustodyFactorProof;
151
+ publicKey: string;
152
+ }): boolean;
71
153
  /** Open something the server sealed to one of those public keys. */
72
154
  export declare function openSealedToFactor(args: {
73
155
  custodianKey: string;
@@ -76,9 +158,12 @@ export declare function openSealedToFactor(args: {
76
158
  passkeyPrfOutput?: Uint8Array;
77
159
  phraseWords?: string[];
78
160
  phraseSalt?: string;
161
+ aad?: string;
79
162
  }): Uint8Array;
80
163
  /** The aad the server must use when sealing to a custodian's factor key. */
81
164
  export declare const factorWrapAad: (custodianKey: string, factor: EnvelopeKind) => string;
165
+ /** Canonical AAD for V3. Length-prefixing prevents ambiguous concatenation. */
166
+ export declare function custodyEnvelopeV3Aad(envelope: Pick<CustodyEnvelopeV3, 'version' | 'suite' | 'realmId' | 'ceremonyKey' | 'custodianKey' | 'shareIndex' | 'passkeyCredentialId' | 'generation' | 'passkeyGeneration' | 'phraseGeneration'>, factor: EnvelopeKind): string;
82
167
  /**
83
168
  * A share plus a probe, sealed together.
84
169
  *
@@ -174,9 +174,18 @@ function phraseVerifier(words, salt) {
174
174
  }
175
175
 
176
176
  // src/custody-share.ts
177
+ import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
178
+ import { hkdf as hkdf3 } from "@noble/hashes/hkdf.js";
179
+ import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
180
+ var CUSTODY_ENVELOPE_VERSION = 3;
181
+ var CUSTODY_ENVELOPE_SUITE = "ml-kem-768+x25519+hkdf-sha256+aes-256-gcm+ml-dsa-65";
182
+ var CUSTODY_FACTOR_PROOF_VERSION = 1;
177
183
  var KEY_BYTES3 = 32;
178
184
  var MIN_PRF_BYTES = 32;
179
185
  var PASSKEY_KEY_SALT = "forgezero:custodian:passkey:v1";
186
+ function isCustodyEnvelopeV3(value) {
187
+ return value.version === CUSTODY_ENVELOPE_VERSION && value.suite === CUSTODY_ENVELOPE_SUITE;
188
+ }
180
189
  var utf82 = (value) => new TextEncoder().encode(value);
181
190
  var toBase642 = (bytes) => {
182
191
  let binary = "";
@@ -283,31 +292,167 @@ function phraseWrappingKey(custodianKey, phraseWords, salt) {
283
292
  function wrappingKeysFor(args) {
284
293
  if (!args.passkeyCredentialId)
285
294
  throw new Error("custody-share: passkey credential id is required");
295
+ if (!/^[A-Za-z0-9_-]{43}$/.test(args.passkeyPrfSalt)) {
296
+ throw new Error("custody-share: ceremony passkey PRF salt is required");
297
+ }
286
298
  const salt = newSalt();
287
299
  const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
288
300
  const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
301
+ const passkeyAction = custodyFactorActionKeyPair("ceremony_passkey", args.custodianKey, args.passkeyPrfOutput);
302
+ const phraseMaterial = phraseToKey(args.phraseWords, salt);
303
+ const phraseAction = custodyFactorActionKeyPair("recovery_phrase", args.custodianKey, phraseMaterial);
289
304
  try {
290
305
  return {
291
306
  passkeyCredentialId: args.passkeyCredentialId,
307
+ passkeyPrfSalt: args.passkeyPrfSalt,
292
308
  passkeyPublicKey: toBase642(passkey.publicKey),
293
309
  phrasePublicKey: toBase642(phrase.publicKey),
294
310
  phraseSalt: toBase642(salt),
295
- phraseVerifier: phraseVerifier(args.phraseWords, salt)
311
+ phraseVerifier: phraseVerifier(args.phraseWords, salt),
312
+ passkeyActionPublicKey: toBase642(passkeyAction.publicKey),
313
+ phraseActionPublicKey: toBase642(phraseAction.publicKey)
296
314
  };
297
315
  } finally {
298
316
  passkey.secretKey.fill(0);
299
317
  phrase.secretKey.fill(0);
318
+ passkeyAction.secretKey.fill(0);
319
+ phraseAction.secretKey.fill(0);
320
+ phraseMaterial.fill(0);
321
+ }
322
+ }
323
+ var actionInfo = (factor, custodianKey) => `forgezero:custody:factor-action:ml-dsa-65:v1:${factor}:${custodianKey.length}:${custodianKey}`;
324
+ function custodyFactorActionKeyPair(factor, custodianKey, factorMaterial) {
325
+ assertCustodianKey(custodianKey);
326
+ if (factorMaterial.length < 32)
327
+ throw new Error("custody-share: action factor must be at least 32 bytes");
328
+ const seed = hkdf3(sha2563, factorMaterial, utf82("forgezero:custody:factor-action:ml-dsa-65:v1"), utf82(actionInfo(factor, custodianKey)), 32);
329
+ try {
330
+ const pair = ml_dsa65.keygen(seed);
331
+ return { publicKey: Uint8Array.from(pair.publicKey), secretKey: Uint8Array.from(pair.secretKey) };
332
+ } finally {
333
+ seed.fill(0);
334
+ }
335
+ }
336
+ function custodyFactorActionPublicKey(factor, custodianKey, factorMaterial) {
337
+ const pair = custodyFactorActionKeyPair(factor, custodianKey, factorMaterial);
338
+ try {
339
+ return toBase642(pair.publicKey);
340
+ } finally {
341
+ pair.secretKey.fill(0);
342
+ }
343
+ }
344
+ var proofBytes = (binding) => {
345
+ const values = [
346
+ "forgezero-custody-factor-proof-v1",
347
+ String(binding.version),
348
+ binding.realmId,
349
+ binding.ceremonyKey,
350
+ binding.custodianKey,
351
+ binding.factor,
352
+ binding.requestKey,
353
+ binding.method,
354
+ binding.path,
355
+ binding.payloadDigest,
356
+ binding.challenge,
357
+ String(binding.expiresAtSec)
358
+ ];
359
+ return utf82(values.map((value) => `${utf82(value).length}:${value}`).join(`
360
+ `));
361
+ };
362
+ function canonicalJson(value) {
363
+ if (value === null || typeof value === "boolean" || typeof value === "string")
364
+ return JSON.stringify(value);
365
+ if (typeof value === "number") {
366
+ if (!Number.isFinite(value))
367
+ throw new Error("custody-share: non-finite action payload number");
368
+ return JSON.stringify(value);
369
+ }
370
+ if (Array.isArray(value))
371
+ return `[${value.map(canonicalJson).join(",")}]`;
372
+ if (typeof value === "object") {
373
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
374
+ }
375
+ throw new Error("custody-share: unsupported action payload value");
376
+ }
377
+ function custodyActionPayloadDigest(value) {
378
+ return Array.from(sha2563(utf82(canonicalJson(value))), (byte) => byte.toString(16).padStart(2, "0")).join("");
379
+ }
380
+ function createCustodyFactorProof(args) {
381
+ if (args.binding.version !== CUSTODY_FACTOR_PROOF_VERSION) {
382
+ throw new Error("custody-share: unsupported factor proof version");
383
+ }
384
+ const pair = custodyFactorActionKeyPair(args.binding.factor, args.binding.custodianKey, args.factorMaterial);
385
+ try {
386
+ const signature = toBase642(ml_dsa65.sign(proofBytes(args.binding), pair.secretKey));
387
+ return args.binding.factor === "ceremony_passkey" ? {
388
+ version: CUSTODY_FACTOR_PROOF_VERSION,
389
+ factor: "ceremony_passkey",
390
+ credentialId: args.credentialId ?? "",
391
+ signature
392
+ } : { version: CUSTODY_FACTOR_PROOF_VERSION, factor: "recovery_phrase", signature };
393
+ } finally {
394
+ pair.secretKey.fill(0);
395
+ }
396
+ }
397
+ function createCustodyPasskeyProof(binding, passkeyPrfOutput, credentialId) {
398
+ if (binding.factor !== "ceremony_passkey") {
399
+ throw new Error("custody-share: passkey proof requires ceremony_passkey binding");
400
+ }
401
+ return createCustodyFactorProof({
402
+ binding,
403
+ factorMaterial: passkeyPrfOutput,
404
+ credentialId
405
+ });
406
+ }
407
+ function createCustodyPhraseProof(binding, phraseWords, phraseSalt) {
408
+ if (binding.factor !== "recovery_phrase") {
409
+ throw new Error("custody-share: phrase proof requires recovery_phrase binding");
410
+ }
411
+ if (!validatePhrase(phraseWords))
412
+ throw new Error("INVALID_PHRASE");
413
+ const material = phraseToKey(phraseWords, fromBase642(phraseSalt));
414
+ try {
415
+ return createCustodyFactorProof({ binding, factorMaterial: material });
416
+ } finally {
417
+ material.fill(0);
418
+ }
419
+ }
420
+ function verifyCustodyFactorProof(args) {
421
+ try {
422
+ if (args.proof.version !== CUSTODY_FACTOR_PROOF_VERSION || args.proof.factor !== args.binding.factor)
423
+ return false;
424
+ const publicKey = fromBase642(args.publicKey);
425
+ const signature = fromBase642(args.proof.signature);
426
+ return publicKey.length === ml_dsa65.lengths.publicKey && signature.length === ml_dsa65.lengths.signature && ml_dsa65.verify(signature, proofBytes(args.binding), publicKey);
427
+ } catch {
428
+ return false;
300
429
  }
301
430
  }
302
431
  function openSealedToFactor(args) {
303
432
  const pair = args.factor === "passkey" ? passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput) : phraseWrappingKey(args.custodianKey, args.phraseWords, fromBase642(args.phraseSalt));
304
433
  try {
305
- return openFromKey(pair.secretKey, args.box, wrapInfo(args.custodianKey, args.factor));
434
+ return openFromKey(pair.secretKey, args.box, args.aad ?? wrapInfo(args.custodianKey, args.factor));
306
435
  } finally {
307
436
  pair.secretKey.fill(0);
308
437
  }
309
438
  }
310
439
  var factorWrapAad = (custodianKey, factor) => wrapInfo(custodianKey, factor);
440
+ function custodyEnvelopeV3Aad(envelope, factor) {
441
+ const values = [
442
+ "forgezero-custody-envelope-aad",
443
+ String(envelope.version),
444
+ envelope.suite,
445
+ envelope.realmId,
446
+ envelope.ceremonyKey,
447
+ envelope.custodianKey,
448
+ String(envelope.shareIndex),
449
+ factor,
450
+ factor === "passkey" ? envelope.passkeyCredentialId : "recovery_phrase",
451
+ String(factor === "passkey" ? envelope.passkeyGeneration ?? envelope.generation : envelope.phraseGeneration ?? envelope.generation)
452
+ ];
453
+ return values.map((value) => `${utf82(value).length}:${value}`).join(`
454
+ `);
455
+ }
311
456
  var PROBE_BYTES = 32;
312
457
  function joinProbeAndShare(probe, share) {
313
458
  if (probe.length !== PROBE_BYTES)
@@ -324,17 +469,20 @@ function splitProbeAndShare(opened) {
324
469
  }
325
470
  function openFactorEnvelope(args) {
326
471
  const box = args.factor === "passkey" ? args.sealed.passkeyEnvelope : args.sealed.phraseEnvelope;
472
+ const aad = isCustodyEnvelopeV3(args.sealed) ? custodyEnvelopeV3Aad(args.sealed, args.factor) : undefined;
327
473
  return splitProbeAndShare(openSealedToFactor({
328
474
  custodianKey: args.custodianKey,
329
475
  factor: args.factor,
330
476
  box,
331
477
  passkeyPrfOutput: args.passkeyPrfOutput,
332
478
  phraseWords: args.phraseWords,
333
- phraseSalt: args.sealed.phraseSalt
479
+ phraseSalt: args.sealed.phraseSalt,
480
+ aad
334
481
  }));
335
482
  }
336
483
  export {
337
484
  wrappingKeysFor,
485
+ verifyCustodyFactorProof,
338
486
  splitProbeAndShare,
339
487
  shareIndexOf,
340
488
  sealShare,
@@ -345,6 +493,16 @@ export {
345
493
  openSealedToFactor,
346
494
  openFactorEnvelope,
347
495
  joinProbeAndShare,
496
+ isCustodyEnvelopeV3,
348
497
  factorWrapAad,
349
- PROBE_BYTES
498
+ custodyFactorActionPublicKey,
499
+ custodyEnvelopeV3Aad,
500
+ custodyActionPayloadDigest,
501
+ createCustodyPhraseProof,
502
+ createCustodyPasskeyProof,
503
+ createCustodyFactorProof,
504
+ PROBE_BYTES,
505
+ CUSTODY_FACTOR_PROOF_VERSION,
506
+ CUSTODY_ENVELOPE_VERSION,
507
+ CUSTODY_ENVELOPE_SUITE
350
508
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/runtime",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",