11import {
22 ecAlgorithmToCurve ,
33 isEcKeyAlgorithm ,
4+ isMlKemKeyAlgorithm ,
45 isRsaKeyAlgorithm ,
56 type KeyAlgorithm ,
67 type KeyOptions ,
78 MIN_ASYMMETRIC_KEY_SIZE_BITS ,
9+ mlKemAlgorithmToLevel ,
810 type PrivateKey ,
911 type PublicKey ,
1012 type PublicKeyInfo ,
@@ -17,11 +19,26 @@ import { exportSPKI, importX509 } from 'jose';
1719import {
1820 guessAlgorithmName ,
1921 guessCurveName ,
22+ ML_KEM_768_OID ,
23+ ML_KEM_1024_OID ,
2024 toJwsAlg ,
2125} from '../../../../src/crypto/pemPublicToCrypto.js' ;
22- import { unwrapKey , wrapPrivateKey , wrapPublicKey } from './keys.js' ;
26+ import {
27+ unwrapKey ,
28+ wrapMlKemPublicKey ,
29+ unwrapMlKemKey ,
30+ wrapPrivateKey ,
31+ wrapPublicKey ,
32+ } from './keys.js' ;
33+ import { decodeMlKemSpkiDer , encodeMlKemSpkiDer } from './mlkem-asn1.js' ;
2334import { rsaOaepSha1 } from './rsa.js' ;
2435
36+ function detectMlKemLevelFromHex ( hex : string ) : 768 | 1024 | undefined {
37+ if ( hex . includes ( ML_KEM_768_OID ) ) return 768 ;
38+ if ( hex . includes ( ML_KEM_1024_OID ) ) return 1024 ;
39+ return undefined ;
40+ }
41+
2542/**
2643 * Extract PEM public key from X.509 certificate or return PEM key as-is.
2744 */
@@ -115,6 +132,15 @@ export async function parsePublicKeyPem(pem: string): Promise<PublicKeyInfo> {
115132
116133 const keyData = base64Decode ( removePemFormatting ( publicKeyPem ) ) ;
117134
135+ // ML-KEM: detect by OID before falling through to RSA/EC.
136+ // subtle.crypto does not (as of 2026) support ML-KEM
137+ // decodeMlKemSpkiDer also validates length so this rejects mis-encoded keys.
138+ const mlKemLevel = detectMlKemLevelFromHex ( hexEncode ( keyData ) ) ;
139+ if ( mlKemLevel !== undefined ) {
140+ decodeMlKemSpkiDer ( new Uint8Array ( keyData ) ) ;
141+ return { algorithm : `mlkem:${ mlKemLevel } ` as const , pem : publicKeyPem } ;
142+ }
143+
118144 // Try RSA first - use JWK export to get modulus size
119145 try {
120146 const modulusBits = await extractRsaModulusBitLength ( keyData ) ;
@@ -225,12 +251,31 @@ export async function publicKeyPemToJwk(publicKeyPem: string): Promise<JsonWebKe
225251
226252/**
227253 * Import a PEM public key as an opaque key.
254+ *
255+ * Accepts standard `-----BEGIN PUBLIC KEY-----` SPKI envelopes for RSA, EC, and
256+ * ML-KEM (per draft-ietf-lamps-kyber-certificates, OIDs id-alg-ml-kem-{768,1024}).
257+ * ML-KEM keys produced by `openssl pkey -pubout` round-trip without translation.
228258 */
229259export async function importPublicKey ( pem : string , options : KeyOptions ) : Promise < PublicKey > {
230260 const { usage = 'encrypt' , extractable = true , algorithmHint } = options ;
231261
232- // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM.
262+ // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM
263+ // and identifies ML-KEM keys by OID.
233264 const keyInfo = await parsePublicKeyPem ( pem ) ;
265+
266+ // ML-KEM: import via SPKI codec. WebCrypto has no ML-KEM support, so we keep
267+ // the key as an opaque `PublicKey` carrying the raw encapsulation key bytes.
268+ if ( isMlKemKeyAlgorithm ( keyInfo . algorithm ) ) {
269+ const der = new Uint8Array ( base64Decode ( removePemFormatting ( keyInfo . pem ) ) ) ;
270+ const { level, rawKey } = decodeMlKemSpkiDer ( der ) ;
271+ if ( algorithmHint && algorithmHint !== `mlkem:${ level } ` ) {
272+ throw new ConfigurationError (
273+ `ML-KEM SPKI advertises mlkem:${ level } but algorithmHint is ${ algorithmHint } `
274+ ) ;
275+ }
276+ return wrapMlKemPublicKey ( rawKey , level ) ;
277+ }
278+
234279 const algorithm = algorithmHint || keyInfo . algorithm ;
235280 // Use keyInfo.pem (normalised SPKI) not the original pem, which may be a certificate.
236281 // Passing raw X.509 DER bytes to crypto.subtle.importKey('spki') would throw DataError.
@@ -376,9 +421,21 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis
376421}
377422
378423/**
379- * Export an opaque public key to PEM format.
424+ * Export an opaque public key to PEM SPKI format.
425+ *
426+ * ML-KEM keys are wrapped in a SubjectPublicKeyInfo envelope using the NIST
427+ * OIDs id-alg-ml-kem-{768,1024} (per draft-ietf-lamps-kyber-certificates),
428+ * so the resulting PEM is byte-compatible with `openssl pkey -pubout`.
380429 */
381430export async function exportPublicKeyPem ( key : PublicKey ) : Promise < string > {
431+ if ( isMlKemKeyAlgorithm ( key . algorithm ) ) {
432+ const level = mlKemAlgorithmToLevel ( key . algorithm ) ;
433+ const der = encodeMlKemSpkiDer ( unwrapMlKemKey ( key ) , level ) ;
434+ return formatAsPem (
435+ der . buffer . slice ( der . byteOffset , der . byteOffset + der . byteLength ) ,
436+ 'PUBLIC KEY'
437+ ) ;
438+ }
382439 const cryptoKey = unwrapKey ( key ) ;
383440 const keyBuffer = await crypto . subtle . exportKey ( 'spki' , cryptoKey ) ;
384441 return formatAsPem ( keyBuffer , 'PUBLIC KEY' ) ;
0 commit comments