Skip to content

Commit 987d01d

Browse files
divybotlittledivy
andauthored
fix(ext/crypto): move getPublicKey to SubtleCrypto and validate usages (#34913)
## Summary [`getPublicKey`](https://wicg.github.io/webcrypto-modern-algos/#SubtleCrypto-method-getPublicKey) was incorrectly exposed as a method on `CryptoKey.prototype`. Per the WICG WebCrypto Modern Algorithms spec it belongs on `SubtleCrypto.prototype` as `getPublicKey(key, keyUsages)`, and it must validate the requested public-key usages for the key's algorithm. It also previously did no usage validation. Before: ```js const kp = await crypto.subtle.generateKey({ name: "ML-KEM-512" }, true, ["decapsulateBits"]); kp.privateKey.getPublicKey(["sign"]); // returned a CryptoKey (wrong) ``` After: ```js const kp = await crypto.subtle.generateKey({ name: "ML-KEM-512" }, true, ["decapsulateBits"]); kp.privateKey.getPublicKey; // undefined -> TypeError when called await crypto.subtle.getPublicKey(kp.privateKey, ["encapsulateBits"]); // CryptoKey await crypto.subtle.getPublicKey(kp.privateKey, ["sign"]); // DOMException (SyntaxError) ``` ## Changes - Removed `getPublicKey()` from `CryptoKey` (so `key.getPublicKey` is now `undefined`). - Added async `SubtleCrypto.prototype.getPublicKey(key, keyUsages)` following the spec step order: - `NotSupportedError` for algorithms that cannot derive a public key (symmetric/KDF), - `InvalidAccessError` when the input is not a private key, - `SyntaxError` when a requested usage is not valid for a public key of the algorithm, - otherwise derives an extractable public key with the requested usages. - Implemented support for **all** asymmetric algorithms: RSA, EC (ECDSA/ECDH), Ed25519, X25519, X448, ML-KEM and ML-DSA. RSA/EC reuse the existing SPKI export path (which already derives the public key from the private key) and re-import; Ed25519/X25519/X448 derive the raw public key and re-import as a JWK. A new `op_crypto_x448_public_key` op derives the X448 public key from its private key. - Updated the `WebCryptoAPI` WPT expectations: `getPublicKey.tentative` now passes fully, and the `getPublicKey` `idlharness` subtests are no longer expected to fail. ## Tests - Updated the existing ML-KEM/ML-DSA `getPublicKey` unit tests to the new API and added invalid-usage / wrong-key-type rejection assertions. - Added unit tests covering `getPublicKey` for the classical algorithms (ECDSA, Ed25519, X25519, RSA-PSS) and the `NotSupportedError` case. - The `getPublicKey.tentative` WPT test passes for every covered algorithm (verified by replicating each subtest against the build). Closes #34907 Closes denoland/divybot#495 Co-authored-by: divybot <divybot@users.noreply.github.com> Co-authored-by: Divy Srivastava <me@littledivy.com>
1 parent 1d51877 commit 987d01d

6 files changed

Lines changed: 344 additions & 89 deletions

File tree

ext/crypto/00_crypto.js

Lines changed: 193 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const {
6565
op_crypto_verify_mldsa,
6666
op_crypto_wrap_key,
6767
op_crypto_x25519_public_key,
68+
op_crypto_x448_public_key,
6869
} = core.ops;
6970
const {
7071
ArrayBufferIsView,
@@ -472,79 +473,6 @@ class CryptoKey {
472473
return this[_algorithm];
473474
}
474475

475-
/**
476-
* Derive the public key associated with this CryptoKey, when the underlying
477-
* algorithm supports it (currently ML-KEM decapsulation keys and ML-DSA
478-
* signing keys).
479-
*
480-
* https://wicg.github.io/webcrypto-modern-algos/#CryptoKey-method-getPublicKey
481-
*
482-
* @returns {CryptoKey}
483-
*/
484-
getPublicKey() {
485-
webidl.assertBranded(this, CryptoKeyPrototype);
486-
if (this[_type] !== "private") {
487-
throw new DOMException(
488-
"getPublicKey() is only valid on private keys",
489-
"InvalidAccessError",
490-
);
491-
}
492-
493-
const algorithm = this[_algorithm];
494-
const algorithmName = algorithm.name;
495-
switch (algorithmName) {
496-
case "ML-KEM-512":
497-
case "ML-KEM-768":
498-
case "ML-KEM-1024": {
499-
const handle = this[_handle];
500-
let publicKeyBytes;
501-
try {
502-
publicKeyBytes = op_crypto_ml_kem_get_public_key(
503-
algorithmName,
504-
handle.cppgc,
505-
);
506-
} catch (_) {
507-
throw new DOMException(
508-
"Failed to derive public key",
509-
"OperationError",
510-
);
511-
}
512-
const pubHandle = {};
513-
setKeyData(pubHandle, publicKeyBytes);
514-
const filteredUsages = ArrayPrototypeFilter(
515-
this[_usages],
516-
(u) => u === "encapsulateKey" || u === "encapsulateBits",
517-
);
518-
return constructKey(
519-
"public",
520-
true,
521-
filteredUsages.length > 0
522-
? filteredUsages
523-
: ["encapsulateKey", "encapsulateBits"],
524-
{ name: algorithmName },
525-
pubHandle,
526-
);
527-
}
528-
case "ML-DSA-44":
529-
case "ML-DSA-65":
530-
case "ML-DSA-87": {
531-
const pub = WeakMapPrototypeGet(MLDSA_PUBLIC_FROM_PRIVATE, this);
532-
if (pub === undefined) {
533-
throw new DOMException(
534-
"Public key is not available",
535-
"InvalidAccessError",
536-
);
537-
}
538-
return pub;
539-
}
540-
default:
541-
throw new DOMException(
542-
`getPublicKey() is not supported for ${algorithmName}`,
543-
"NotSupportedError",
544-
);
545-
}
546-
}
547-
548476
[SymbolFor("Deno.privateCustomInspect")](inspect, inspectOptions) {
549477
return inspect(
550478
createFilteredInspectProxy({
@@ -623,6 +551,21 @@ function usageIntersection(a, b) {
623551
);
624552
}
625553

554+
/**
555+
* Throw a SyntaxError if any requested usage is not valid for a public key of
556+
* the algorithm (i.e. is not present in `allowed`).
557+
*
558+
* @param {string[]} requested
559+
* @param {string[]} allowed
560+
*/
561+
function validatePublicKeyUsages(requested, allowed) {
562+
for (let i = 0; i < requested.length; i++) {
563+
if (!ArrayPrototypeIncludes(allowed, requested[i])) {
564+
throw new DOMException("Invalid key usage", "SyntaxError");
565+
}
566+
}
567+
}
568+
626569
// The key material for every CryptoKey lives in Rust, wrapped in a V8
627570
// garbage-collected (cppgc) object created by `op_crypto_key_store_insert`. A
628571
// `handle` here is a plain object that holds that cppgc object on its `cppgc`
@@ -2372,6 +2315,183 @@ class SubtleCrypto {
23722315
return TypedArrayPrototypeGetBuffer(sharedSecret);
23732316
}
23742317

2318+
/**
2319+
* Derive the public key associated with a private key, for asymmetric
2320+
* algorithms (RSA, EC, Ed25519, X25519/X448 and the post-quantum ML-KEM and
2321+
* ML-DSA families).
2322+
*
2323+
* https://wicg.github.io/webcrypto-modern-algos/#SubtleCrypto-method-getPublicKey
2324+
*
2325+
* @param {CryptoKey} key
2326+
* @param {KeyUsage[]} keyUsages
2327+
* @returns {Promise<CryptoKey>}
2328+
*/
2329+
async getPublicKey(key, keyUsages) {
2330+
webidl.assertBranded(this, SubtleCryptoPrototype);
2331+
const prefix = "Failed to execute 'getPublicKey' on 'SubtleCrypto'";
2332+
webidl.requiredArguments(arguments.length, 2, prefix);
2333+
key = webidl.converters.CryptoKey(key, prefix, "Argument 1");
2334+
keyUsages = webidl.converters["sequence<KeyUsage>"](
2335+
keyUsages,
2336+
prefix,
2337+
"Argument 2",
2338+
);
2339+
2340+
const algorithm = key[_algorithm];
2341+
const algorithmName = algorithm.name;
2342+
2343+
// 1. Algorithms that cannot derive a public key reject with a
2344+
// NotSupportedError (this also covers symmetric and KDF algorithms).
2345+
switch (algorithmName) {
2346+
case "RSASSA-PKCS1-v1_5":
2347+
case "RSA-PSS":
2348+
case "RSA-OAEP":
2349+
case "ECDSA":
2350+
case "ECDH":
2351+
case "Ed25519":
2352+
case "X25519":
2353+
case "X448":
2354+
case "ML-DSA-44":
2355+
case "ML-DSA-65":
2356+
case "ML-DSA-87":
2357+
case "ML-KEM-512":
2358+
case "ML-KEM-768":
2359+
case "ML-KEM-1024":
2360+
break;
2361+
default:
2362+
throw new DOMException(
2363+
`getPublicKey() is not supported for ${algorithmName}`,
2364+
"NotSupportedError",
2365+
);
2366+
}
2367+
2368+
// 2. The public key can only be derived from a private key.
2369+
if (key[_type] !== "private") {
2370+
throw new DOMException(
2371+
"Public keys can only be derived from private keys",
2372+
"InvalidAccessError",
2373+
);
2374+
}
2375+
2376+
// 3. Derive the public key. For ML-KEM/ML-DSA the usages allowed for a
2377+
// public key are validated here; for the other algorithms the derived
2378+
// public key material is re-imported, which performs the same per-algorithm
2379+
// usage validation (rejecting invalid usages with a SyntaxError).
2380+
switch (algorithmName) {
2381+
case "ML-KEM-512":
2382+
case "ML-KEM-768":
2383+
case "ML-KEM-1024": {
2384+
validatePublicKeyUsages(keyUsages, ML_KEM_PUBLIC_USAGES);
2385+
let publicKeyBytes;
2386+
try {
2387+
publicKeyBytes = op_crypto_ml_kem_get_public_key(
2388+
algorithmName,
2389+
key[_handle].cppgc,
2390+
);
2391+
} catch (_) {
2392+
throw new DOMException(
2393+
"Failed to derive public key",
2394+
"OperationError",
2395+
);
2396+
}
2397+
const pubHandle = {};
2398+
setKeyData(pubHandle, publicKeyBytes);
2399+
return constructKey(
2400+
"public",
2401+
true,
2402+
keyUsages,
2403+
{ name: algorithmName },
2404+
pubHandle,
2405+
);
2406+
}
2407+
case "ML-DSA-44":
2408+
case "ML-DSA-65":
2409+
case "ML-DSA-87": {
2410+
validatePublicKeyUsages(keyUsages, ["verify"]);
2411+
// The matching public key is derived and stored alongside the private
2412+
// key at generate/import time; reuse its key material.
2413+
const pub = WeakMapPrototypeGet(MLDSA_PUBLIC_FROM_PRIVATE, key);
2414+
if (pub === undefined) {
2415+
throw new DOMException(
2416+
"Failed to derive public key",
2417+
"OperationError",
2418+
);
2419+
}
2420+
return constructKey(
2421+
"public",
2422+
true,
2423+
keyUsages,
2424+
{ name: algorithmName },
2425+
pub[_handle],
2426+
);
2427+
}
2428+
case "RSASSA-PKCS1-v1_5":
2429+
case "RSA-PSS":
2430+
case "RSA-OAEP": {
2431+
let spki;
2432+
try {
2433+
spki = op_crypto_export_key({
2434+
algorithm: algorithmName,
2435+
format: "spki",
2436+
}, getKeyData(key[_handle]));
2437+
} catch (_) {
2438+
throw new DOMException(
2439+
"Failed to derive public key",
2440+
"OperationError",
2441+
);
2442+
}
2443+
return await this.importKey("spki", spki, algorithm, true, keyUsages);
2444+
}
2445+
case "ECDSA":
2446+
case "ECDH": {
2447+
let spki;
2448+
try {
2449+
spki = op_crypto_export_key({
2450+
algorithm: algorithmName,
2451+
namedCurve: algorithm.namedCurve,
2452+
format: "spki",
2453+
}, getKeyData(key[_handle]));
2454+
} catch (_) {
2455+
throw new DOMException(
2456+
"Failed to derive public key",
2457+
"OperationError",
2458+
);
2459+
}
2460+
return await this.importKey("spki", spki, algorithm, true, keyUsages);
2461+
}
2462+
default: {
2463+
// Ed25519, X25519 and X448 store raw key material; derive the raw
2464+
// public key from the private key and re-import it as a JWK.
2465+
let x;
2466+
try {
2467+
switch (algorithmName) {
2468+
case "Ed25519":
2469+
x = op_crypto_jwk_x_ed25519(getKeyData(key[_handle]));
2470+
break;
2471+
case "X25519":
2472+
x = op_crypto_x25519_public_key(getKeyData(key[_handle]));
2473+
break;
2474+
default: // X448
2475+
x = op_crypto_x448_public_key(getKeyData(key[_handle]));
2476+
break;
2477+
}
2478+
} catch (_) {
2479+
throw new DOMException(
2480+
"Failed to derive public key",
2481+
"OperationError",
2482+
);
2483+
}
2484+
const jwk = {
2485+
kty: "OKP",
2486+
crv: algorithmName,
2487+
x,
2488+
ext: true,
2489+
};
2490+
return await this.importKey("jwk", jwk, algorithm, true, keyUsages);
2491+
}
2492+
}
2493+
}
2494+
23752495
[SymbolFor("Deno.privateCustomInspect")](inspect, inspectOptions) {
23762496
return `${this.constructor.name} ${inspect({}, inspectOptions)}`;
23772497
}

ext/crypto/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ deno_core::extension!(deno_crypto,
125125
x448::op_crypto_derive_bits_x448,
126126
x448::op_crypto_import_spki_x448,
127127
x448::op_crypto_import_pkcs8_x448,
128+
x448::op_crypto_x448_public_key,
128129
x448::op_crypto_export_spki_x448,
129130
x448::op_crypto_export_pkcs8_x448,
130131
ed25519::op_crypto_generate_ed25519_keypair,

ext/crypto/x448.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright 2018-2026 the Deno authors. MIT license.
22

3+
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
34
use deno_core::convert::Uint8Array;
45
use deno_core::op2;
56
use ed448_goldilocks::EdwardsScalar;
@@ -79,6 +80,24 @@ pub fn op_crypto_derive_bits_x448(
7980
const X448_OID: const_oid::ObjectIdentifier =
8081
const_oid::ObjectIdentifier::new_unwrap("1.3.101.111");
8182

83+
#[op2]
84+
#[string]
85+
pub fn op_crypto_x448_public_key(
86+
#[buffer] private_key: &[u8],
87+
) -> Result<String, X448Error> {
88+
use base64::Engine;
89+
90+
let private_key: [u8; 56] = private_key
91+
.try_into()
92+
.map_err(|_| X448Error::InvalidKeyLength)?;
93+
// x448(pkey, 5), identical derivation to op_crypto_generate_x448_keypair.
94+
let mut scalar_bytes = [0u8; 57];
95+
scalar_bytes[..56].copy_from_slice(&private_key);
96+
let scalar = EdwardsScalar::from_bytes_mod_order(&scalar_bytes.into());
97+
let point = &MontgomeryPoint::GENERATOR * &scalar;
98+
Ok(BASE64_URL_SAFE_NO_PAD.encode(point.0))
99+
}
100+
82101
#[op2]
83102
pub fn op_crypto_export_spki_x448(
84103
#[buffer] pubkey: &[u8],

tests/unit/webcrypto_mldsa_test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
assertEquals,
66
assertNotEquals,
77
assertRejects,
8-
assertThrows,
98
} from "./test_util.ts";
109

1110
// deno-lint-ignore no-explicit-any
@@ -354,10 +353,17 @@ for (const [name, pubLen, privLen, sigLen] of variants) {
354353
["sign", "verify"],
355354
) as CryptoKeyPair;
356355

357-
const derived = (privateKey as AnyKey).getPublicKey();
356+
// getPublicKey lives on SubtleCrypto.prototype, not CryptoKey.prototype.
357+
assertEquals(typeof (privateKey as AnyKey).getPublicKey, "undefined");
358+
359+
const derived = await (crypto.subtle as AnyKey).getPublicKey(
360+
privateKey,
361+
["verify"],
362+
);
358363
assert(derived);
359364
assertEquals(derived.type, "public");
360365
assertEquals(derived.algorithm.name, name);
366+
assertEquals(derived.usages, ["verify"]);
361367

362368
const rawPub = new Uint8Array(await exportKey("raw-public", publicKey));
363369
const rawDerived = new Uint8Array(await exportKey("raw-public", derived));
@@ -370,14 +376,20 @@ for (const [name, pubLen, privLen, sigLen] of variants) {
370376
);
371377
});
372378

373-
Deno.test(`webcrypto ${name} getPublicKey() throws for public keys`, async () => {
374-
const { publicKey } = await crypto.subtle.generateKey(
379+
Deno.test(`webcrypto ${name} getPublicKey() rejects invalid input`, async () => {
380+
const { publicKey, privateKey } = await crypto.subtle.generateKey(
375381
{ name } as AnyAlg,
376382
true,
377383
["sign", "verify"],
378384
) as CryptoKeyPair;
379-
assertThrows(
380-
() => (publicKey as AnyKey).getPublicKey(),
385+
// Public keys are not valid input.
386+
await assertRejects(
387+
() => (crypto.subtle as AnyKey).getPublicKey(publicKey, ["verify"]),
388+
DOMException,
389+
);
390+
// Invalid public-key usages for the algorithm reject with SyntaxError.
391+
await assertRejects(
392+
() => (crypto.subtle as AnyKey).getPublicKey(privateKey, ["sign"]),
381393
DOMException,
382394
);
383395
});

0 commit comments

Comments
 (0)