@noble/post-quantum 0.6.1 → 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.
package/src/utils.ts CHANGED
@@ -8,7 +8,11 @@ import {
8
8
  type TypedArray,
9
9
  abytes,
10
10
  abytes as abytes_,
11
+ ahash as ahash_,
12
+ anumber,
13
+ bytesToHex,
11
14
  concatBytes,
15
+ isBytes,
12
16
  isLE,
13
17
  randomBytes as randb,
14
18
  } from '@noble/hashes/utils.js';
@@ -158,9 +162,30 @@ export { concatBytesDoc as concatBytes };
158
162
  */
159
163
  export const randomBytes: typeof randb = randb;
160
164
 
165
+ export function aarray<T>(
166
+ item: unknown,
167
+ title: string,
168
+ inner: (elm: T, title: string) => void = () => {}
169
+ ): T[] {
170
+ if (!Array.isArray(item))
171
+ throw new TypeError(`"${title}" expected array, got type=${typeof item}`);
172
+ for (let i = 0; i < item.length; i++) inner(item[i], `${title}[${i}]`);
173
+ return item;
174
+ }
175
+
176
+ export function aobject<T extends object>(value: unknown, title = 'object'): T {
177
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
178
+ throw new TypeError(
179
+ title === 'object'
180
+ ? 'expected valid options object'
181
+ : `"${title}" expected object, got type=${typeof value}`
182
+ );
183
+ return value as T;
184
+ }
185
+
161
186
  /**
162
187
  * Compares two byte arrays in a length-constant way for equal lengths.
163
- * Unequal lengths return `false` immediately, and there is no runtime type validation.
188
+ * Inputs are validated as byte arrays; unequal lengths return `false` immediately.
164
189
  * @param a - First byte array.
165
190
  * @param b - Second byte array.
166
191
  * @returns Whether both arrays contain the same bytes.
@@ -171,6 +196,8 @@ export const randomBytes: typeof randb = randb;
171
196
  * ```
172
197
  */
