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 +30 -2
- package/package.json +8 -4
- package/src/algorithms.js +28 -7
- package/src/dhkem.js +321 -0
- package/src/install.js +6 -2
- package/src/subtle.js +17 -3
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:
|
|
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.
|
|
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 (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
+
if (!a) return false;
|
|
387
|
+
if (a.ops) return a.ops.supports(operation);
|
|
388
|
+
return OPS[a.kind].includes(operation);
|
|
375
389
|
}
|