@forgezero/runtime 0.1.5 → 0.1.7

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.
@@ -28,13 +28,15 @@ export declare function openWithKey(key: Uint8Array, box: CipherBox, aad: string
28
28
  * plaintext — so a captured response is ciphertext, and opening it requires the
29
29
  * factor itself.
30
30
  *
31
- * X25519 + HKDF-SHA256 + AES-256-GCM, with the ephemeral public key carried in
32
- * the box. Nothing bespoke: the ephemeral-static shape is what every sealed-box
33
- * construction uses, and doing it by hand is how people lose the AAD.
31
+ * ML-KEM-768 + X25519 + HKDF-SHA256 + AES-256-GCM. Both KEM halves are
32
+ * mandatory: a captured custody envelope remains confidential after a future
33
+ * break of X25519, while the classical half hedges a failure in the newer KEM.
34
+ * The version is explicit and the opener accepts no legacy X25519-only shape.
34
35
  */
35
36
  export interface SealedToKey extends CipherBox {
36
- /** base64 — the ephemeral public key this box was sealed with. */
37
- ephemeral: string;
37
+ version: 2;
38
+ /** base64 — the mandatory hybrid ML-KEM-768 + X25519 ciphertext. */
39
+ kemCiphertext: string;
38
40
  }
39
41
  /** Seal to a recipient's public key. */
40
42
  export declare function sealToKey(recipientPublicKey: Uint8Array, plaintext: Uint8Array, aad: string): SealedToKey;
@@ -8,9 +8,9 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
8
8
 
9
9
  // src/custody-crypto.ts
10
10
  import { gcm } from "@noble/ciphers/aes.js";
11
- import { x25519 } from "@noble/curves/ed25519.js";
12
11
  import { hkdf } from "@noble/hashes/hkdf.js";
13
12
  import { sha256 } from "@noble/hashes/sha2.js";
13
+ import { ml_kem768_x25519 } from "@noble/post-quantum/hybrid.js";
14
14
  var KEY_BYTES = 32;
15
15
  var NONCE_BYTES = 12;
16
16
  var toBase64 = (bytes) => {
@@ -44,40 +44,62 @@ function openWithKey(key, box, aad) {
44
44
  throw new Error(`custody: unknown algorithm ${box.alg}`);
45
45
  return gcm(key, fromBase64(box.nonce), utf8(aad)).decrypt(fromBase64(box.ciphertext));
46
46
  }
47
- var WRAP_INFO = "forgezero:custody:wrap:v1";
48
- var wrapKey = (shared, ephemeral, recipient) => hkdf(sha256, shared, concatBytes(ephemeral, recipient), utf8(WRAP_INFO), 32);
49
- function concatBytes(left, right) {
50
- const out = new Uint8Array(left.length + right.length);
51
- out.set(left, 0);
52
- out.set(right, left.length);
53
- return out;
54
- }
47
+ var WRAP_INFO = "forgezero:custody:wrap:ml-kem-768+x25519:v2";
48
+ var WRAP_SEED_SALT = utf8("forgezero:custody:wrapkey:ml-kem-768+x25519:v2");
49
+ var wrapKey = (shared) => hkdf(sha256, shared, undefined, utf8(WRAP_INFO), 32);
55
50
  function sealToKey(recipientPublicKey, plaintext, aad) {
56
- const ephemeralSecret = x25519.utils.randomSecretKey();
57
- const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);
58
- const shared = x25519.getSharedSecret(ephemeralSecret, recipientPublicKey);
59
- const key = wrapKey(shared, ephemeralPublic, recipientPublicKey);
51
+ if (recipientPublicKey.length !== ml_kem768_x25519.lengths.publicKey) {
52
+ throw new Error("custody: invalid hybrid recipient public key");
53
+ }
54
+ const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipientPublicKey);
55
+ const shared = Uint8Array.from(sharedSecret);
56
+ const ciphertext = Uint8Array.from(cipherText);
57
+ const key = wrapKey(shared);
60
58
  try {
61
- return { ...sealWithKey(key, plaintext, aad), ephemeral: toBase64(ephemeralPublic) };
59
+ return {
60
+ version: 2,
61
+ ...sealWithKey(key, plaintext, aad),
62
+ kemCiphertext: toBase64(ciphertext)
63
+ };
62
64
  } finally {
63
65
  key.fill(0);
64
- ephemeralSecret.fill(0);
66
+ shared.fill(0);
65
67
  }
66
68
  }
