subtlepq 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Post-quantum polyfill for the Web Cryptography API: ML-KEM (FIPS 203) and ML-DSA (FIPS 204) per the [WICG Modern Algorithms draft](https://wicg.github.io/webcrypto-modern-algos/), with native delegation where the platform already supports it.
4
4
 
5
- **Status: P2 — engine, core JS layer, all key formats, key wrapping.** DHKEM is on the way.
5
+ **Status: P4 — engine, core JS layer, all key formats, key wrapping, native-parity suites, DHKEM extension.**
6
6
 
7
7
  Key formats: `raw-public`, `raw-seed`, `spki`, `pkcs8` (seed-only encoding per the LAMPS
8
8
  drafts; the `both` CHOICE is accepted on import and cross-checked), and `jwk` (kty `"AKP"`,
@@ -34,6 +34,26 @@ const sig = await crypto.subtle.sign({ name: "ML-DSA-65", context: ctx }, kp.pri
34
34
 
35
35
  Known limits (inherent to a script polyfill, made loud rather than silent): ML-* keys are not structured-cloneable — `postMessage`/IndexedDB throws `DataCloneError`; export the 32–64-byte seed and re-import instead. Key material lives in wasm linear memory: `extractable: false` is API-level enforcement, not platform isolation.
36
36
 
37
+ ## DHKEM: classical ECDH in the same KEM shape
38
+
39
+ `subtlepq/dhkem` wraps ephemeral-static ECDH as a KEM per [RFC 9180 §4.1](https://www.rfc-editor.org/rfc/rfc9180#section-4.1) — `DHKEM-X25519-HKDF-SHA256` and `DHKEM-P256-HKDF-SHA256` — so classical and post-quantum key agreement share one calling convention, and migrating to ML-KEM is a name change:
40
+
41
+ ```js
42
+ import * as dhkem from "subtlepq/dhkem";
43
+ dhkem.register(); // before install(), if you use the true polyfill
44
+ import { subtle } from "subtlepq";
45
+
46
+ const kp = await subtle.generateKey("DHKEM-X25519-HKDF-SHA256", false,
47
+ ["encapsulateKey", "decapsulateKey"]);
48
+ const { sharedKey, ciphertext } = await subtle.encapsulateKey(
49
+ "DHKEM-X25519-HKDF-SHA256", kp.publicKey, // -> "ML-KEM-768" later
50
+ { name: "AES-GCM", length: 256 }, false, ["encrypt"]);
51
+ ```
52
+
53
+ These names are **not** in the WICG draft — this is a subtlepq extension (if the WG adopts DHKEM identifiers, those will be adopted and these aliased). Underneath it is 100% native WebCrypto (ECDH/X25519 + HKDF): no wasm is involved, and the keys are genuine platform CryptoKeys — structured-cloneable, native usage enforcement (surfaced as `deriveBits`). The ciphertext is the serialized ephemeral public key (32 bytes X25519 / 65 bytes P-256), interoperable with any RFC 9180 DHKEM implementation, and validated against the RFC 9180 Appendix A test vectors.
54
+
55
+ One asymmetry vs a true KEM: RFC 9180 decapsulation mixes the recipient *public* key into the KDF. subtlepq remembers the public half for keys created through `subtlepq/dhkem`; for private keys from elsewhere (or structured-cloned into another realm) pass it explicitly — `decapsulateBits({ name: "DHKEM-X25519-HKDF-SHA256", publicKey }, key, ct)` — or use an extractable key.
56
+
37
57
  ## Engine
38
58
 
39
59
  `wasm/` builds a minimal [liboqs](https://github.com/open-quantum-safe/liboqs) (pinned submodule, ML-KEM + ML-DSA only) plus a thin shim (`subtlepq_engine.c`) to WebAssembly:
@@ -48,7 +68,15 @@ Outputs `wasm/dist/subtlepq-engine.mjs` (+ `.wasm` sidecar, CSP-clean) and `subt
48
68
 
49
69
  Seeded operations liboqs does not expose publicly (ML-DSA keygen from seed, KAT-deterministic encapsulation/signing) use a bracketed RNG-playback override — the same technique liboqs' own ACVP tests use.
50
70
 
51
- Tests run NIST ACVP known-answer vectors (subset, see `test/vectors/acvp-subset.json`) for all six parameter sets, plus roundtrip, implicit-rejection, context, and polyfill-routing checks.
71
+ Tests run NIST ACVP known-answer vectors (subset, see `test/vectors/acvp-subset.json`) for all six parameter sets, plus roundtrip, implicit-rejection, context, and polyfill-routing checks. `test/dhkem-test.mjs` validates the DHKEM extension against the RFC 9180 Appendix A vectors (`test/vectors/rfc9180-subset.json`).
72
+
73
+ `npm run test:parity` (Node >= 24.7) runs the polyfill side-by-side against the
74
+ platform-native ML-KEM/ML-DSA WebCrypto implementation as an oracle: identical
75
+ spki/pkcs8/jwk bytes, cross-stack encapsulate/decapsulate and sign/verify, identical
76
+ AES-KW/AES-GCM wrapKey output, and matching DOMException names on malformed input.
77
+ `npm run test:libves` cross-checks ML-KEM against [libVES](https://github.com/vesvault/libVES)
78
+ (an independent liboqs build and ASN.1 layer): same-seed keypair equality, SPKI byte
79
+ parity, and mutual key import and encapsulation.
52
80
 
53
81
  ## License
54
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subtlepq",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Post-quantum polyfill for the Web Cryptography API: ML-KEM (FIPS 203) and ML-DSA (FIPS 204) per the WICG Modern Algorithms draft, with native delegation",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -19,6 +19,7 @@
19
19
  "sideEffects": false,
20
20
  "files": [
21
21
  "src/algorithms.js",
22
+ "src/dhkem.js",
22
23
  "src/engine.js",
23
24
  "src/errors.js",
24
25
  "src/formats.js",
@@ -33,16 +34,19 @@
33
34
  ],
34
35
  "exports": {
35
36
  ".": "./src/index.js",
36
- "./install": "./src/install.js"
37
+ "./install": "./src/install.js",
38
+ "./dhkem": "./src/dhkem.js"
37
39
  },
38
40
  "scripts": {
39
41
  "build": "wasm/build.sh",
40
42
  "prepack": "node -e \"const p=require('./package.json'),fs=require('fs');for(const f of p.files)if(!fs.existsSync(f))throw new Error(f+' missing - run wasm/build.sh before packing')\"",
41
- "test": "node test/engine-test.mjs && node test/polyfill-test.mjs"
43
+ "test": "node test/engine-test.mjs && node test/polyfill-test.mjs && node test/dhkem-test.mjs",
44
+ "test:parity": "node test/parity-native.mjs",
45
+ "test:libves": "node test/parity-libves.mjs"
42
46
  },
43
47
  "keywords": [
44
48
  "ml-kem", "ml-dsa", "webcrypto", "polyfill", "post-quantum",
45
49
  "fips203", "fips204", "kyber", "dilithium", "subtlecrypto",
46
- "encapsulation", "kem"
50
+ "encapsulation", "kem", "dhkem", "hpke", "rfc9180"
47
51
  ]
48
52
  }
package/src/algorithms.js CHANGED
@@ -13,8 +13,25 @@ const NAMES = [
13
13
  export const KEM_PUB_USAGES = ["encapsulateBits", "encapsulateKey"];
14
14
  export const KEM_PRIV_USAGES = ["decapsulateBits", "decapsulateKey"];
15
15
 
16
+ /* Extension algorithms (subtlepq/dhkem): registered at runtime, carrying
17
+ * their own ops table; the core ML-* set above stays engine-backed. */
18
+ const EXTENSIONS = new Map();
19
+
20
+ export function registerExtension(name, kind, ops) {
21
+ EXTENSIONS.set(name.toLowerCase(), { name, kind, ops });
22
+ }
23
+
24
+ export function unregisterExtension(name) {
25
+ EXTENSIONS.delete(name.toLowerCase());
26
+ }
27
+
28
+ export function hasExtensions() {
29
+ return EXTENSIONS.size > 0;
30
+ }
31
+
16
32
  /* WebCrypto algorithm names are matched case-insensitively.
17
- * Returns { name, kind, context? } with the canonical name, or null. */
33
+ * Returns { name, kind, context?, ops? } with the canonical name, or null;
34
+ * `ops` marks an extension algorithm handled outside subtle.js. */
18
35
  export function normalizeAlg(alg) {
19
36
  let name, context;
20
37
  if (typeof alg === "string") {
@@ -26,10 +43,14 @@ export function normalizeAlg(alg) {
26
43
  return null;
27
44
  }
28
45
  const canonical = NAMES.find((n) => n.toLowerCase() === name.toLowerCase());
29
- if (!canonical) return null;
30
- return {
31
- name: canonical,
32
- kind: canonical.startsWith("ML-KEM") ? "kem" : "sig",
33
- context,
34
- };
46
+ if (canonical) {
47
+ return {
48
+ name: canonical,
49
+ kind: canonical.startsWith("ML-KEM") ? "kem" : "sig",
50
+ context,
51
+ };
52
+ }
53
+ const ext = EXTENSIONS.get(name.toLowerCase());
54
+ if (!ext) return null;
55
+ return { name: ext.name, kind: ext.kind, ops: ext.ops, context };
35
56
  }
package/src/dhkem.js ADDED
@@ -0,0 +1,321 @@
1
+ /***************************************************************************
2
+ * subtlepq/dhkem: DHKEM over the platform's native ECDH -- RFC 9180 sec 4.1
3
+ *
4
+ * (c) 2026 VESvault Corp
5
+ * SPDX-License-Identifier: Apache-2.0
6
+ *
7
+ * KEM-shaped classical key agreement: ephemeral-static ECDH wrapped as
8
+ * encapsulate/decapsulate, so classical and post-quantum share one calling
9
+ * convention and migrating to ML-KEM is a name change. NOT part of the
10
+ * WICG draft -- a subtlepq extension; if the WG ever adopts DHKEM
11
+ * identifiers, those will be adopted here and these names aliased.
12
+ *
13
+ * Everything underneath is native WebCrypto (ECDH / X25519 + HKDF-SHA256);
14
+ * no WASM is involved and all keys are genuine platform CryptoKeys --
15
+ * structured-cloneable, native usage enforcement (surfaced as
16
+ * "deriveBits", the underlying native usage).
17
+ *
18
+ * Decapsulation needs the recipient PUBLIC key bytes for the RFC 9180
19
+ * kem_context. They are remembered automatically for private keys made by
20
+ * generateKey()/importKey() of this module; for foreign or structured-
21
+ * cloned private keys pass { name, publicKey } (CryptoKey or serialized
22
+ * bytes) or use an extractable key.
23
+ ***************************************************************************/
24
+
25
+ import { registerExtension, unregisterExtension } from "./algorithms.js";
26
+ import { importSharedKey, toBytes } from "./subtle.js";
27
+ import { unb64u } from "./formats.js";
28
+ import * as E from "./errors.js";
29
+
30
+ const te = new TextEncoder();
31
+
32
+ function cat(...parts) {
33
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
34
+ let o = 0;
35
+ for (const p of parts) { out.set(p, o); o += p.length; }
36
+ return out;
37
+ }
38
+
39
+ /* suite_id = "KEM" || I2OSP(kem_id, 2); Nsecret = 32 for both suites.
40
+ * The RFC serialized public key form is exactly WebCrypto "raw": 32 bytes
41
+ * for X25519, the 65-byte uncompressed point for P-256. */
42
+ const SUITES = {
43
+ "DHKEM-X25519-HKDF-SHA256": {
44
+ nEnc: 32,
45
+ native: { name: "X25519" },
46
+ suiteId: cat(te.encode("KEM"), [0x00, 0x20]),
47
+ jwkPub: (jwk) => (jwk.x === undefined ? null : unb64u(jwk.x, "x")),
48
+ },
49
+ "DHKEM-P256-HKDF-SHA256": {
50
+ nEnc: 65,
51
+ native: { name: "ECDH", namedCurve: "P-256" },
52
+ suiteId: cat(te.encode("KEM"), [0x00, 0x10]),
53
+ jwkPub: (jwk) => (jwk.x === undefined || jwk.y === undefined ? null
54
+ : cat([0x04], unb64u(jwk.x, "x"), unb64u(jwk.y, "y"))),
55
+ },
56
+ };
57
+
58
+ const PUB_USAGES = ["encapsulateBits", "encapsulateKey"];
59
+ const PRIV_USAGES = ["decapsulateBits", "decapsulateKey", "deriveBits", "deriveKey"];
60
+
61
+ /* key -> its serialized PUBLIC key bytes (for a private key, the public
62
+ * half): encap serializes pkR and decap mixes it into the kem_context,
63
+ * and neither can be exported off a non-extractable CryptoKey */
64
+ const pkmap = new WeakMap();
65
+
66
+ function nativeSubtle() {
67
+ const ns = globalThis.crypto && globalThis.crypto.subtle;
68
+ if (!ns) throw E.notSupported("no native SubtleCrypto for DHKEM");
69
+ return ns;
70
+ }
71
+
72
+ function requireSuite(alg) {
73
+ let name, obj;
74
+ if (typeof alg === "string") {
75
+ name = alg;
76
+ } else if (alg && typeof alg.name === "string") {
77
+ name = alg.name;
78
+ obj = alg;
79
+ }
80
+ const canonical = name === undefined ? undefined :
81
+ Object.keys(SUITES).find((n) => n.toLowerCase() === name.toLowerCase());
82
+ if (!canonical) throw E.notSupported("unrecognized DHKEM algorithm");
83
+ return { name: canonical, s: SUITES[canonical], alg: obj };
84
+ }
85
+
86
+ function requireKey(key, s, name, type) {
87
+ const ka = key && key.algorithm;
88
+ const match = ka && ka.name === s.native.name &&
89
+ (s.native.namedCurve === undefined || ka.namedCurve === s.native.namedCurve);
90
+ if (!match) {
91
+ throw E.invalidAccess("key is not a native " + s.native.name +
92
+ (s.native.namedCurve ? "/" + s.native.namedCurve : "") +
93
+ " key usable with " + name);
94
+ }
95
+ if (key.type !== type) throw E.invalidAccess("expected a " + type + " key");
96
+ }
97
+
98
+ function checkUsages(usages, allowed) {
99
+ for (const u of usages) {
100
+ if (!allowed.includes(u)) throw E.syntaxError("unsupported key usage " + u);
101
+ }
102
+ }
103
+
104
+ /* ExtractAndExpand (RFC 9180 sec 4.1) in one native HKDF deriveBits call:
105
+ * LabeledExtract("", "eae_prk", dh) + LabeledExpand(prk, "shared_secret",
106
+ * kem_context, 32) map exactly onto HKDF's extract-then-expand. */
107
+ async function extractAndExpand(ns, s, dh, kemContext) {
108
+ const ikm = cat(te.encode("HPKE-v1"), s.suiteId, te.encode("eae_prk"), dh);
109
+ const info = cat([0, 32], te.encode("HPKE-v1"), s.suiteId,
110
+ te.encode("shared_secret"), kemContext);
111
+ const k = await ns.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
112
+ ikm.fill(0);
113
+ return new Uint8Array(await ns.deriveBits(
114
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, k, 256));
115
+ }
116
+
117
+ const dhBits = async (ns, s, priv, pub) => new Uint8Array(
118
+ await ns.deriveBits({ name: s.native.name, public: pub }, priv, 256));
119
+
120
+ async function publicBytesFor(ns, key, s, name, alg) {
121
+ const given = alg && alg.publicKey;
122
+ if (given !== undefined && given !== null) {
123
+ if (typeof CryptoKey !== "undefined" && given instanceof CryptoKey) {
124
+ requireKey(given, s, name, "public");
125
+ return new Uint8Array(await ns.exportKey("raw", given));
126
+ }
127
+ const b = toBytes(given, "publicKey");
128
+ if (b.length !== s.nEnc) throw E.dataError("bad " + name + " publicKey length");
129
+ return b;
130
+ }
131
+ const known = pkmap.get(key);
132
+ if (known) return known.slice();
133
+ if (key.extractable) {
134
+ const pub = s.jwkPub(await ns.exportKey("jwk", key));
135
+ if (pub) return pub;
136
+ }
137
+ throw E.invalidAccess("cannot determine the recipient public key for " + name +
138
+ " decapsulation; pass { name, publicKey } or use a key from this module");
139
+ }
140
+
141
+ /* ---- key management (thin translation onto native ECDH / X25519) ---- */
142
+
143
+ export async function generateKey(algorithm, extractable, keyUsages = []) {
144
+ const { s } = requireSuite(algorithm);
145
+ checkUsages(keyUsages, [...PUB_USAGES, ...PRIV_USAGES]);
146
+ if (!keyUsages.some((u) => PRIV_USAGES.includes(u))) {
147
+ throw E.syntaxError("private key usages must not be empty");
148
+ }
149
+ const ns = nativeSubtle();
150
+ const pair = await ns.generateKey(s.native, extractable, ["deriveBits"]);
151
+ const pub = new Uint8Array(await ns.exportKey("raw", pair.publicKey));
152
+ pkmap.set(pair.publicKey, pub);
153
+ pkmap.set(pair.privateKey, pub);
154
+ return pair;
155
+ }
156
+
157
+ export async function importKey(format, keyData, algorithm, extractable, keyUsages = []) {
158
+ const { s, name } = requireSuite(algorithm);
159
+ const ns = nativeSubtle();
160
+ const importPriv = async (fmt, data) => {
161
+ checkUsages(keyUsages, PRIV_USAGES);
162
+ if (!keyUsages.length) throw E.syntaxError("private key usages must not be empty");
163
+ return ns.importKey(fmt, data, s.native, extractable, ["deriveBits"]);
164
+ };
165
+ if (format === "raw" || format === "raw-public" || format === "spki") {
166
+ checkUsages(keyUsages, PUB_USAGES);
167
+ const fmt = format === "raw-public" ? "raw" : format;
168
+ const bytes = toBytes(keyData, "keyData");
169
+ const key = await ns.importKey(fmt, bytes, s.native, extractable, []);
170
+ if (fmt === "raw") {
171
+ pkmap.set(key, bytes);
172
+ } else {
173
+ /* spki: recover the raw form via an extractable twin */
174
+ try {
175
+ const twin = extractable ? key
176
+ : await ns.importKey("spki", bytes, s.native, true, []);
177
+ pkmap.set(key, new Uint8Array(await ns.exportKey("raw", twin)));
178
+ } catch {}
179
+ }
180
+ return key;
181
+ }
182
+ if (format === "pkcs8") {
183
+ const bytes = toBytes(keyData, "keyData");
184
+ const key = await importPriv("pkcs8", bytes);
185
+ /* recover the public half for decap: from the key if extractable,
186
+ * else from a throwaway extractable import of the same bytes */
187
+ try {
188
+ const twin = extractable ? key
189
+ : await ns.importKey("pkcs8", bytes, s.native, true, ["deriveBits"]);
190
+ const pub = s.jwkPub(await ns.exportKey("jwk", twin));
191
+ if (pub) pkmap.set(key, pub);
192
+ } catch {}
193
+ bytes.fill(0);
194
+ return key;
195
+ }
196
+ if (format === "jwk") {
197
+ if (!keyData || typeof keyData !== "object" || ArrayBuffer.isView(keyData) ||
198
+ keyData instanceof ArrayBuffer) {
199
+ throw E.dataError("jwk import expects a JsonWebKey object");
200
+ }
201
+ /* alg/key_ops carry DHKEM-level values the native import would
202
+ * reject; the native usage set is supplied explicitly instead */
203
+ const clean = { ...keyData };
204
+ delete clean.alg;
205
+ delete clean.key_ops;
206
+ delete clean.use;
207
+ if (keyData.d === undefined) {
208
+ checkUsages(keyUsages, PUB_USAGES);
209
+ const key = await ns.importKey("jwk", clean, s.native, extractable, []);
210
+ const pub = s.jwkPub(keyData);
211
+ if (pub) pkmap.set(key, pub);
212
+ return key;
213
+ }
214
+ const key = await importPriv("jwk", clean);
215
+ const pub = s.jwkPub(keyData);
216
+ if (pub) pkmap.set(key, pub);
217
+ return key;
218
+ }
219
+ throw E.notSupported("key format " + format + " is not supported");
220
+ }
221
+
222
+ /* ---- KEM operations (RFC 9180 sec 4.1 Encap / Decap, base mode) ---- */
223
+
224
+ async function encap(algorithm, encapsulationKey) {
225
+ const { s, name } = requireSuite(algorithm);
226
+ requireKey(encapsulationKey, s, name, "public");
227
+ const ns = nativeSubtle();
228
+ const eph = await ns.generateKey(s.native, false, ["deriveBits"]);
229
+ const enc = new Uint8Array(await ns.exportKey("raw", eph.publicKey));
230
+ const dh = await dhBits(ns, s, eph.privateKey, encapsulationKey);
231
+ const known = pkmap.get(encapsulationKey);
232
+ let pkRm;
233
+ if (known) {
234
+ pkRm = known;
235
+ } else {
236
+ try {
237
+ pkRm = new Uint8Array(await ns.exportKey("raw", encapsulationKey));
238
+ } catch {
239
+ throw E.invalidAccess("cannot serialize the " + name +
240
+ " encapsulation key: not extractable and not from this module");
241
+ }
242
+ }
243
+ const ss = await extractAndExpand(ns, s, dh, cat(enc, pkRm));
244
+ dh.fill(0);
245
+ return { ss, enc };
246
+ }
247
+
248
+ async function decap(algorithm, decapsulationKey, ciphertext) {
249
+ const { s, name, alg } = requireSuite(algorithm);
250
+ requireKey(decapsulationKey, s, name, "private");
251
+ const enc = toBytes(ciphertext, "ciphertext");
252
+ if (enc.length !== s.nEnc) throw E.opError("bad " + name + " ciphertext length");
253
+ const ns = nativeSubtle();
254
+ let pkE;
255
+ try {
256
+ pkE = await ns.importKey("raw", enc, s.native, true, []);
257
+ } catch {
258
+ /* unlike ML-KEM implicit rejection, DHKEM decap MAY fail: an
259
+ * invalid public key raises ValidationError per RFC 9180 */
260
+ throw E.opError("invalid " + name + " ciphertext");
261
+ }
262
+ const dh = await dhBits(ns, s, decapsulationKey, pkE);
263
+ const pkRm = await publicBytesFor(ns, decapsulationKey, s, name, alg);
264
+ const ss = await extractAndExpand(ns, s, dh, cat(enc, pkRm));
265
+ dh.fill(0);
266
+ return ss;
267
+ }
268
+
269
+ export async function encapsulateBits(algorithm, encapsulationKey) {
270
+ const { ss, enc } = await encap(algorithm, encapsulationKey);
271
+ return { sharedKey: ss.buffer, ciphertext: enc.buffer };
272
+ }
273
+
274
+ export async function encapsulateKey(algorithm, encapsulationKey,
275
+ sharedKeyAlgorithm, extractable, keyUsages) {
276
+ const { ss, enc } = await encap(algorithm, encapsulationKey);
277
+ const sharedKey = await importSharedKey(ss, sharedKeyAlgorithm, extractable, keyUsages);
278
+ return { sharedKey, ciphertext: enc.buffer };
279
+ }
280
+
281
+ export async function decapsulateBits(algorithm, decapsulationKey, ciphertext) {
282
+ return (await decap(algorithm, decapsulationKey, ciphertext)).buffer;
283
+ }
284
+
285
+ export async function decapsulateKey(algorithm, decapsulationKey, ciphertext,
286
+ sharedKeyAlgorithm, extractable, keyUsages) {
287
+ const ss = await decap(algorithm, decapsulationKey, ciphertext);
288
+ return importSharedKey(ss, sharedKeyAlgorithm, extractable, keyUsages);
289
+ }
290
+
291
+ /* ---- feature detection / registration ---- */
292
+
293
+ const OPS = ["generateKey", "importKey", "exportKey",
294
+ "encapsulateBits", "encapsulateKey", "decapsulateBits", "decapsulateKey"];
295
+
296
+ export function supports(operation, algorithm) {
297
+ if (algorithm !== undefined) {
298
+ try {
299
+ requireSuite(algorithm);
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+ return OPS.includes(operation);
305
+ }
306
+
307
+ const ops = {
308
+ generateKey, importKey,
309
+ encapsulateBits, encapsulateKey, decapsulateBits, decapsulateKey,
310
+ supports: (operation) => OPS.includes(operation),
311
+ };
312
+
313
+ /* Makes the DHKEM-* names routable through the subtlepq ponyfill `subtle`
314
+ * and, after install(), through crypto.subtle; call before install(). */
315
+ export function register() {
316
+ for (const name of Object.keys(SUITES)) registerExtension(name, "kem", ops);
317
+ }
318
+
319
+ export function unregister() {
320
+ for (const name of Object.keys(SUITES)) unregisterExtension(name);
321
+ }
package/src/install.js CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import * as ours from "./subtle.js";
15
15
  import { ROUTES, shape } from "./router.js";
16
+ import { hasExtensions } from "./algorithms.js";
16
17
  import * as E from "./errors.js";
17
18
 
18
19
  let saved = null;
@@ -36,8 +37,11 @@ export function install() {
36
37
  const ctor = subtle.constructor;
37
38
  saved = { proto, ctor, methods: {}, supports: Object.getOwnPropertyDescriptor(ctor, "supports") };
38
39
 
39
- /* nothing to do on platforms that already do ML-KEM + ML-DSA natively */
40
- if (nativeSupports(ctor, "encapsulateBits", "ML-KEM-768") &&
40
+ /* nothing to do on platforms that already do ML-KEM + ML-DSA natively --
41
+ * unless extension algorithms (subtlepq/dhkem) are registered, which no
42
+ * native implementation covers; register() before install() */
43
+ if (!hasExtensions() &&
44
+ nativeSupports(ctor, "encapsulateBits", "ML-KEM-768") &&
41
45
  nativeSupports(ctor, "sign", "ML-DSA-65")) {
42
46
  return;
43
47
  }
package/src/subtle.js CHANGED
@@ -24,7 +24,7 @@ import * as E from "./errors.js";
24
24
 
25
25
  const rng = (n) => globalThis.crypto.getRandomValues(new Uint8Array(n));
26
26
 
27
- function toBytes(src, what) {
27
+ export function toBytes(src, what) {
28
28
  if (ArrayBuffer.isView(src)) {
29
29
  return new Uint8Array(
30
30
  src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength));
@@ -69,7 +69,7 @@ function signContext(a) {
69
69
  return ctx;
70
70
  }
71
71
 
72
- async function importSharedKey(ssBytes, sharedKeyAlgorithm, extractable, keyUsages) {
72
+ export async function importSharedKey(ssBytes, sharedKeyAlgorithm, extractable, keyUsages) {
73
73
  const ns = globalThis.crypto && globalThis.crypto.subtle;
74
74
  if (!ns) throw E.notSupported("no native SubtleCrypto to hold the shared key");
75
75
  try {
@@ -83,6 +83,7 @@ async function importSharedKey(ssBytes, sharedKeyAlgorithm, extractable, keyUsag
83
83
 
84
84
  export async function generateKey(algorithm, extractable, keyUsages = []) {
85
85
  const a = requireAlg(algorithm);
86
+ if (a.ops) return a.ops.generateKey(algorithm, extractable, keyUsages);
86
87
  const eng = await getEngine();
87
88
  if (a.kind === "kem") {
88
89
  checkUsages(keyUsages, [...KEM_PUB_USAGES, ...KEM_PRIV_USAGES]);
@@ -111,6 +112,7 @@ export async function generateKey(algorithm, extractable, keyUsages = []) {
111
112
 
112
113
  export async function importKey(format, keyData, algorithm, extractable, keyUsages = []) {
113
114
  const a = requireAlg(algorithm);
115
+ if (a.ops) return a.ops.importKey(format, keyData, algorithm, extractable, keyUsages);
114
116
  const eng = await getEngine();
115
117
  const L = a.kind === "kem" ? eng.kemLengths(a.name) : eng.sigLengths(a.name);
116
118
  const pubUsages = a.kind === "kem" ? KEM_PUB_USAGES : ["verify"];
@@ -234,6 +236,7 @@ export async function getPublicKey(key, keyUsages = []) {
234
236
 
235
237
  export async function encapsulateBits(algorithm, encapsulationKey) {
236
238
  const a = requireAlg(algorithm, "kem");
239
+ if (a.ops) return a.ops.encapsulateBits(algorithm, encapsulationKey);
237
240
  const m = requireKey(encapsulationKey, a, "public", "encapsulateBits");
238
241
  const { ct, ss } = (await getEngine()).kemEncaps(a.name, m.pub);
239
242
  return { sharedKey: ss.buffer, ciphertext: ct.buffer };
@@ -242,6 +245,10 @@ export async function encapsulateBits(algorithm, encapsulationKey) {
242
245
  export async function encapsulateKey(algorithm, encapsulationKey,
243
246
  sharedKeyAlgorithm, extractable, keyUsages) {
244
247
  const a = requireAlg(algorithm, "kem");
248
+ if (a.ops) {
249
+ return a.ops.encapsulateKey(algorithm, encapsulationKey,
250
+ sharedKeyAlgorithm, extractable, keyUsages);
251
+ }
245
252
  const m = requireKey(encapsulationKey, a, "public", "encapsulateKey");
246
253
  const { ct, ss } = (await getEngine()).kemEncaps(a.name, m.pub);
247
254
  const sharedKey = await importSharedKey(ss, sharedKeyAlgorithm, extractable, keyUsages);
@@ -250,6 +257,7 @@ export async function encapsulateKey(algorithm, encapsulationKey,
250
257
 
251
258
  export async function decapsulateBits(algorithm, decapsulationKey, ciphertext) {
252
259
  const a = requireAlg(algorithm, "kem");
260
+ if (a.ops) return a.ops.decapsulateBits(algorithm, decapsulationKey, ciphertext);
253
261
  const m = requireKey(decapsulationKey, a, "private", "decapsulateBits");
254
262
  const eng = await getEngine();
255
263
  const ct = toBytes(ciphertext, "ciphertext");
@@ -262,6 +270,10 @@ export async function decapsulateBits(algorithm, decapsulationKey, ciphertext) {
262
270
  export async function decapsulateKey(algorithm, decapsulationKey, ciphertext,
263
271
  sharedKeyAlgorithm, extractable, keyUsages) {
264
272
  const a = requireAlg(algorithm, "kem");
273
+ if (a.ops) {
274
+ return a.ops.decapsulateKey(algorithm, decapsulationKey, ciphertext,
275
+ sharedKeyAlgorithm, extractable, keyUsages);
276
+ }
265
277
  const m = requireKey(decapsulationKey, a, "private", "decapsulateKey");
266
278
  const eng = await getEngine();
267
279
  const ct = toBytes(ciphertext, "ciphertext");
@@ -371,5 +383,7 @@ const OPS = {
371
383
 
372
384
  export function supports(operation, algorithm) {
373
385
  const a = normalizeAlg(algorithm);
374
- return a ? OPS[a.kind].includes(operation) : false;
386
+ if (!a) return false;
387
+ if (a.ops) return a.ops.supports(operation);
388
+ return OPS[a.kind].includes(operation);
375
389
  }