@noble/post-quantum 0.7.0 → 0.7.1

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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Friendly async wrappers over ML-KEM and ML-KEM-768 + X25519 from built-in WebCrypto.
3
+ * Private keys use the same raw seed accepted by the synchronous implementations' `keygen(seed)`;
4
+ * they are not expanded decapsulation keys.
5
+ *
6
+ * # WebCrypto quirks
7
+ *
8
+ * - The algorithms are experimental: a runtime can expose `encapsulateBits` and friends while
9
+ * implementing none of them, so support is probed with a full round-trip in `isSupported()`.
10
+ * - `MLKEM768-X25519` accepts `raw-seed` on import, but has no `raw-seed` / `raw-public` export.
11
+ * Its key bytes are read out of the JWK `priv` / `pub` members instead.
12
+ * - base64url is hand-rolled: scure-base's `base64urlnopad` would do, but this module must not add
13
+ * dependencies, and importing the synchronous implementations for four byte lengths would pull
14
+ * the whole lattice math into a WebCrypto-only entrypoint.
15
+ * @module
16
+ */
17
+ /*! noble-post-quantum - MIT License (c) 2024 Paul Miller (paulmillr.com) */
18
+ import { abytes, cleanBytes, copyBytes, equalBytes, type TArg, type TRet } from './utils.ts';
19
+
20
+ function _subtle(): any {
21
+ const cr = typeof globalThis === 'object' ? (globalThis as any).crypto : null;
22
+ const sb = cr?.subtle;
23
+ if (typeof sb === 'object' && sb != null) return sb;
24
+ throw new Error('crypto.subtle must be defined');
25
+ }
26
+
27
+ type MLKEMName = 'ML-KEM-512' | 'ML-KEM-768' | 'ML-KEM-1024';
28
+
29
+ const PRIVATE_USAGES = ['decapsulateBits'];
30
+ const PUBLIC_USAGES = ['encapsulateBits'];
31
+ const ALL_USAGES = ['encapsulateBits', 'decapsulateBits'];
32
+ const PROBED_METHODS = ['encapsulateBits', 'decapsulateBits', 'getPublicKey'];
33
+ const SHARED_SECRET_LENGTH = 32;
34
+ const arrayBufferByteLength = Object.getOwnPropertyDescriptor(
35
+ ArrayBuffer.prototype,
36
+ 'byteLength'
37
+ )?.get;
38
+
39
+ // Typed-array constructors also accept numbers and array-like objects, which would turn malformed
40
+ // provider results into newly allocated all-zero buffers. Use the intrinsic getter as a cross-realm
41
+ // ArrayBuffer brand check before constructing a view.
42
+ function providerBytes(value: unknown, title: string): TRet<Uint8Array> {
43
+ try {
44
+ if (arrayBufferByteLength === undefined) throw new TypeError('missing ArrayBuffer getter');
45
+ arrayBufferByteLength.call(value);
46
+ } catch {
47
+ throw new TypeError(`WebCrypto "${title}" expected ArrayBuffer`);
48
+ }
49
+ return new Uint8Array(value as ArrayBuffer) as TRet<Uint8Array>;
50
+ }
51
+
52
+ /** Byte lengths for a WebCrypto wrapper's serialized keys and ciphertexts. */
53
+ type KEMLengths = {
54
+ /** Deterministic key-generation seed length. */
55
+ seed: number;
56
+ /**
57
+ * Raw seed private-key length. Note this is the *seed*, not the expanded decapsulation key:
58
+ * for ML-KEM the synchronous `lengths.secretKey` is much larger (1632 / 2400 / 3168 bytes), so
59
+ * these private keys only fit the synchronous `keygen(seed)`, never its `decapsulate(ct, sk)`.
60
+ */
61
+ secretKey: number;
62
+ /** Serialized public-key length. */
63
+ publicKey: number;
64
+ /** Encapsulated ciphertext length. */
65
+ cipherText: number;
66
+ };
67
+
68
+ /** Strategy for serializing keys, which differs between the raw and JWK-only algorithms. */
69
+ type KeyCodec = {
70
+ importPublic(subtle: any, algorithm: any, publicKey: TArg<Uint8Array>): Promise<any>;
71
+ exportPublic(subtle: any, key: any, length: number): Promise<TRet<Uint8Array>>;
72
+ exportPrivate(subtle: any, key: any, length: number): Promise<TRet<Uint8Array>>;
73
+ };
74
+
75
+ /** Async KEM interface backed by the current runtime's WebCrypto implementation. */
76
+ export type WebCryptoKEM = {
77
+ /** WebCrypto algorithm name passed to `crypto.subtle`. */
78
+ webCryptoName: string;
79
+ /** Byte lengths for this WebCrypto wrapper's serialized keys and ciphertexts. */
80
+ lengths: KEMLengths;
81
+ /**
82
+ * Checks whether the runtime implements the complete WebCrypto surface used by this wrapper.
83
+ * Probes with a real key generation and encapsulation round-trip, and memoizes the result.
84
+ * @returns Whether key generation, serialization, encapsulation, and decapsulation are supported.
85
+ */
86
+ isSupported(): Promise<boolean>;
87
+ /**
88
+ * Generates a KEM key pair.
89
+ * @param seed - Optional raw seed for deterministic key generation.
90
+ * @returns Raw seed private key and serialized public key.
91
+ */
92
+ keygen(seed?: TArg<Uint8Array>): TRet<Promise<{ secretKey: Uint8Array; publicKey: Uint8Array }>>;
93
+ /**
94
+ * Derives a serialized public key from a raw seed private key.
95
+ * @param secretKey - Raw seed private key.
96
+ * @returns Serialized public key.
97
+ */
98
+ getPublicKey(secretKey: TArg<Uint8Array>): TRet<Promise<Uint8Array>>;
99
+ /**
100
+ * Encapsulates a new random shared secret to a serialized public key.
101
+ * @param publicKey - Recipient public key.
102
+ * @returns Ciphertext and 32-byte shared secret.
103
+ */
104
+ encapsulate(
105
+ publicKey: TArg<Uint8Array>
106
+ ): TRet<Promise<{ cipherText: Uint8Array; sharedSecret: Uint8Array }>>;
107
+ /**
108
+ * Decapsulates a ciphertext with a raw seed private key.
109
+ * @param cipherText - Encapsulated ciphertext bytes.
110
+ * @param secretKey - Private key in WebCrypto `raw-seed` format.
111
+ * @returns Decapsulated 32-byte shared secret.
112
+ */
113
+ decapsulate(cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>): TRet<Promise<Uint8Array>>;
114
+ };
115
+
116
+ /** Async ML-KEM interface backed by the current runtime's WebCrypto implementation. */
117
+ export type WebCryptoMLKEM = WebCryptoKEM & { webCryptoName: MLKEMName };
118
+
119
+ function base64url(bytes: TArg<Uint8Array>): string {
120
+ let binary = '';
121
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
122
+ return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
123
+ }
124
+
125
+ function debase64url(value: unknown, title: string): TRet<Uint8Array> {
126
+ if (typeof value !== 'string') throw new Error(`WebCrypto JWK is missing ${title}`);
127
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
128
+ const padding = base64.length % 4;
129
+ const binary = globalThis.atob(base64 + (padding ? '='.repeat(4 - padding) : ''));
130
+ const bytes = new Uint8Array(binary.length);
131
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
132
+ return bytes as TRet<Uint8Array>;
133
+ }
134
+
135
+ /** Keys serialized through the `raw-seed` / `raw-public` WebCrypto formats. */
136
+ const rawCodec: KeyCodec = {
137
+ importPublic: (subtle, algorithm, publicKey) =>
138
+ subtle.importKey('raw-public', publicKey, algorithm, false, PUBLIC_USAGES),
139
+ exportPublic: async (subtle, key, length) =>
140
+ abytes(new Uint8Array(await subtle.exportKey('raw-public', key)), length, 'publicKey'),
141
+ exportPrivate: async (subtle, key, length) =>
142
+ abytes(new Uint8Array(await subtle.exportKey('raw-seed', key)), length, 'secretKey'),
143
+ };
144
+
145
+ /** Keys serialized through the JWK `pub` / `priv` members, for algorithms without raw export. */
146
+ function jwkCodec(webCryptoName: string): TRet<KeyCodec> {
147
+ const exportMember = async (subtle: any, key: any, member: 'priv' | 'pub', length: number) => {
148
+ const jwk = await subtle.exportKey('jwk', key);
149
+ return abytes(debase64url(jwk?.[member], member), length, member) as TRet<Uint8Array>;
150
+ };
151
+ return {
152
+ importPublic: (subtle, algorithm, publicKey) =>
153
+ subtle.importKey(
154
+ 'jwk',
155
+ {
156
+ kty: 'AKP',
157
+ alg: webCryptoName,
158
+ key_ops: PUBLIC_USAGES,
159
+ ext: true,
160
+ pub: base64url(publicKey),
161
+ },
162
+ algorithm,
163
+ true,
164
+ PUBLIC_USAGES
165
+ ),
166
+ exportPublic: (subtle, key, length) => exportMember(subtle, key, 'pub', length),
167
+ exportPrivate: (subtle, key, length) => exportMember(subtle, key, 'priv', length),
168
+ };
169
+ }
170
+
171
+ function createWebCryptoKEM(
172
+ webCryptoName: string,
173
+ lengths: KEMLengths,
174
+ codec: KeyCodec
175
+ ): TRet<WebCryptoKEM> {
176
+ const algorithm = Object.freeze({ name: webCryptoName });
177
+ const frozen = Object.freeze(lengths);
178
+ let supported: boolean | undefined;
179
+
180
+ // Imports a raw seed, wiping the detached copy as soon as WebCrypto has consumed it.
181
+ const importSecret = async (subtle: any, secretKey: TArg<Uint8Array>) => {
182
+ const secret = copyBytes(abytes(secretKey, frozen.secretKey, 'secretKey'));
183
+ try {
184
+ return await subtle.importKey('raw-seed', secret, algorithm, false, PRIVATE_USAGES);
185
+ } finally {
186
+ cleanBytes(secret);
187
+ }
188
+ };
189
+
190
+ const getPublicKey = async (secretKey: TArg<Uint8Array>): Promise<TRet<Uint8Array>> => {
191
+ const subtle = _subtle();
192
+ const privateKey = await importSecret(subtle, secretKey);
193
+ const publicKey = await subtle.getPublicKey(privateKey, PUBLIC_USAGES);
194
+ return codec.exportPublic(subtle, publicKey, frozen.publicKey);
195
+ };
196
+
197
+ const keygen = async (seed?: TArg<Uint8Array>) => {
198
+ if (seed !== undefined) {
199
+ const secretKey = copyBytes(abytes(seed, frozen.seed, 'seed'));
200
+ try {
201
+ const publicKey = await getPublicKey(secretKey);
202
+ return { secretKey: secretKey as TRet<Uint8Array>, publicKey };
203
+ } catch (error) {
204
+ cleanBytes(secretKey);
205
+ throw error;
206
+ }
207
+ }
208
+ const subtle = _subtle();
209
+ const keys = await subtle.generateKey(algorithm, true, ALL_USAGES);
210
+ const [secretKey, publicKey] = await Promise.all([
211
+ codec.exportPrivate(subtle, keys.privateKey, frozen.secretKey),
212
+ codec.exportPublic(subtle, keys.publicKey, frozen.publicKey),
213
+ ]);
214
+ return { secretKey, publicKey };
215
+ };
216
+
217
+ const encapsulate = async (publicKey: TArg<Uint8Array>) => {
218
+ const subtle = _subtle();
219
+ // No copy: the bytes are public and WebCrypto consumes them before the next await.
220
+ const key = await codec.importPublic(
221
+ subtle,
222
+ algorithm,
223
+ abytes(publicKey, frozen.publicKey, 'publicKey')
224
+ );
225
+ const { ciphertext, sharedKey } = await subtle.encapsulateBits(algorithm, key);
226
+ // Provider outputs cross a trust boundary: some runtimes expose experimental methods with
227
+ // incomplete implementations. Materialize the secret first so every later rejection can wipe
228
+ // it, including a malformed ciphertext result.
229
+ const sharedSecret = providerBytes(sharedKey, 'sharedKey');
230
+ try {
231
+ const cipherText = abytes(
232
+ providerBytes(ciphertext, 'ciphertext'),
233
+ frozen.cipherText,
234
+ 'cipherText'
235
+ ) as TRet<Uint8Array>;
236
+ abytes(sharedSecret, SHARED_SECRET_LENGTH, 'sharedSecret');
237
+ return { cipherText, sharedSecret: sharedSecret as TRet<Uint8Array> };
238
+ } catch (error) {
239
+ cleanBytes(sharedSecret);
240
+ throw error;
241
+ }
242
+ };
243
+
244
+ const decapsulate = async (
245
+ cipherText: TArg<Uint8Array>,
246
+ secretKey: TArg<Uint8Array>
247
+ ): Promise<TRet<Uint8Array>> => {
248
+ // Snapshot the ciphertext: the key import below awaits before WebCrypto reads these bytes.
249
+ const cipher = copyBytes(abytes(cipherText, frozen.cipherText, 'cipherText'));
250
+ const subtle = _subtle();
251
+ const key = await importSecret(subtle, secretKey);
252
+ const sharedSecret = providerBytes(
253
+ await subtle.decapsulateBits(algorithm, key, cipher),
254
+ 'sharedKey'
255
+ );
256
+ try {
257
+ return abytes(sharedSecret, SHARED_SECRET_LENGTH, 'sharedSecret') as TRet<Uint8Array>;
258
+ } catch (error) {
259
+ cleanBytes(sharedSecret);
260
+ throw error;
261
+ }
262
+ };
263
+
264
+ return Object.freeze({
265
+ webCryptoName,
266
+ lengths: frozen,
267
+ async isSupported(): Promise<boolean> {
268
+ if (supported !== undefined) return supported;
269
+ let secretKey: Uint8Array | undefined;
270
+ let encapsulatedSecret: Uint8Array | undefined;
271
+ let decapsulatedSecret: Uint8Array | undefined;
272
+ try {
273
+ const subtle = _subtle();
274
+ for (const method of PROBED_METHODS)
275
+ if (typeof subtle[method] !== 'function') return (supported = false);
276
+ const generated = await keygen();
277
+ secretKey = generated.secretKey;
278
+ const { publicKey } = generated;
279
+ const encapsulated = await encapsulate(publicKey);
280
+ encapsulatedSecret = encapsulated.sharedSecret;
281
+ decapsulatedSecret = await decapsulate(encapsulated.cipherText, secretKey);
282
+ const ok =
283
+ equalBytes(await getPublicKey(secretKey), publicKey) &&
284
+ equalBytes(encapsulatedSecret, decapsulatedSecret);
285
+ return (supported = ok);
286
+ } catch {
287
+ return (supported = false);
288
+ } finally {
289
+ // A failed provider can throw at any point after producing secret material. Never let the
290
+ // support probe retain a generated seed or shared-secret output that it received.
291
+ if (secretKey !== undefined) cleanBytes(secretKey);
292
+ if (encapsulatedSecret !== undefined) cleanBytes(encapsulatedSecret);
293
+ if (decapsulatedSecret !== undefined) cleanBytes(decapsulatedSecret);
294
+ }
295
+ },
296
+ keygen,
297
+ getPublicKey,
298
+ encapsulate,
299
+ decapsulate,
300
+ }) as TRet<WebCryptoKEM>;
301
+ }
302
+
303
+ const mlKem = (name: MLKEMName, publicKey: number, cipherText: number) =>
304
+ createWebCryptoKEM(
305
+ name,
306
+ { seed: 64, secretKey: 64, publicKey, cipherText },
307
+ rawCodec
308
+ ) as TRet<WebCryptoMLKEM>;
309
+
310
+ /** WebCrypto ML-KEM-512 wrapper. */
311
+ export const ml_kem512: TRet<WebCryptoMLKEM> = /* @__PURE__ */ mlKem('ML-KEM-512', 800, 768);
312
+ /** WebCrypto ML-KEM-768 wrapper. */
313
+ export const ml_kem768: TRet<WebCryptoMLKEM> = /* @__PURE__ */ mlKem('ML-KEM-768', 1184, 1088);
314
+ /** WebCrypto ML-KEM-1024 wrapper. */
315
+ export const ml_kem1024: TRet<WebCryptoMLKEM> = /* @__PURE__ */ mlKem('ML-KEM-1024', 1568, 1568);
316
+
317
+ /** WebCrypto ML-KEM-768 + X25519 (X-Wing) wrapper. */
318
+ export const ml_kem768_x25519: TRet<WebCryptoKEM> = /* @__PURE__ */ createWebCryptoKEM(
319
+ 'MLKEM768-X25519',
320
+ { seed: 32, secretKey: 32, publicKey: 1216, cipherText: 1120 },
321
+ /* @__PURE__ */ jwkCodec('MLKEM768-X25519')
322
+ );
package/utils.d.ts CHANGED
@@ -186,32 +186,66 @@ export type SigOpts = VerOpts & {
186
186
  * ```
187
187
  */
188
188
  export declare function validateOpts(opts: object): void;
189
+ /** Keys accepted by `verify`. */
190
+ export declare const VER_OPT_KEYS: readonly ['context'];
191
+ /** Keys accepted by `sign`. */
192
+ export declare const SIG_OPT_KEYS: readonly ['context', 'extraEntropy'];
193
+ /**
194
+ * Rejects option keys the caller did not mean to set.
195
+ *
196
+ * Validating the types of known keys while ignoring unknown ones makes a typo
197
+ * indistinguishable from an omission, and for these options an omission is a
198
+ * security downgrade rather than a no-op: `{ ctx }` instead of `{ context }` signs
199
+ * with no domain separation, succeeds, and verifies for anyone who also supplies
200
+ * none. Nothing at any layer reports it. TypeScript catches this through excess
201
+ * property checks, so the exposure is JavaScript callers specifically.
202
+ *
203
+ * @param opts - Options object to check.
204
+ * @param allowed - The keys this call site accepts.
205
+ * Returns a frozen null-prototype snapshot so later reads cannot fall through to a polluted
206
+ * prototype. Like `checkOpts()` in noble-hashes, only enumerable own properties are copied.
207
+ * @throws If any other copied key is present or the bag has a custom prototype. {@link TypeError}
208
+ * @returns Sanitized snapshot of the enumerable own options.
209
+ * @example
210
+ * Accept a known option key. A key the list does not name, such as `ctx`, throws instead.
211
+ * ```ts
212
+ * import { checkOptKeys } from '@noble/post-quantum/utils.js';
213
+ * checkOptKeys({ context: new Uint8Array() }, ['context']);
214
+ * ```
215
+ */
216
+ export declare function checkOptKeys<T extends object>(opts: T, allowed: readonly string[]): T;
189
217
  /**
190
218
  * Validates common verification options.
191
219
  * `context` itself is validated with `abytes(...)`, and individual algorithms may narrow support
192
220
  * further after this shared plain-object gate.
193
221
  * @param opts - Verification options. See {@link VerOpts}.
222
+ * @param allowed - Keys this call site accepts. Defaults to {@link VER_OPT_KEYS}; surfaces that
223
+ * take extra keys, or take fewer, pass their own list.
194
224
  * @throws On wrong argument types. {@link TypeError}
225
+ * @returns Frozen null-prototype snapshot of the validated options.
195
226
  * @example
196
227
  * Validate common verification options.
197
228
  * ```ts
198
229
  * validateVerOpts({ context: new Uint8Array([1]) });
199
230
  * ```
200
231
  */
201
- export declare function validateVerOpts(opts: TArg<VerOpts>): void;
232
+ export declare function validateVerOpts<T extends TArg<VerOpts>>(opts: T, allowed?: readonly string[]): T;
202
233
  /**
203
234
  * Validates common signing options.
204
235
  * `extraEntropy` is validated with `abytes(...)`; exact lengths and extra algorithm-specific
205
236
  * restrictions are enforced later by callers.
206
237
  * @param opts - Signing options. See {@link SigOpts}.
238
+ * @param allowed - Keys this call site accepts. Defaults to {@link SIG_OPT_KEYS}; surfaces that
239
+ * take extra keys, or take fewer, pass their own list.
207
240
  * @throws On wrong argument types. {@link TypeError}
241
+ * @returns Frozen null-prototype snapshot of the validated options.
208
242
  * @example
209
243
  * Validate common signing options.
210
244
  * ```ts
211
245
  * validateSigOpts({ extraEntropy: new Uint8Array([1]) });
212
246
  * ```
213
247
  */
214
- export declare function validateSigOpts(opts: TArg<SigOpts>): void;
248
+ export declare function validateSigOpts<T extends TArg<SigOpts>>(opts: T, allowed?: readonly string[]): T;
215
249
  /** Generic signature interface with key generation, signing, and verification. */
216
250
  export type Signer = CryptoKeys & {
217
251
  /** Public byte lengths for signatures and signing randomness. */
package/utils.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * @module
4
4
  */
5
5
  /*! noble-post-quantum - MIT License (c) 2024 Paul Miller (paulmillr.com) */
6
- import { abytes, abytes as abytes_, ahash as ahash_, anumber, concatBytes, isBytes, isLE, randomBytes as randb, } from '@noble/hashes/utils.js';
6
+ import { abytes, abytes as abytes_, ahash as ahash_, anumber, bytesToHex, concatBytes, isBytes, isLE, randomBytes as randb, } from '@noble/hashes/utils.js';
7
7
  /**
8
8
  * Asserts that a value is a byte array and optionally checks its length.
9
9
  * Returns the original reference unchanged on success, and currently also accepts Node `Buffer`
@@ -92,9 +92,10 @@ export function equalBytes(a, b) {
92
92
  * ```
93
93
  */
94
94
  export function copyBytes(bytes) {
95
- // `Uint8Array.from(...)` would also accept arrays / other typed arrays. Keep this helper strict
96
- // because callers use it at byte-validation boundaries before mutating the detached copy.
97
- return Uint8Array.from(abytes(bytes));
95
+ // The typed-array constructor copies a typed-array source through its internal byte storage.
96
+ // Unlike `Uint8Array.from`, it does not invoke a subclass-overridden iterator. Keep the explicit
97
+ // validation because the constructor itself would also accept arrays and other typed arrays.
98
+ return new Uint8Array(abytes(bytes));
98
99
  }
99
100
  /**
100
101
  * Byte-swaps each 64-bit lane in place.
@@ -156,40 +157,107 @@ export function validateOpts(opts) {
156
157
  if (isBytes(opts))
157
158
  throw new TypeError('"opts" expected object, got Uint8Array');
158
159
  aobject(opts, 'opts');
160
+ const proto = Object.getPrototypeOf(opts);
161
+ // Options are security parameters, not general class instances. Restricting the bag to own
162
+ // properties prevents values injected through Object.prototype (or a custom shared prototype)
163
+ // from silently changing signing behavior. Null-prototype records remain supported.
164
+ if (proto !== null && proto !== Object.prototype)
165
+ throw new TypeError('"opts" expected a plain object');
166
+ }
167
+ // Frozen because they are exported: an unfrozen array export lets anything in the
168
+ // process push a key onto the accepted set and silently re-open exactly the hole this
169
+ // validation closes.
170
+ /** Keys accepted by `verify`. */
171
+ export const VER_OPT_KEYS = /* @__PURE__ */ Object.freeze([
172
+ 'context',
173
+ ]);
174
+ /** Keys accepted by `sign`. */
175
+ export const SIG_OPT_KEYS = /* @__PURE__ */ Object.freeze([
176
+ 'context',
177
+ 'extraEntropy',
178
+ ]);
179
+ /**
180
+ * Rejects option keys the caller did not mean to set.
181
+ *
182
+ * Validating the types of known keys while ignoring unknown ones makes a typo
183
+ * indistinguishable from an omission, and for these options an omission is a
184
+ * security downgrade rather than a no-op: `{ ctx }` instead of `{ context }` signs
185
+ * with no domain separation, succeeds, and verifies for anyone who also supplies
186
+ * none. Nothing at any layer reports it. TypeScript catches this through excess
187
+ * property checks, so the exposure is JavaScript callers specifically.
188
+ *
189
+ * @param opts - Options object to check.
190
+ * @param allowed - The keys this call site accepts.
191
+ * Returns a frozen null-prototype snapshot so later reads cannot fall through to a polluted
192
+ * prototype. Like `checkOpts()` in noble-hashes, only enumerable own properties are copied.
193
+ * @throws If any other copied key is present or the bag has a custom prototype. {@link TypeError}
194
+ * @returns Sanitized snapshot of the enumerable own options.
195
+ * @example
196
+ * Accept a known option key. A key the list does not name, such as `ctx`, throws instead.
197
+ * ```ts
198
+ * import { checkOptKeys } from '@noble/post-quantum/utils.js';
199
+ * checkOptKeys({ context: new Uint8Array() }, ['context']);
200
+ * ```
201
+ */
202
+ export function checkOptKeys(opts, allowed) {
203
+ validateOpts(opts);
204
+ // Snapshot once before validation: Object.assign follows the same own-enumerable option-bag
205
+ // semantics as noble-hashes, while the null prototype keeps omitted fields immune to pollution.
206
+ const normalized = Object.assign(Object.create(null), opts);
207
+ for (const [k, v] of Object.entries(normalized)) {
208
+ // `undefined` means unset everywhere else in these validators, and building an options bag by
209
+ // spread is a normal way to reach these calls, so present-but-undefined stays equivalent to
210
+ // omission.
211
+ if (v === undefined)
212
+ continue;
213
+ if (!allowed.includes(k))
214
+ throw new TypeError('unexpected option "' + String(k) + '"; expected one of: ' + allowed.join(', '));
215
+ }
216
+ return Object.freeze(normalized);
159
217
  }
160
218
  /**
161
219
  * Validates common verification options.
162
220
  * `context` itself is validated with `abytes(...)`, and individual algorithms may narrow support
163
221
  * further after this shared plain-object gate.
164
222
  * @param opts - Verification options. See {@link VerOpts}.
223
+ * @param allowed - Keys this call site accepts. Defaults to {@link VER_OPT_KEYS}; surfaces that
224
+ * take extra keys, or take fewer, pass their own list.
165
225
  * @throws On wrong argument types. {@link TypeError}
226
+ * @returns Frozen null-prototype snapshot of the validated options.
166
227
  * @example
167
228
  * Validate common verification options.
168
229
  * ```ts
169
230
  * validateVerOpts({ context: new Uint8Array([1]) });
170
231
  * ```
171
232
  */
172
- export function validateVerOpts(opts) {
173
- validateOpts(opts);
174
- if (opts.context !== undefined)
175
- abytes(opts.context, undefined, 'opts.context');
233
+ export function validateVerOpts(opts, allowed = VER_OPT_KEYS) {
234
+ const normalized = checkOptKeys(opts, allowed);
235
+ if (normalized.context !== undefined)
236
+ abytes(normalized.context, undefined, 'opts.context');
237
+ return normalized;
176
238
  }
177
239
  /**
178
240
  * Validates common signing options.
179
241
  * `extraEntropy` is validated with `abytes(...)`; exact lengths and extra algorithm-specific
180
242
  * restrictions are enforced later by callers.
181
243
  * @param opts - Signing options. See {@link SigOpts}.
244
+ * @param allowed - Keys this call site accepts. Defaults to {@link SIG_OPT_KEYS}; surfaces that
245
+ * take extra keys, or take fewer, pass their own list.
182
246
  * @throws On wrong argument types. {@link TypeError}
247
+ * @returns Frozen null-prototype snapshot of the validated options.
183
248
  * @example
184
249
  * Validate common signing options.
185
250
  * ```ts
186
251
  * validateSigOpts({ extraEntropy: new Uint8Array([1]) });
187
252
  * ```
188
253
  */
189
- export function validateSigOpts(opts) {
190
- validateVerOpts(opts);
191
- if (opts.extraEntropy !== false && opts.extraEntropy !== undefined)
192
- abytes(opts.extraEntropy, undefined, 'opts.extraEntropy');
254
+ export function validateSigOpts(opts, allowed = SIG_OPT_KEYS) {
255
+ const normalized = checkOptKeys(opts, allowed);
256
+ if (normalized.context !== undefined)
257
+ abytes(normalized.context, undefined, 'opts.context');
258
+ if (normalized.extraEntropy !== false && normalized.extraEntropy !== undefined)
259
+ abytes(normalized.extraEntropy, undefined, 'opts.extraEntropy');
260
+ return normalized;
193
261
  }
194
262
  /**
195
263
  * Builds a fixed-layout coder from byte lengths and nested coders.
@@ -351,6 +419,19 @@ export function getMessage(msg, ctx = EMPTY) {
351
419
  // SHAKE256, or another approved hash/XOF under that subtree.
352
420
  // 06 09 60 86 48 01 65 03 04 02
353
421
  const oidNistP = /* @__PURE__ */ Uint8Array.from([6, 9, 0x60, 0x86, 0x48, 1, 0x65, 3, 4, 2]);
422
+ /**
423
+ * Output length, in bytes, that each XOF OID under this arc denotes.
424
+ *
425
+ * Unlike a fixed hash, an XOF's OID is a promise about the digest length: RFC 8702
426
+ * defines id-shake128 as SHAKE128 with 256-bit output and id-shake256 as SHAKE256 with
427
+ * 512-bit output, and FIPS 204 / FIPS 205 use exactly those pairings for pre-hash. Both
428
+ * bare noble-hashes defaults are half these values, so neither can be signed under its
429
+ * own OID.
430
+ */
431
+ const XOF_OID_OUTPUT_LEN = /* @__PURE__ */ (() => ({
432
+ '060960864801650304020b': 32, // id-shake128, SHAKE128(M, 256)
433
+ '060960864801650304020c': 64, // id-shake256, SHAKE256(M, 512)
434
+ }))();
354
435
  /**
355
436
  * Validates that a hash exposes a NIST hash OID and enough collision resistance.
356
437
  * Current accepted surface is broader than the FIPS algorithm tables: any hash/XOF under the NIST
@@ -380,6 +461,18 @@ export function checkHash(hash, requiredStrength = 0) {
380
461
  // FIPS 204 / FIPS 205 require both collision and second-preimage strength; for approved NIST
381
462
  // hashes/XOFs under this OID subtree, the collision bound from the configured digest length is
382
463
  // the tighter runtime check, so enforce that lower bound here.
464
+ // XOFs under this arc are identified by an OID that fixes their output length:
465
+ // FIPS 204 §5.4.1 (SHAKE128) and FIPS 205 §10.2.2 (both SHAKEs), matching RFC 8702, pair
466
+ // id-shake128 with SHAKE128(M, 256) and id-shake256 with SHAKE256(M, 512). getMessagePrehash embeds
467
+ // hash.oid beside hash(msg), so a shorter digest signs an M' that claims a length
468
+ // it does not have: noble-hashes' bare shake256 defaults to 32 bytes and cleared
469
+ // the collision bound at the 128-bit level, producing signatures a conformant
470
+ // verifier rejects because it recomputes 512 bits. Check the length the OID
471
+ // denotes rather than the generic bound.
472
+ const xofLen = XOF_OID_OUTPUT_LEN[bytesToHex(oid)];
473
+ if (xofLen !== undefined && hash.outputLen !== xofLen) {
474
+ throw new Error('Pre-hash XOF output length must be ' + xofLen + ' bytes for this OID, got: ' + hash.outputLen);
475
+ }
383
476
  const collisionResistance = (hash.outputLen * 8) / 2;
384
477
  if (requiredStrength > collisionResistance) {
385
478
  throw new Error('Pre-hash security strength too low: ' +
package/webcrypto.d.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Friendly async wrappers over ML-KEM and ML-KEM-768 + X25519 from built-in WebCrypto.
3
+ * Private keys use the same raw seed accepted by the synchronous implementations' `keygen(seed)`;
4
+ * they are not expanded decapsulation keys.
5
+ *
6
+ * # WebCrypto quirks
7
+ *
8
+ * - The algorithms are experimental: a runtime can expose `encapsulateBits` and friends while
9
+ * implementing none of them, so support is probed with a full round-trip in `isSupported()`.
10
+ * - `MLKEM768-X25519` accepts `raw-seed` on import, but has no `raw-seed` / `raw-public` export.
11
+ * Its key bytes are read out of the JWK `priv` / `pub` members instead.
12
+ * - base64url is hand-rolled: scure-base's `base64urlnopad` would do, but this module must not add
13
+ * dependencies, and importing the synchronous implementations for four byte lengths would pull
14
+ * the whole lattice math into a WebCrypto-only entrypoint.
15
+ * @module
16
+ */
17
+ /*! noble-post-quantum - MIT License (c) 2024 Paul Miller (paulmillr.com) */
18
+ import { type TArg, type TRet } from './utils.ts';
19
+ type MLKEMName = 'ML-KEM-512' | 'ML-KEM-768' | 'ML-KEM-1024';
20
+ /** Byte lengths for a WebCrypto wrapper's serialized keys and ciphertexts. */
21
+ type KEMLengths = {
22
+ /** Deterministic key-generation seed length. */
23
+ seed: number;
24
+ /**
25
+ * Raw seed private-key length. Note this is the *seed*, not the expanded decapsulation key:
26
+ * for ML-KEM the synchronous `lengths.secretKey` is much larger (1632 / 2400 / 3168 bytes), so
27
+ * these private keys only fit the synchronous `keygen(seed)`, never its `decapsulate(ct, sk)`.
28
+ */
29
+ secretKey: number;
30
+ /** Serialized public-key length. */
31
+ publicKey: number;
32
+ /** Encapsulated ciphertext length. */
33
+ cipherText: number;
34
+ };
35
+ /** Async KEM interface backed by the current runtime's WebCrypto implementation. */
36
+ export type WebCryptoKEM = {
37
+ /** WebCrypto algorithm name passed to `crypto.subtle`. */
38
+ webCryptoName: string;
39
+ /** Byte lengths for this WebCrypto wrapper's serialized keys and ciphertexts. */
40
+ lengths: KEMLengths;
41
+ /**
42
+ * Checks whether the runtime implements the complete WebCrypto surface used by this wrapper.
43
+ * Probes with a real key generation and encapsulation round-trip, and memoizes the result.
44
+ * @returns Whether key generation, serialization, encapsulation, and decapsulation are supported.
45
+ */
46
+ isSupported(): Promise<boolean>;
47
+ /**
48
+ * Generates a KEM key pair.
49
+ * @param seed - Optional raw seed for deterministic key generation.
50
+ * @returns Raw seed private key and serialized public key.
51
+ */
52
+ keygen(seed?: TArg<Uint8Array>): TRet<Promise<{
53
+ secretKey: Uint8Array;
54
+ publicKey: Uint8Array;
55
+ }>>;
56
+ /**
57
+ * Derives a serialized public key from a raw seed private key.
58
+ * @param secretKey - Raw seed private key.
59
+ * @returns Serialized public key.
60
+ */
61
+ getPublicKey(secretKey: TArg<Uint8Array>): TRet<Promise<Uint8Array>>;
62
+ /**
63
+ * Encapsulates a new random shared secret to a serialized public key.
64
+ * @param publicKey - Recipient public key.
65
+ * @returns Ciphertext and 32-byte shared secret.
66
+ */
67
+ encapsulate(publicKey: TArg<Uint8Array>): TRet<Promise<{
68
+ cipherText: Uint8Array;
69
+ sharedSecret: Uint8Array;
70
+ }>>;
71
+ /**
72
+ * Decapsulates a ciphertext with a raw seed private key.
73
+ * @param cipherText - Encapsulated ciphertext bytes.
74
+ * @param secretKey - Private key in WebCrypto `raw-seed` format.
75
+ * @returns Decapsulated 32-byte shared secret.
76
+ */
77
+ decapsulate(cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>): TRet<Promise<Uint8Array>>;
78
+ };
79
+ /** Async ML-KEM interface backed by the current runtime's WebCrypto implementation. */
80
+ export type WebCryptoMLKEM = WebCryptoKEM & {
81
+ webCryptoName: MLKEMName;
82
+ };
83
+ /** WebCrypto ML-KEM-512 wrapper. */
84
+ export declare const ml_kem512: TRet<WebCryptoMLKEM>;
85
+ /** WebCrypto ML-KEM-768 wrapper. */
86
+ export declare const ml_kem768: TRet<WebCryptoMLKEM>;
87
+ /** WebCrypto ML-KEM-1024 wrapper. */
88
+ export declare const ml_kem1024: TRet<WebCryptoMLKEM>;
89
+ /** WebCrypto ML-KEM-768 + X25519 (X-Wing) wrapper. */
90
+ export declare const ml_kem768_x25519: TRet<WebCryptoKEM>;
91
+ export {};