67
69
  function openFromKey(recipientSecretKey, box, aad) {
68
- const ephemeralPublic = fromBase64(box.ephemeral);
69
- const recipientPublic = x25519.getPublicKey(recipientSecretKey);
70
- const shared = x25519.getSharedSecret(recipientSecretKey, ephemeralPublic);
71
- const key = wrapKey(shared, ephemeralPublic, recipientPublic);
70
+ if (box?.version !== 2 || typeof box.kemCiphertext !== "string") {
71
+ throw new Error("custody: unsupported sealed-to-key envelope");
72
+ }
73
+ if (recipientSecretKey.length !== ml_kem768_x25519.lengths.secretKey) {
74
+ throw new Error("custody: invalid hybrid recipient secret key");
75
+ }
76
+ const ciphertext = fromBase64(box.kemCiphertext);
77
+ if (ciphertext.length !== ml_kem768_x25519.lengths.cipherText) {
78
+ throw new Error("custody: invalid hybrid KEM ciphertext");
79
+ }
80
+ const shared = Uint8Array.from(ml_kem768_x25519.decapsulate(ciphertext, recipientSecretKey));
81
+ const key = wrapKey(shared);
72
82
  try {
73
83
  return openWithKey(key, box, aad);
74
84
  } finally {
75
85
  key.fill(0);
86
+ shared.fill(0);
76
87
  }
77
88
  }
78
89
  function wrappingKeyPair(factorMaterial, info) {
79
- const secretKey = hkdf(sha256, factorMaterial, utf8("forgezero:custody:wrapkey:v1"), utf8(info), 32);
80
- return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
90
+ if (factorMaterial.length < 32) {
91
+ throw new Error("custody: wrapping factor material must be at least 32 bytes");
92
+ }
93
+ const seed = hkdf(sha256, factorMaterial, WRAP_SEED_SALT, utf8(info), 32);
94
+ try {
95
+ const pair = ml_kem768_x25519.keygen(seed);
96
+ return {
97
+ secretKey: Uint8Array.from(pair.secretKey),
98
+ publicKey: Uint8Array.from(pair.publicKey)
99
+ };
100
+ } finally {
101
+ seed.fill(0);
102
+ }
81
103
  }