173
198
  export function equalBytes(a: TArg<Uint8Array>, b: TArg<Uint8Array>): boolean {
199
+ a = abytes(a);
200
+ b = abytes(b);
174
201
  if (a.length !== b.length) return false;
175
202
  let diff = 0;
176
203
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
@@ -189,9 +216,10 @@ export function equalBytes(a: TArg<Uint8Array>, b: TArg<Uint8Array>): boolean {
189
216
  * ```
190
217
  */
191
218
  export function copyBytes(bytes: TArg<Uint8Array>): TRet<Uint8Array> {
192
- // `Uint8Array.from(...)` would also accept arrays / other typed arrays. Keep this helper strict
193
- // because callers use it at byte-validation boundaries before mutating the detached copy.
194
- return Uint8Array.from(abytes(bytes)) as TRet<Uint8Array>;
219
+ // The typed-array constructor copies a typed-array source through its internal byte storage.
220
+ // Unlike `Uint8Array.from`, it does not invoke a subclass-overridden iterator. Keep the explicit
221
+ // validation because the constructor itself would also accept arrays and other typed arrays.
222
+ return new Uint8Array(abytes(bytes)) as TRet<Uint8Array>;
195
223
  }
196
224
 
197
225
  /**
@@ -287,8 +315,68 @@ export type SigOpts = VerOpts & {
287
315
  */
288
316
  export function validateOpts(opts: object): void {
289
317
  // Arrays silently passed here before, but these call sites expect named option-bag fields.
290
- if (Object.prototype.toString.call(opts) !== '[object Object]')
291
- throw new TypeError('expected valid options object');
318
+ if (isBytes(opts)) throw new TypeError('"opts" expected object, got Uint8Array');
319
+ aobject(opts, 'opts');
320
+ const proto = Object.getPrototypeOf(opts);
321
+ // Options are security parameters, not general class instances. Restricting the bag to own
322
+ // properties prevents values injected through Object.prototype (or a custom shared prototype)
323
+ // from silently changing signing behavior. Null-prototype records remain supported.
324
+ if (proto !== null && proto !== Object.prototype)
325
+ throw new TypeError('"opts" expected a plain object');
326
+ }
327
+
328
+ // Frozen because they are exported: an unfrozen array export lets anything in the
329
+ // process push a key onto the accepted set and silently re-open exactly the hole this
330
+ // validation closes.
331
+ /** Keys accepted by `verify`. */
332
+ export const VER_OPT_KEYS: readonly ['context'] = /* @__PURE__ */ Object.freeze([
333
+ 'context',
334
+ ] as const);
335
+ /** Keys accepted by `sign`. */
336
+ export const SIG_OPT_KEYS: readonly ['context', 'extraEntropy'] = /* @__PURE__ */ Object.freeze([
337
+ 'context',
338
+ 'extraEntropy',
339
+ ] as const);
340
+
341
+ /**
342
+ * Rejects option keys the caller did not mean to set.
343
+ *
344
+ * Validating the types of known keys while ignoring unknown ones makes a typo
345
+ * indistinguishable from an omission, and for these options an omission is a
346
+ * security downgrade rather than a no-op: `{ ctx }` instead of `{ context }` signs
347
+ * with no domain separation, succeeds, and verifies for anyone who also supplies
348
+ * none. Nothing at any layer reports it. TypeScript catches this through excess
349
+ * property checks, so the exposure is JavaScript callers specifically.
350
+ *
351
+ * @param opts - Options object to check.
352
+ * @param allowed - The keys this call site accepts.
353
+ * Returns a frozen null-prototype snapshot so later reads cannot fall through to a polluted
354
+ * prototype. Like `checkOpts()` in noble-hashes, only enumerable own properties are copied.
355
+ * @throws If any other copied key is present or the bag has a custom prototype. {@link TypeError}
356
+ * @returns Sanitized snapshot of the enumerable own options.
357
+ * @example
358
+ * Accept a known option key. A key the list does not name, such as `ctx`, throws instead.
359
+ * ```ts
360
+ * import { checkOptKeys } from '@noble/post-quantum/utils.js';
361
+ * checkOptKeys({ context: new Uint8Array() }, ['context']);
362
+ * ```
363
+ */
364
+ export function checkOptKeys<T extends object>(opts: T, allowed: readonly string[]): T {
365
+ validateOpts(opts);
366
+ // Snapshot once before validation: Object.assign follows the same own-enumerable option-bag
367
+ // semantics as noble-hashes, while the null prototype keeps omitted fields immune to pollution.
368
+ const normalized = Object.assign(Object.create(null), opts) as Record<string, unknown>;
369
+ for (const [k, v] of Object.entries(normalized)) {
370
+ // `undefined` means unset everywhere else in these validators, and building an options bag by
371
+ // spread is a normal way to reach these calls, so present-but-undefined stays equivalent to
372
+ // omission.
373
+ if (v === undefined) continue;
374
+ if (!allowed.includes(k))
375
+ throw new TypeError(
376
+ 'unexpected option "' + String(k) + '"; expected one of: ' + allowed.join(', ')
377
+ );
378
+ }
379
+ return Object.freeze(normalized) as T;
292
380
  }
293
381
 
294
382
  /**
@@ -296,16 +384,23 @@ export function validateOpts(opts: object): void {
296
384
  * `context` itself is validated with `abytes(...)`, and individual algorithms may narrow support
297
385
  * further after this shared plain-object gate.
298
386
  * @param opts - Verification options. See {@link VerOpts}.
387
+ * @param allowed - Keys this call site accepts. Defaults to {@link VER_OPT_KEYS}; surfaces that
388
+ * take extra keys, or take fewer, pass their own list.
299
389
  * @throws On wrong argument types. {@link TypeError}
390
+ * @returns Frozen null-prototype snapshot of the validated options.
300
391
  * @example
301
392
  * Validate common verification options.
302
393
  * ```ts
303
394
  * validateVerOpts({ context: new Uint8Array([1]) });
304
395
  * ```
305
396
  */
306
- export function validateVerOpts(opts: TArg<VerOpts>): void {
307
- validateOpts(opts);
308
- if (opts.context !== undefined) abytes(opts.context, undefined, 'opts.context');
397
+ export function validateVerOpts<T extends TArg<VerOpts>>(
398
+ opts: T,
399
+ allowed: readonly string[] = VER_OPT_KEYS
400
+ ): T {
401
+ const normalized = checkOptKeys(opts, allowed);
402
+ if (normalized.context !== undefined) abytes(normalized.context, undefined, 'opts.context');
403
+ return normalized;
309
404
  }
310
405
 
311
406
  /**
@@ -313,17 +408,25 @@ export function validateVerOpts(opts: TArg<VerOpts>): void {
313
408
  * `extraEntropy` is validated with `abytes(...)`; exact lengths and extra algorithm-specific
314
409
  * restrictions are enforced later by callers.
315
410
  * @param opts - Signing options. See {@link SigOpts}.
411
+ * @param allowed - Keys this call site accepts. Defaults to {@link SIG_OPT_KEYS}; surfaces that
412
+ * take extra keys, or take fewer, pass their own list.
316
413
  * @throws On wrong argument types. {@link TypeError}
414
+ * @returns Frozen null-prototype snapshot of the validated options.
317
415
  * @example
318
416
  * Validate common signing options.
319
417
  * ```ts
320
418
  * validateSigOpts({ extraEntropy: new Uint8Array([1]) });
321
419
  * ```
322
420
  */
323
- export function validateSigOpts(opts: TArg<SigOpts>): void {
324
- validateVerOpts(opts);
325
- if (opts.extraEntropy !== false && opts.extraEntropy !== undefined)
326
- abytes(opts.extraEntropy, undefined, 'opts.extraEntropy');
421
+ export function validateSigOpts<T extends TArg<SigOpts>>(
422
+ opts: T,
423
+ allowed: readonly string[] = SIG_OPT_KEYS
424
+ ): T {
425
+ const normalized = checkOptKeys(opts, allowed);
426
+ if (normalized.context !== undefined) abytes(normalized.context, undefined, 'opts.context');
427
+ if (normalized.extraEntropy !== false && normalized.extraEntropy !== undefined)
428
+ abytes(normalized.extraEntropy, undefined, 'opts.extraEntropy');
429
+ return normalized;
327
430
  }
328
431
 
329
432
  /** Generic signature interface with key generation, signing, and verification. */
@@ -503,11 +606,12 @@ export function vecCoder<T>(c: TArg<BytesCoderLen<T>>, vecLen: number): TRet<Byt
503
606
  return {
504
607
  bytesLen,
505
608
  encode: (u: TArg<T[]>): TRet<Uint8Array> => {
506
- if (u.length !== vecLen)
507
- throw new RangeError(`vecCoder.encode: wrong length=${u.length}. Expected: ${vecLen}`);
609
+ const uArr = aarray<T>(u, 'u');
610
+ if (uArr.length !== vecLen)
611
+ throw new RangeError(`vecCoder.encode: wrong length=${uArr.length}. Expected: ${vecLen}`);
508
612
  const res = new Uint8Array(bytesLen);
509
- for (let i = 0, pos = 0; i < u.length; i++) {
510
- const b = coder.encode(u[i] as T);
613
+ for (let i = 0, pos = 0; i < uArr.length; i++) {
614
+ const b = coder.encode(uArr[i] as T);
511
615
  res.set(b, pos);
512
616
  b.fill(0); // clean
513
617
  pos += b.length;
@@ -546,6 +650,7 @@ export function cleanBytes(...list: (TypedArray | TypedArray[])[]): void {
546
650
  * Creates a 32-bit mask with the lowest `bits` bits set.
547
651
  * @param bits - Number of low bits to keep.
548
652
  * @returns Bit mask with `bits` ones.
653
+ * @throws On wrong argument types. {@link TypeError}
549
654
  * @throws On wrong argument ranges or values. {@link RangeError}
550
655
  * @example
551
656
  * Create a low-bit mask for packed-field operations.
@@ -554,8 +659,8 @@ export function cleanBytes(...list: (TypedArray | TypedArray[])[]): void {
554
659
  * ```
555
660
  */
556
661
  export function getMask(bits: number): number {
557
- if (!Number.isSafeInteger(bits) || bits < 0 || bits > 32)
558
- throw new RangeError(`expected bits in [0..32], got ${bits}`);
662
+ anumber(bits, 'bits');
663
+ if (bits > 32) throw new RangeError('"bits" expected <= 32, got ' + bits);
559
664
  // JS shifts are modulo 32, so bit 32 needs an explicit full-width mask.
560
665
  return bits === 32 ? 0xffffffff : ~(-1 << bits) >>> 0;
561
666
  }
@@ -577,8 +682,8 @@ export const EMPTY: TRet<Uint8Array> = /* @__PURE__ */ Uint8Array.of();
577
682
  * ```
578
683
  */
579
684
  export function getMessage(msg: TArg<Uint8Array>, ctx: TArg<Uint8Array> = EMPTY): TRet<Uint8Array> {
580
- abytes_(msg);
581
- abytes_(ctx);
685
+ abytes_(msg, undefined, 'msg');
686
+ abytes_(ctx, undefined, 'ctx');
582
687
  if (ctx.length > 255) throw new RangeError('context should be 255 bytes or less');
583
688
  return concatBytes(new Uint8Array([0, ctx.length]), ctx, msg);
584
689
  }
@@ -589,6 +694,20 @@ export function getMessage(msg: TArg<Uint8Array>, ctx: TArg<Uint8Array> = EMPTY)
589
694
  // 06 09 60 86 48 01 65 03 04 02
590
695
  const oidNistP = /* @__PURE__ */ Uint8Array.from([6, 9, 0x60, 0x86, 0x48, 1, 0x65, 3, 4, 2]);
591
696
 
697
+ /**
698
+ * Output length, in bytes, that each XOF OID under this arc denotes.
699
+ *
700
+ * Unlike a fixed hash, an XOF's OID is a promise about the digest length: RFC 8702
701
+ * defines id-shake128 as SHAKE128 with 256-bit output and id-shake256 as SHAKE256 with
702
+ * 512-bit output, and FIPS 204 / FIPS 205 use exactly those pairings for pre-hash. Both
703
+ * bare noble-hashes defaults are half these values, so neither can be signed under its
704
+ * own OID.
705
+ */
706
+ const XOF_OID_OUTPUT_LEN: Record<string, number> = /* @__PURE__ */ (() => ({
707
+ '060960864801650304020b': 32, // id-shake128, SHAKE128(M, 256)
708
+ '060960864801650304020c': 64, // id-shake256, SHAKE256(M, 512)
709
+ }))();
710
+
592
711
  /**
593
712
  * Validates that a hash exposes a NIST hash OID and enough collision resistance.
594
713
  * Current accepted surface is broader than the FIPS algorithm tables: any hash/XOF under the NIST
@@ -607,11 +726,31 @@ const oidNistP = /* @__PURE__ */ Uint8Array.from([6, 9, 0x60, 0x86, 0x48, 1, 0x6
607
726
  * ```
608
727
  */
609
728
  export function checkHash(hash: CHash, requiredStrength: number = 0): void {
610
- if (!hash.oid || !equalBytes(hash.oid.subarray(0, 10), oidNistP))
611
- throw new Error('hash.oid is invalid: expected NIST hash');
729
+ if (typeof hash !== 'function' || typeof (hash as any).create !== 'function')
730
+ throw new TypeError('"hash" expected hash function, got type=' + typeof hash);
731
+ ahash_(hash);
732
+ anumber(requiredStrength, 'requiredStrength');
733
+ const oid = hash.oid as unknown as TArg<Uint8Array>;
734
+ abytes_(oid, undefined, 'hash.oid');
735
+ if (!equalBytes(oid.subarray(0, 10), oidNistP))
736
+ throw new Error('"hash.oid" is invalid: expected NIST hash');
612
737
  // FIPS 204 / FIPS 205 require both collision and second-preimage strength; for approved NIST
613
738
  // hashes/XOFs under this OID subtree, the collision bound from the configured digest length is
614
739
  // the tighter runtime check, so enforce that lower bound here.
740
+ // XOFs under this arc are identified by an OID that fixes their output length:
741
+ // FIPS 204 §5.4.1 (SHAKE128) and FIPS 205 §10.2.2 (both SHAKEs), matching RFC 8702, pair
742
+ // id-shake128 with SHAKE128(M, 256) and id-shake256 with SHAKE256(M, 512). getMessagePrehash embeds
743
+ // hash.oid beside hash(msg), so a shorter digest signs an M' that claims a length
744
+ // it does not have: noble-hashes' bare shake256 defaults to 32 bytes and cleared
745
+ // the collision bound at the 128-bit level, producing signatures a conformant
746
+ // verifier rejects because it recomputes 512 bits. Check the length the OID
747
+ // denotes rather than the generic bound.
748
+ const xofLen = XOF_OID_OUTPUT_LEN[bytesToHex(oid as Uint8Array)];
749
+ if (xofLen !== undefined && hash.outputLen !== xofLen) {
750
+ throw new Error(
751
+ 'Pre-hash XOF output length must be ' + xofLen + ' bytes for this OID, got: ' + hash.outputLen
752
+ );
753
+ }
615
754
  const collisionResistance = (hash.outputLen * 8) / 2;
616
755
  if (requiredStrength > collisionResistance) {
617
756
  throw new Error(
@@ -646,9 +785,31 @@ export function getMessagePrehash(
646
785
  msg: TArg<Uint8Array>,
647
786
  ctx: TArg<Uint8Array> = EMPTY
648
787
  ): TRet<Uint8Array> {
649
- abytes_(msg);
650
- abytes_(ctx);
788
+ checkHash(hash);
789
+ abytes_(msg, undefined, 'msg');
790
+ abytes_(ctx, undefined, 'ctx');
651
791
  if (ctx.length > 255) throw new RangeError('context should be 255 bytes or less');
652
792
  const hashed = hash(msg);
653
793
  return concatBytes(new Uint8Array([1, ctx.length]), ctx, hash.oid!, hashed);
654
794
  }
795
+
796
+ /**
797
+ * Asserts something is a string.
798
+ * @param value - Value to validate.
799
+ * @param title - Label included in thrown errors.
800
+ * @returns The validated string.
801
+ * @throws On wrong argument types. {@link TypeError}
802
+ * @example
803
+ * Validate a label string.
804
+ *
805
+ * ```ts
806
+ * astring('example', 'label');
807
+ * ```
808
+ */
809
+ export function astring(value: unknown, title: string = ''): string {
810
+ if (typeof value !== 'string') {
811
+ const prefix = title && `"${title}" `;
812
+ throw new TypeError(prefix + 'expected string, got type=' + typeof value);
813
+ }
814
+ return value;
815
+ }
@@ -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
+ );