82
104
  export {
83
105
  wrappingKeyPair,
@@ -1,6 +1,8 @@
1
1
  import { type CipherBox, type SealedToKey } from './custody-crypto';
2
2
  export interface SealedShare {
3
3
  shareIndex: number;
4
+ /** Account credential whose PRF deterministically derives this passkey key. */
5
+ passkeyCredentialId?: string;
4
6
  passkeyEnvelope: CipherBox;
5
7
  phraseEnvelope: CipherBox;
6
8
  /** base64 — the HKDF salt for the phrase key and the verifier. */
@@ -42,6 +44,8 @@ export declare function openShareWithPhrase(sealed: SealedShare, custodianKey: s
42
44
  * ever sent.
43
45
  */
44
46
  export interface WrappingKeys {
47
+ /** The verified, PRF-capable account credential selected by the browser. */
48
+ passkeyCredentialId: string;
45
49
  passkeyPublicKey: string;
46
50
  phrasePublicKey: string;
47
51
  phraseSalt: string;
@@ -60,6 +64,7 @@ export declare function phraseWrappingKey(custodianKey: string, phraseWords: str
60
64
  /** Everything the server needs, and nothing it must not have. */
61
65
  export declare function wrappingKeysFor(args: {
62
66
  custodianKey: string;
67
+ passkeyCredentialId: string;
63
68
  passkeyPrfOutput: Uint8Array;
64
69
  phraseWords: string[];
65
70
  }): WrappingKeys;
@@ -8,9 +8,9 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
8
8
 
9
9
  // src/custody-crypto.ts
10
10
  import { gcm } from "@noble/ciphers/aes.js";
11
- import { x25519 } from "@noble/curves/ed25519.js";
12
11
  import { hkdf } from "@noble/hashes/hkdf.js";
13
12
  import { sha256 } from "@noble/hashes/sha2.js";
13
+ import { ml_kem768_x25519 } from "@noble/post-quantum/hybrid.js";
14
14
  var KEY_BYTES = 32;
15
15
  var NONCE_BYTES = 12;
16
16
  var toBase64 = (bytes) => {
@@ -44,40 +44,62 @@ function openWithKey(key, box, aad) {
44
44
  throw new Error(`custody: unknown algorithm ${box.alg}`);
45
45
  return gcm(key, fromBase64(box.nonce), utf8(aad)).decrypt(fromBase64(box.ciphertext));
46
46
  }
47
- var WRAP_INFO = "forgezero:custody:wrap:v1";
48
- var wrapKey = (shared, ephemeral, recipient) => hkdf(sha256, shared, concatBytes(ephemeral, recipient), utf8(WRAP_INFO), 32);
49
- function concatBytes(left, right) {
50
- const out = new Uint8Array(left.length + right.length);
51
- out.set(left, 0);
52
- out.set(right, left.length);
53
- return out;
54
- }
47
+ var WRAP_INFO = "forgezero:custody:wrap:ml-kem-768+x25519:v2";
48
+ var WRAP_SEED_SALT = utf8("forgezero:custody:wrapkey:ml-kem-768+x25519:v2");
49
+ var wrapKey = (shared) => hkdf(sha256, shared, undefined, utf8(WRAP_INFO), 32);
55
50
  function sealToKey(recipientPublicKey, plaintext, aad) {
56
- const ephemeralSecret = x25519.utils.randomSecretKey();
57
- const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);
58
- const shared = x25519.getSharedSecret(ephemeralSecret, recipientPublicKey);
59
- const key = wrapKey(shared, ephemeralPublic, recipientPublicKey);
51
+ if (recipientPublicKey.length !== ml_kem768_x25519.lengths.publicKey) {
52
+ throw new Error("custody: invalid hybrid recipient public key");
53
+ }
54
+ const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipientPublicKey);
55
+ const shared = Uint8Array.from(sharedSecret);
56
+ const ciphertext = Uint8Array.from(cipherText);
57
+ const key = wrapKey(shared);
60
58
  try {
61
- return { ...sealWithKey(key, plaintext, aad), ephemeral: toBase64(ephemeralPublic) };
59
+ return {
60
+ version: 2,
61
+ ...sealWithKey(key, plaintext, aad),
62
+ kemCiphertext: toBase64(ciphertext)
63
+ };
62
64
  } finally {
63
65
  key.fill(0);
64
- ephemeralSecret.fill(0);
66
+ shared.fill(0);
65
67
  }
66
68
  }
67
69
  function openFromKey(recipientSecretKey, box, aad) {
68
- const ephemeralPublic = fromBase64(box.ephemeral);
69
- const recipientPublic = x25519.getPublicKey(recipientSecretKey);
70
- const shared = x25519.getSharedSecret(recipientSecretKey, ephemeralPublic);
71
- const key = wrapKey(shared, ephemeralPublic, recipientPublic);
70
+ if (box?.version !== 2 || typeof box.kemCiphertext !== "string") {
71
+ throw new Error("custody: unsupported sealed-to-key envelope");
72
+ }
73
+ if (recipientSecretKey.length !== ml_kem768_x25519.lengths.secretKey) {
74
+ throw new Error("custody: invalid hybrid recipient secret key");
75
+ }
76
+ const ciphertext = fromBase64(box.kemCiphertext);
77
+ if (ciphertext.length !== ml_kem768_x25519.lengths.cipherText) {
78
+ throw new Error("custody: invalid hybrid KEM ciphertext");
79
+ }
80
+ const shared = Uint8Array.from(ml_kem768_x25519.decapsulate(ciphertext, recipientSecretKey));
81
+ const key = wrapKey(shared);
72
82
  try {
73
83
  return openWithKey(key, box, aad);
74
84
  } finally {
75
85
  key.fill(0);
86
+ shared.fill(0);
76
87
  }
77
88
  }
78
89
  function wrappingKeyPair(factorMaterial, info) {
79
- const secretKey = hkdf(sha256, factorMaterial, utf8("forgezero:custody:wrapkey:v1"), utf8(info), 32);
80
- return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
90
+ if (factorMaterial.length < 32) {
91
+ throw new Error("custody: wrapping factor material must be at least 32 bytes");
92
+ }
93
+ const seed = hkdf(sha256, factorMaterial, WRAP_SEED_SALT, utf8(info), 32);
94
+ try {
95
+ const pair = ml_kem768_x25519.keygen(seed);
96
+ return {
97
+ secretKey: Uint8Array.from(pair.secretKey),
98
+ publicKey: Uint8Array.from(pair.publicKey)
99
+ };
100
+ } finally {
101
+ seed.fill(0);
102
+ }
81
103
  }
82
104
 
83
105
  // src/phrase.ts
@@ -247,11 +269,14 @@ function phraseWrappingKey(custodianKey, phraseWords, salt) {
247
269
  return wrappingKeyPair(phraseToKey(phraseWords, salt), wrapInfo(custodianKey, "phrase"));
248
270
  }
249
271
  function wrappingKeysFor(args) {
272
+ if (!args.passkeyCredentialId)
273
+ throw new Error("custody-share: passkey credential id is required");
250
274
  const salt = newSalt();
251
275
  const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
252
276
  const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
253
277
  try {
254
278
  return {
279
+ passkeyCredentialId: args.passkeyCredentialId,
255
280
  passkeyPublicKey: toBase642(passkey.publicKey),
256
281
  phrasePublicKey: toBase642(phrase.publicKey),
257
282
  phraseSalt: toBase642(salt),
@@ -9,9 +9,9 @@
9
9
  * are young; Ed25519 is not. Requiring both means a break in either one leaves
10
10
  * the other standing, and the cost is a few hundred bytes per request.
11
11
  *
12
- * This is the one place ForgeZero uses asymmetric post-quantum crypto. Custody
13
- * needs none it is symmetric and Shamir throughout — but a node signature is
14
- * genuinely a signature, so there is a Shor target here and nowhere else.
12
+ * This is the machine-identity use of asymmetric post-quantum crypto. Browser
13
+ * passkeys have a separate PRF-derived ML-DSA companion in passkey-hybrid.ts,
14
+ * while persisted custody remains symmetric and Shamir-based.
15
15
  *
16
16
  * ## Where the primitives come from
17
17
  *
@@ -55,9 +55,13 @@ export interface NodePublicKeys {
55
55
  * break in one leaves the other genuinely independent.
56
56
  */
57
57
  export declare function deriveKeysFromSeed(seed: Uint8Array): NodeKeyPair;
58
+ /** Derive only public halves for server-side credential issuance, then erase every private byte. */
59
+ export declare function derivePublicKeysFromSeed(seed: Uint8Array): NodePublicKeys;
58
60
  /** Generate a node key pair. The secret halves never leave the caller. */
59
61
  export declare function generateNodeKeys(): NodeKeyPair;
60
62
  export interface SignedEnvelope {
63
+ version: 1;
64
+ suite: typeof REQUEST_SIGNATURE_SUITE;
61
65
  nodeKey: string;
62
66
  timestamp: number;
63
67
  nonce: string;
@@ -70,11 +74,14 @@ export interface ResponseRecipient {
70
74
  }
71
75
  export interface SealedResponse {
72
76
  version: 1;
77
+ suite: typeof RESPONSE_SEALING_SUITE;
73
78
  kemCiphertext: string;
74
79
  nonce: string;
75
80
  ciphertext: string;
76
81
  }
77
82
  export declare const RESPONSE_KEY_HEADER = "x-fz-response-key";
83
+ export declare const REQUEST_SIGNATURE_SUITE: "ed25519+ml-dsa-65";
84
+ export declare const RESPONSE_SEALING_SUITE: "ml-kem-768+x25519/aes-256-gcm";
78
85
  export declare function validResponsePublicKey(value: string): boolean;
79
86
  /** One ephemeral hybrid ML-KEM-768 + X25519 recipient per request. */
80
87
  export declare function generateResponseRecipient(): ResponseRecipient;
@@ -95,6 +102,7 @@ export declare function decodeSignatureHeader(raw: string, nodeKey: string): Sig
95
102
  * large upload does not have to be buffered twice.
96
103
  */
97
104
  export declare function canonicalString(args: {
105
+ identity: string;
98
106
  method: string;
99
107
  path: string;
100
108
  query: string;
package/dist/identity.js CHANGED
@@ -26,10 +26,26 @@ function deriveKeysFromSeed(seed) {
26
26
  const edPublic = ed25519.getPublicKey(edSecret);
27
27
  const mlKeys = ml_dsa65.keygen(mlSeed);
28
28
  mlSeed.fill(0);
29
- return {
29
+ const result = {
30
30
  ed25519: { publicKey: b64(edPublic), secretKey: b64(edSecret) },
31
31
  mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
32
32
  };
33
+ edSecret.fill(0);
34
+ mlKeys.secretKey.fill(0);
35
+ return result;
36
+ }
37
+ function derivePublicKeysFromSeed(seed) {
38
+ if (seed.length < 32)
39
+ throw new Error("identity: seed must be at least 32 bytes");
40
+ const edSecret = hkdf(sha256, seed, undefined, ENCODER.encode("forgezero/identity/ed25519/v1"), 32);
41
+ const mlSeed = hkdf(sha256, seed, undefined, ENCODER.encode("forgezero/identity/ml-dsa-65/v1"), 32);
42
+ const edPublic = ed25519.getPublicKey(edSecret);
43
+ const mlKeys = ml_dsa65.keygen(mlSeed);
44
+ const result = { ed25519: b64(edPublic), mlDsa: b64(mlKeys.publicKey) };
45
+ edSecret.fill(0);
46
+ mlSeed.fill(0);
47
+ mlKeys.secretKey.fill(0);
48
+ return result;
33
49
  }
34
50
  function generateNodeKeys() {
35
51
  const edSecret = ed25519.utils.randomSecretKey();
@@ -37,26 +53,60 @@ function generateNodeKeys() {
37
53
  const seed = randomBytes(32);
38
54
  const mlKeys = ml_dsa65.keygen(seed);
39
55
  seed.fill(0);
40
- return {
56
+ const result = {
41
57
  ed25519: { publicKey: b64(edPublic), secretKey: b64(edSecret) },
42
58
  mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
43
59
  };
60
+ edSecret.fill(0);
61
+ mlKeys.secretKey.fill(0);
62
+ return result;
44
63
  }
45
64
  var RESPONSE_KEY_HEADER = "x-fz-response-key";
46
- function validResponsePublicKey(value) {
65
+ var REQUEST_SIGNATURE_SUITE = "ed25519+ml-dsa-65";
66
+ var RESPONSE_SEALING_SUITE = "ml-kem-768+x25519/aes-256-gcm";
67
+ var BASE64URL = /^[A-Za-z0-9_-]+$/;
68
+ var encodedLength = (bytes) => Math.ceil(bytes * 4 / 3);
69
+ var length = (value, name) => {
70
+ if (!Number.isSafeInteger(value) || value <= 0)
71
+ throw new Error(`identity: ${name} length unavailable`);
72
+ return value;
73
+ };
74
+ var ED_PUBLIC_BYTES = length(ed25519.lengths.publicKey, "Ed25519 public key");
75
+ var ED_SIGNATURE_BYTES = length(ed25519.lengths.signature, "Ed25519 signature");
76
+ var ML_PUBLIC_BYTES = length(ml_dsa65.lengths.publicKey, "ML-DSA-65 public key");
77
+ var ML_SIGNATURE_BYTES = length(ml_dsa65.lengths.signature, "ML-DSA-65 signature");
78
+ var RESPONSE_PUBLIC_BYTES = length(ml_kem768_x25519.lengths.publicKey, "hybrid response public key");
79
+ var RESPONSE_SECRET_BYTES = length(ml_kem768_x25519.lengths.secretKey, "hybrid response secret key");
80
+ var RESPONSE_CIPHERTEXT_BYTES = length(ml_kem768_x25519.lengths.cipherText, "hybrid response ciphertext");
81
+ function exactBytes(value, bytes) {
82
+ if (typeof value !== "string" || value.length !== encodedLength(bytes) || !BASE64URL.test(value))
83
+ return null;
47
84
  try {
48
- return un64(value).length === ml_kem768_x25519.lengths.publicKey;
85
+ const decoded = un64(value);
86
+ return decoded.length === bytes && b64(decoded) === value ? decoded : null;
49
87
  } catch {
50
- return false;
88
+ return null;
51
89
  }
52
90
  }
91
+ function validIdentity(value) {
92
+ return typeof value === "string" && value.length >= 1 && value.length <= 128 && !/[^A-Za-z0-9_.:@/-]/.test(value) && !/[\0\r\n]/.test(value);
93
+ }
94
+ function validResponsePublicKey(value) {
95
+ return exactBytes(value, RESPONSE_PUBLIC_BYTES) !== null;
96
+ }
53
97
  function generateResponseRecipient() {
54
98
  const pair = ml_kem768_x25519.keygen();
55
99
  return { publicKey: b64(pair.publicKey), secretKey: b64(pair.secretKey) };
56
100
  }
57
101
  var responseKey = (sharedSecret) => hkdf(sha256, sharedSecret, undefined, ENCODER.encode("forgezero/response/ml-kem-768+x25519/v1"), 32);
58
102
  async function sealResponse(recipientPublicKey, requestBinding, payload) {
59
- const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(un64(recipientPublicKey));
103
+ const recipient = exactBytes(recipientPublicKey, RESPONSE_PUBLIC_BYTES);
104
+ if (!recipient)
105
+ throw new Error("response: malformed recipient public key");
106
+ if (!requestBinding || requestBinding.length > 16384 || /[\0\r\n]/.test(requestBinding)) {
107
+ throw new Error("response: malformed request binding");
108
+ }
109
+ const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipient);
60
110
  const rawKey = responseKey(sharedSecret);
61
111
  sharedSecret.fill(0);
62
112
  const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["encrypt"]);
@@ -66,6 +116,10 @@ async function sealResponse(recipientPublicKey, requestBinding, payload) {
66
116
  if (serialized === undefined)
67
117
  throw new Error("response: payload is not JSON serializable");
68
118
  const plaintext = ENCODER.encode(serialized);
119
+ if (plaintext.length > 16 * 1024 * 1024 - 16) {
120
+ plaintext.fill(0);
121
+ throw new Error("response: payload exceeds sealed response limit");
122
+ }
69
123
  const ciphertext = await crypto.subtle.encrypt({
70
124
  name: "AES-GCM",
71
125
  iv: new Uint8Array(nonce),
@@ -75,25 +129,46 @@ async function sealResponse(recipientPublicKey, requestBinding, payload) {
75
129
  plaintext.fill(0);
76
130
  return {
77
131
  version: 1,
132
+ suite: RESPONSE_SEALING_SUITE,
78
133
  kemCiphertext: b64(cipherText),
79
134
  nonce: b64(nonce),
80
135
  ciphertext: b64(new Uint8Array(ciphertext))
81
136
  };
82
137
  }
83
138
  async function openResponse(recipientSecretKey, requestBinding, envelope) {
84
- if (envelope?.version !== 1)
139
+ if (!requestBinding || requestBinding.length > 16384 || /[\0\r\n]/.test(requestBinding)) {
140
+ throw new Error("response: malformed request binding");
141
+ }
142
+ if (envelope?.version !== 1 || envelope.suite !== RESPONSE_SEALING_SUITE) {
85
143
  throw new Error("response: unsupported sealed response");
86
- const sharedSecret = ml_kem768_x25519.decapsulate(un64(envelope.kemCiphertext), un64(recipientSecretKey));
144
+ }
145
+ const kemCiphertext = exactBytes(envelope.kemCiphertext, RESPONSE_CIPHERTEXT_BYTES);
146
+ const nonce = exactBytes(envelope.nonce, 12);
147
+ if (!kemCiphertext || !nonce || typeof envelope.ciphertext !== "string" || envelope.ciphertext.length < encodedLength(16) || envelope.ciphertext.length > encodedLength(16 * 1024 * 1024) || !BASE64URL.test(envelope.ciphertext)) {
148
+ throw new Error("response: malformed sealed response");
149
+ }
150
+ let encodedCiphertext;
151
+ try {
152
+ encodedCiphertext = un64(envelope.ciphertext);
153
+ } catch {
154
+ throw new Error("response: malformed sealed response");
155
+ }
156
+ if (encodedCiphertext.length < 16 || encodedCiphertext.length > 16 * 1024 * 1024 || b64(encodedCiphertext) !== envelope.ciphertext) {
157
+ throw new Error("response: malformed sealed response");
158
+ }
159
+ const sharedSecret = ml_kem768_x25519.decapsulate(kemCiphertext, exactBytes(recipientSecretKey, RESPONSE_SECRET_BYTES) ?? (() => {
160
+ throw new Error("response: malformed recipient secret key");
161
+ })());
87
162
  const rawKey = responseKey(sharedSecret);
88
163
  sharedSecret.fill(0);
89
164
  const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["decrypt"]);
90
165
  rawKey.fill(0);
91
166
  const decrypted = new Uint8Array(await crypto.subtle.decrypt({
92
167
  name: "AES-GCM",
93
- iv: new Uint8Array(un64(envelope.nonce)),
168
+ iv: new Uint8Array(nonce),
94
169
  additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
95
170
  tagLength: 128
96
- }, key, new Uint8Array(un64(envelope.ciphertext))));
171
+ }, key, new Uint8Array(encodedCiphertext)));
97
172
  try {
98
173
  return JSON.parse(new TextDecoder().decode(decrypted));
99
174
  } finally {
@@ -101,6 +176,8 @@ async function openResponse(recipientSecretKey, requestBinding, envelope) {
101
176
  }
102
177
  }
103
178
  var SIGNATURE_FIELDS = (envelope) => ({
179
+ version: envelope.version,
180
+ suite: envelope.suite,
104
181
  timestamp: envelope.timestamp,
105
182
  nonce: envelope.nonce,
106
183
  edSignature: envelope.edSignature,
@@ -110,15 +187,19 @@ function encodeSignatureHeader(envelope) {
110
187
  return b64(ENCODER.encode(JSON.stringify(SIGNATURE_FIELDS(envelope))));
111
188
  }
112
189
  function decodeSignatureHeader(raw, nodeKey) {
190
+ if (typeof raw !== "string" || raw.length < 64 || raw.length > 8192 || !BASE64URL.test(raw))
191
+ return null;
113
192
  let parsed;
114
193
  try {
115
194
  parsed = JSON.parse(new TextDecoder().decode(un64(raw)));
116
195
  } catch {
117
196
  return null;
118
197
  }
119
- if (typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) || typeof parsed.nonce !== "string" || typeof parsed.edSignature !== "string" || typeof parsed.mlDsaSignature !== "string")
198
+ if (Object.keys(parsed).length !== 6 || parsed.version !== 1 || parsed.suite !== REQUEST_SIGNATURE_SUITE || typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) || !exactBytes(parsed.nonce ?? "", 16) || !exactBytes(parsed.edSignature ?? "", ED_SIGNATURE_BYTES) || !exactBytes(parsed.mlDsaSignature ?? "", ML_SIGNATURE_BYTES) || !validIdentity(nodeKey))
120
199
  return null;
121
200
  return {
201
+ version: 1,
202
+ suite: REQUEST_SIGNATURE_SUITE,
122
203
  nodeKey,
123
204
  timestamp: parsed.timestamp,
124
205
  nonce: parsed.nonce,
@@ -127,9 +208,15 @@ function decodeSignatureHeader(raw, nodeKey) {
127
208
  };
128
209
  }
129
210
  function canonicalString(args) {
211
+ if (!validIdentity(args.identity) || !/^[A-Za-z][A-Za-z0-9-]{0,31}$/.test(args.method) || /[\0\r\n]/.test(args.path) || /[\0\r\n]/.test(args.query) || !exactBytes(args.nonce, 16) || args.responseKey !== undefined && !validResponsePublicKey(args.responseKey)) {
212
+ throw new Error("identity: malformed canonical request");
213
+ }
130
214
  const body = typeof args.body === "string" ? ENCODER.encode(args.body) : args.body;
131
215
  const digest = Array.from(sha256(body), (byte) => byte.toString(16).padStart(2, "0")).join("");
132
216
  const fields = [
217
+ "forgezero/request-signature/v1",
218
+ REQUEST_SIGNATURE_SUITE,
219
+ args.identity,
133
220
  args.method.toUpperCase(),
134
221
  args.path,
135
222
  args.query ?? "",
@@ -137,16 +224,17 @@ function canonicalString(args) {
137
224
  args.nonce,
138
225
  digest
139
226
  ];
140
- if (args.responseKey)
141
- fields.push(args.responseKey);
227
+ fields.push(args.responseKey ?? "-");
142
228
  return fields.join(`
143
229
  `);
144
230
  }
145
231
  function signRequest(keys, nodeKey, args) {
146
232
  const timestamp = Math.floor(Date.now() / 1000);
147
233
  const nonce = b64(randomBytes(16));
148
- const message = ENCODER.encode(canonicalString({ ...args, query: args.query ?? "", timestamp, nonce }));
234
+ const message = ENCODER.encode(canonicalString({ ...args, identity: nodeKey, query: args.query ?? "", timestamp, nonce }));
149
235
  return {
236
+ version: 1,
237
+ suite: REQUEST_SIGNATURE_SUITE,
150
238
  nodeKey,
151
239
  timestamp,
152
240
  nonce,
@@ -157,12 +245,16 @@ function signRequest(keys, nodeKey, args) {
157
245
  var CLOCK_SKEW_SECONDS = 300;
158
246
  function verifyRequest(args) {
159
247
  const now = args.nowSeconds ?? Math.floor(Date.now() / 1000);
248
+ if (args.envelope.version !== 1 || args.envelope.suite !== REQUEST_SIGNATURE_SUITE || !validIdentity(args.envelope.nodeKey) || !exactBytes(args.envelope.nonce, 16) || !exactBytes(args.envelope.edSignature, ED_SIGNATURE_BYTES) || !exactBytes(args.envelope.mlDsaSignature, ML_SIGNATURE_BYTES) || !exactBytes(args.publicKeys.ed25519, ED_PUBLIC_BYTES) || !exactBytes(args.publicKeys.mlDsa, ML_PUBLIC_BYTES) || args.responseKey !== undefined && !validResponsePublicKey(args.responseKey)) {
249
+ return { verified: false, reason: "malformed" };
250
+ }
160
251
  if (Math.abs(now - args.envelope.timestamp) > CLOCK_SKEW_SECONDS) {
161
252
  return { verified: false, reason: "timestamp_out_of_window" };
162
253
  }
163
254
  let message;
164
255
  try {
165
256
  message = ENCODER.encode(canonicalString({
257
+ identity: args.envelope.nodeKey,
166
258
  method: args.method,
167
259
  path: args.path,
168
260
  query: args.query ?? "",
@@ -199,9 +291,12 @@ export {
199
291
  generateResponseRecipient,
200
292
  generateNodeKeys,
201
293
  encodeSignatureHeader,
294
+ derivePublicKeysFromSeed,
202
295
  deriveKeysFromSeed,
203
296
  decodeSignatureHeader,
204
297
  canonicalString,
298
+ RESPONSE_SEALING_SUITE,
205
299
  RESPONSE_KEY_HEADER,
300
+ REQUEST_SIGNATURE_SUITE,
206
301
  CLOCK_SKEW_SECONDS
207
302
  };
@@ -0,0 +1,37 @@
1
+ export declare const PASSKEY_HYBRID_VERSION: 1;
2
+ export declare const PASSKEY_HYBRID_SUITE: "webauthn+prf-ml-dsa-65";
3
+ export declare const PASSKEY_PRF_SALT: Uint8Array<ArrayBuffer>;
4
+ export type PasskeyHybridPurpose = 'register' | 'signin' | 'session' | 'action';
5
+ export interface PasskeyHybridBinding {
6
+ version: typeof PASSKEY_HYBRID_VERSION;
7
+ suite: typeof PASSKEY_HYBRID_SUITE;
8
+ purpose: PasskeyHybridPurpose;
9
+ rpId: string;
10
+ origin: string;
11
+ challenge: string;
12
+ userKey: string;
13
+ sessionKey: string;
14
+ actionRequestKey: string;
15
+ }
16
+ export interface PasskeyHybridProof {
17
+ version: typeof PASSKEY_HYBRID_VERSION;
18
+ suite: typeof PASSKEY_HYBRID_SUITE;
19
+ credentialId: string;
20
+ signature: string;
21
+ }
22
+ export interface PasskeyHybridRegistrationProof extends PasskeyHybridProof {
23
+ publicKey: string;
24
+ /** A verified WebAuthn assertion which performed the PRF evaluation. */
25
+ assertion: unknown;
26
+ }
27
+ export declare function passkeyHybridMessage(binding: PasskeyHybridBinding, credentialId: string): Uint8Array;
28
+ export declare function createPasskeyHybridProof(prfOutput: Uint8Array, binding: PasskeyHybridBinding, credentialId: string): PasskeyHybridProof & {
29
+ publicKey: string;
30
+ };
31
+ export declare function verifyPasskeyHybridProof(args: {
32
+ proof: PasskeyHybridProof;
33
+ publicKey: string;
34
+ binding: PasskeyHybridBinding;
35
+ }): boolean;
36
+ export declare const PASSKEY_ML_DSA_PUBLIC_KEY_BYTES: number;
37
+ export declare const PASSKEY_ML_DSA_SIGNATURE_BYTES: number;