From a629e7db24d3adc6fc9534cfb2413f354d3ef945 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Sun, 26 May 2024 11:46:25 -0500 Subject: [PATCH] Add example implementation of ML-KEM-68 for COSE HPKE --- package-lock.json | 53 ++++++++ package.json | 1 + src/cose/Params.ts | 43 +++++- src/cose/encrypt/direct.ts | 10 +- src/cose/encrypt/hpke/direct.ts | 136 ++++++++++++++++++- src/cose/key/convertCoseKeyToJsonWebKey.ts | 14 +- src/cose/key/generate.ts | 21 ++- src/cose/key/publicFromPrivate.ts | 34 +++-- src/cose/key/thumbprint.ts | 12 +- src/cose/requested-assignment.ts | 9 ++ test/post-quantum/README.md | 14 ++ test/post-quantum/ml-kem-hpke.sanity.test.ts | 43 ++++++ test/post-quantum/sanity.test.ts | 21 +++ 13 files changed, 380 insertions(+), 31 deletions(-) create mode 100644 src/cose/requested-assignment.ts create mode 100644 test/post-quantum/README.md create mode 100644 test/post-quantum/ml-kem-hpke.sanity.test.ts create mode 100644 test/post-quantum/sanity.test.ts diff --git a/package-lock.json b/package-lock.json index ec1ed62..564aed6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { + "@noble/post-quantum": "^0.1.0", "@peculiar/x509": "^1.9.7", "@transmute/cose": "^0.1.0", "@transmute/rfc9162": "^0.0.5", @@ -1194,6 +1195,37 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/post-quantum": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.1.0.tgz", + "integrity": "sha512-JG1K5NaeYr7hVzLdbtm0OYaNDbr95k2kxHFOyELuwQveRnfcoRNdHcHnG67XdxJuRVgsfs3ZWzjme4LIWaxVuw==", + "dependencies": { + "@noble/ciphers": "0.5.2", + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/ciphers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.2.tgz", + "integrity": "sha512-GADtQmZCdgbnNp+daPLc3OY3ibEtGGDV/+CzeM3MFnhiQ7ELQKlsHWYq0YbYUXx4jU3/Y1erAxU6r+hwpewqmQ==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6273,6 +6305,27 @@ "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", "dev": true }, + "@noble/post-quantum": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.1.0.tgz", + "integrity": "sha512-JG1K5NaeYr7hVzLdbtm0OYaNDbr95k2kxHFOyELuwQveRnfcoRNdHcHnG67XdxJuRVgsfs3ZWzjme4LIWaxVuw==", + "requires": { + "@noble/ciphers": "0.5.2", + "@noble/hashes": "1.4.0" + }, + "dependencies": { + "@noble/ciphers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.2.tgz", + "integrity": "sha512-GADtQmZCdgbnNp+daPLc3OY3ibEtGGDV/+CzeM3MFnhiQ7ELQKlsHWYq0YbYUXx4jU3/Y1erAxU6r+hwpewqmQ==" + }, + "@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==" + } + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 18baa93..6776227 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "typescript": "^4.9.4" }, "dependencies": { + "@noble/post-quantum": "^0.1.0", "@peculiar/x509": "^1.9.7", "@transmute/cose": "^0.1.0", "@transmute/rfc9162": "^0.0.5", diff --git a/src/cose/Params.ts b/src/cose/Params.ts index 20896c6..5ae1193 100644 --- a/src/cose/Params.ts +++ b/src/cose/Params.ts @@ -1,6 +1,9 @@ // This module is just just a limited set of the IANA registries, // exposed to make Map initialization more readable +import { IANACOSEKeyCommonParameters } from "./key-common-parameters" +import * as requested from './requested-assignment' + export type HeaderMapEntry = [number, any] export type HeaderMap = Map @@ -77,14 +80,12 @@ export const KeyWrap = { A128KW: -3 } -export const Direct = { - 'HPKE-Base-P256-SHA256-AES128GCM': 35 -} export const EC2 = 2 export const KeyType = { - EC2 + EC2, + ['ML-KEM']: requested.KeyTypes['ML-KEM'] } export const Epk = { @@ -97,7 +98,39 @@ export const Curve = { P256: 1, } +export const Key = { + Type: parseInt(IANACOSEKeyCommonParameters['1'].Label, 10), + Algorithm: parseInt(IANACOSEKeyCommonParameters['3'].Label, 10) +} + +export const KeyTypeAlgorithms = { + ['ML-KEM']: { + ['HPKE-Base-ML-KEM-768-SHA256-AES128GCM']: requested.Algorithms['HPKE-Base-ML-KEM-768-SHA256-AES128GCM'], + ['ML-KEM-768']: requested.Algorithms['ML-KEM-768'] + } +} + +export const KeyTypeParameters = { + ['ML-KEM']: { + Public: -1, + Secret: -2, + }, + ['EC2']: { + Curve: -1, + PublicX: -2, + PublicY: -3, + Secret: -4, + } +} + + +export const Direct = { + 'HPKE-Base-P256-SHA256-AES128GCM': 35, + 'HPKE-Base-ML-KEM-768-SHA256-AES128GCM': KeyTypeAlgorithms['ML-KEM']['HPKE-Base-ML-KEM-768-SHA256-AES128GCM'] +} export const COSE_Encrypt0 = 16 export const COSE_Sign1 = 18 -export const COSE_Encrypt = 96 \ No newline at end of file +export const COSE_Encrypt = 96 + + diff --git a/src/cose/encrypt/direct.ts b/src/cose/encrypt/direct.ts index 502e586..cd63b6e 100644 --- a/src/cose/encrypt/direct.ts +++ b/src/cose/encrypt/direct.ts @@ -8,7 +8,7 @@ import { EMPTY_BUFFER } from "../../cbor" import * as aes from './aes' import * as ecdh from './ecdh' -import { COSE_Encrypt, Epk, KeyAgreement, Protected, ProtectedHeader, Unprotected } from "../Params" +import { COSE_Encrypt, Direct, Epk, KeyAgreement, Protected, ProtectedHeader, Unprotected } from "../Params" import { createAAD } from './utils' @@ -33,10 +33,10 @@ export const encrypt = async (req: RequestDirectEncryption) => { throw new Error('Direct encryption requires a single recipient') } const recipientPublicKeyJwk = req.recipients.keys[0] - if (recipientPublicKeyJwk.crv !== 'P-256') { - throw new Error('Only P-256 is supported currently') + if (recipientPublicKeyJwk.crv !== 'P-256' && recipientPublicKeyJwk.kty !== 'ML-KEM') { + throw new Error('Only P-256 DHKEM and ML-KEM-768 are currently supported') } - if (recipientPublicKeyJwk.alg === hpke.primaryAlgorithm.label) { + if (Object.keys(Direct).includes(recipientPublicKeyJwk.alg)) { return hpke.encrypt.direct(req) } const alg = req.protectedHeader.get(Protected.Alg) @@ -71,7 +71,7 @@ export const encrypt = async (req: RequestDirectEncryption) => { export const decrypt = async (req: RequestDirectDecryption) => { const receiverPrivateKeyJwk = req.recipients.keys[0] - if (receiverPrivateKeyJwk.alg === hpke.primaryAlgorithm.label) { + if (Object.keys(Direct).includes(receiverPrivateKeyJwk.alg)) { return hpke.decrypt.direct(req) } const decoded = await decodeFirst(req.ciphertext) diff --git a/src/cose/encrypt/hpke/direct.ts b/src/cose/encrypt/hpke/direct.ts index f552be3..82ee298 100644 --- a/src/cose/encrypt/hpke/direct.ts +++ b/src/cose/encrypt/hpke/direct.ts @@ -1,6 +1,9 @@ -import { COSE_Encrypt0, Direct, Protected, Unprotected, UnprotectedHeader } from '../../Params' + +import { ml_kem768 } from '@noble/post-quantum/ml-kem'; + +import { COSE_Encrypt0, Direct, KeyTypeAlgorithms, Protected, Unprotected, UnprotectedHeader } from '../../Params' import { RequestDirectEncryption, RequestDirectDecryption } from '../types' import { Tagged, decodeFirst, encodeAsync } from "cbor-web" @@ -8,15 +11,25 @@ import { computeInfo } from './computeInfo' import { suites, JOSE_HPKE_ALG } from './suites' import { publicKeyFromJwk, privateKeyFromJwk, computeHPKEAad } from './common' +import { base64url } from 'jose' -export const encryptDirect = async (req: RequestDirectEncryption) => { +import * as aes from '../aes' + +import { CipherSuite, KemId, KdfId, AeadId } from 'hpke-js' +import { EMPTY_BUFFER, toArrayBuffer } from '../../../cbor'; + +import { createAAD } from '../utils'; + +const dhkemsuite = new CipherSuite({ + kem: KemId.DhkemP256HkdfSha256, + kdf: KdfId.HkdfSha256, + aead: AeadId.Aes128Gcm, +}); + +const handleDHKemEncrypt = async (req: RequestDirectEncryption) => { if (req.unprotectedHeader === undefined) { req.unprotectedHeader = UnprotectedHeader([]) } - const alg = req.protectedHeader.get(Protected.Alg) - if (alg !== Direct['HPKE-Base-P256-SHA256-AES128GCM']) { - throw new Error('Only alg 35 is supported') - } const protectedHeader = await encodeAsync(req.protectedHeader) const unprotectedHeader = req.unprotectedHeader; const [recipientPublicKeyJwk] = req.recipients.keys @@ -44,7 +57,74 @@ export const encryptDirect = async (req: RequestDirectEncryption) => { ]), { canonical: true }) } -export const decryptDirect = async (req: RequestDirectDecryption) => { + +const sharedSecretToContentEncryptionKey = async (sharedSecret: Uint8Array) => { + const ikm = sharedSecret; + // https://datatracker.ietf.org/doc/html/rfc9180#section-4-10 + // labeled_ikm = concat("HPKE-v1", suite_id, label, ikm) + // 🔥 this is ALL WRONG.... 🔥 + // fake + const suite_id = Buffer.from('0xFFFF', 'hex') // unassigned kem id https://www.iana.org/assignments/hpke/hpke.xhtml + // should be: + // suite_id = concat( + // "HPKE", + // I2OSP(kem_id, 2), + // I2OSP(kdf_id, 2), + // I2OSP(aead_id, 2) + // ) + const labeled_ikm = Buffer.concat([ + new TextEncoder().encode('HPKE-v1'), + suite_id, + Buffer.from(''), + ikm + ]) + const salt = new TextEncoder().encode('') // empty string? + return dhkemsuite.kdf.extract(salt, labeled_ikm) +} + +const handleMLKemEncrypt = async (req: RequestDirectEncryption) => { + const protectedHeader = await encodeAsync(req.protectedHeader) + const unprotectedHeader = req.unprotectedHeader || new Map(); + const [recipientPublicKeyJwk] = req.recipients.keys + const publicKey = base64url.decode(recipientPublicKeyJwk.x) + const { cipherText, sharedSecret } = ml_kem768.encapsulate(publicKey); + const kemCt = cipherText; + const aeadContentEncryptionKey = await sharedSecretToContentEncryptionKey(sharedSecret) + const aeadAlg = 1; // AES 128 GCM + const iv = await aes.getIv(aeadAlg) // random for each direct encryption + const externalAad = EMPTY_BUFFER + // const hpkeSealAad = computeHPKEAad(protectedHeader) // confused why I don't need this... + const aad = await createAAD(protectedHeader, 'Encrypt', externalAad) + const ct = await aes.encrypt(aeadAlg, new Uint8Array(req.plaintext), new Uint8Array(iv), new Uint8Array(aad), new Uint8Array(aeadContentEncryptionKey)) + // HPKE direct mode prefix iv? + const ctWithIv = Buffer.concat([ + Buffer.from(iv), + Buffer.from(ct) + ]) + if (recipientPublicKeyJwk.kid) { + unprotectedHeader.set(Unprotected.Kid, recipientPublicKeyJwk.kid) + } + unprotectedHeader.set(Unprotected.Ek, toArrayBuffer(kemCt)) + return encodeAsync(new Tagged(COSE_Encrypt0, [ + protectedHeader, + unprotectedHeader, + ctWithIv, + ]), { canonical: true }) +} + +export const encryptDirect = async (req: RequestDirectEncryption) => { + const alg = req.protectedHeader.get(Protected.Alg) + if (alg === Direct['HPKE-Base-P256-SHA256-AES128GCM']) { + return handleDHKemEncrypt(req) + } + if (alg === Direct['HPKE-Base-ML-KEM-768-SHA256-AES128GCM']) { + return handleMLKemEncrypt(req) + } + throw new Error('Unsupported HPKE algorithm') +} + + +const handleDHKemDecrypt = async (req: RequestDirectDecryption) => { const decoded = await decodeFirst(req.ciphertext) if (decoded.tag !== COSE_Encrypt0) { throw new Error('Only tag 16 cose encrypt are supported') @@ -73,3 +153,45 @@ export const decryptDirect = async (req: RequestDirectDecryption) => { const plaintext = await hpkeRecipient.open(ciphertext, hpkeSealAad) return plaintext } + + +const handleMLKemDecrypt = async (req: RequestDirectDecryption) => { + const decoded = await decodeFirst(req.ciphertext) + if (decoded.tag !== COSE_Encrypt0) { + throw new Error('Only tag 16 cose encrypt are supported') + } + const [protectedHeader, unprotectedHeader, ctWithIv] = decoded.value + const kid = unprotectedHeader.get(Unprotected.Kid).toString(); + const receiverPrivateKeyJwk = req.recipients.keys.find((k) => { + return k.kid === kid + }) + const ek = unprotectedHeader.get(Unprotected.Ek) + const iv = ctWithIv.slice(0, 16) // AES-128-GCM iv length + const encryptedContent = ctWithIv.slice(16, ctWithIv.length) + const secretKey = base64url.decode(receiverPrivateKeyJwk.d) + const sharedSecret = ml_kem768.decapsulate(ek, secretKey); + const aeadContentEncryptionKey = await sharedSecretToContentEncryptionKey(sharedSecret) + const aeadAlg = 1; // AES 128 GCM + const externalAad = EMPTY_BUFFER + // const hpkeSealAad = computeHPKEAad(protectedHeader) // confused why I don't need this... + const aad = await createAAD(protectedHeader, 'Encrypt', externalAad) + return aes.decrypt(aeadAlg, encryptedContent, new Uint8Array(iv), new Uint8Array(aad), new Uint8Array(aeadContentEncryptionKey)) +} + + +export const decryptDirect = async (req: RequestDirectDecryption) => { + const decoded = await decodeFirst(req.ciphertext) + if (decoded.tag !== COSE_Encrypt0) { + throw new Error('Only tag 16 cose encrypt are supported') + } + const [protectedHeader] = decoded.value + const decodedProtectedHeader = await decodeFirst(protectedHeader) + const alg = decodedProtectedHeader.get(Protected.Alg) + if (alg === Direct['HPKE-Base-P256-SHA256-AES128GCM']) { + return handleDHKemDecrypt(req) + } + if (alg === Direct['HPKE-Base-ML-KEM-768-SHA256-AES128GCM']) { + return handleMLKemDecrypt(req) + } + throw new Error('Unsupported HPKE algorithm') +} diff --git a/src/cose/key/convertCoseKeyToJsonWebKey.ts b/src/cose/key/convertCoseKeyToJsonWebKey.ts index f7fd85a..a80d0fe 100644 --- a/src/cose/key/convertCoseKeyToJsonWebKey.ts +++ b/src/cose/key/convertCoseKeyToJsonWebKey.ts @@ -1,5 +1,5 @@ import { base64url, calculateJwkThumbprint } from "jose"; -import { CoseKey } from "."; +import { CoseKey, thumbprint } from "."; import { IANACOSEAlgorithms } from '../algorithms'; @@ -9,12 +9,24 @@ const algorithms = Object.values(IANACOSEAlgorithms) const curves = Object.values(IANACOSEEllipticCurves) import { formatJwk } from "./formatJwk"; +import { KeyType, KeyTypeParameters } from "../Params"; export const convertCoseKeyToJsonWebKey = async (coseKey: CoseKey): Promise => { const kty = coseKey.get(1) as number const kid = coseKey.get(2) const alg = coseKey.get(3) const crv = coseKey.get(-1) + + if (kty === KeyType["ML-KEM"]) { + //short circuit. + return formatJwk({ + "kid": await thumbprint.calculateCoseKeyThumbprintUri(coseKey), + "alg": "HPKE-Base-ML-KEM-768-SHA256-AES128GCM", + "kty": "ML-KEM", + "x": base64url.encode(coseKey.get(KeyTypeParameters['ML-KEM'].Public) as Uint8Array), + "d": coseKey.get(KeyTypeParameters['ML-KEM'].Secret) ? base64url.encode(coseKey.get(KeyTypeParameters['ML-KEM'].Secret) as Uint8Array) : undefined, + }) as T + } // kty EC, kty: EK if (![2, 5].includes(kty)) { throw new Error('This library requires does not support the given key type') diff --git a/src/cose/key/generate.ts b/src/cose/key/generate.ts index 0c7d24c..6c13742 100644 --- a/src/cose/key/generate.ts +++ b/src/cose/key/generate.ts @@ -1,5 +1,7 @@ +import { ml_kem768 } from '@noble/post-quantum/ml-kem'; + import { generateKeyPair, exportJWK, calculateJwkThumbprint } from "jose" import { IANACOSEAlgorithms } from "../algorithms" @@ -7,7 +9,7 @@ import { IANACOSEAlgorithms } from "../algorithms" import { CoseKey } from '.' export type CoseKeyAgreementAlgorithms = 'ECDH-ES+A128KW' -export type CoseDirectEncryptionAlgorithms = 'HPKE-Base-P256-SHA256-AES128GCM' +export type CoseDirectEncryptionAlgorithms = 'HPKE-Base-P256-SHA256-AES128GCM' | 'HPKE-Base-ML-KEM-768-SHA256-AES128GCM' export type CoseSignatureAlgorithms = 'ES256' | 'ES384' | 'ES512' export type ContentTypeOfJsonWebKey = 'application/jwk+json' export type ContentTypeOfCoseKey = 'application/cose-key' @@ -20,6 +22,8 @@ import { thumbprint } from "./thumbprint" import { formatJwk } from './formatJwk' import { cbor } from "../.." +import { Key, KeyType, KeyTypeAlgorithms, KeyTypeParameters } from '../..'; +import { toArrayBuffer } from '../../cbor'; export const generate = async (alg: CoseSignatureAlgorithms | CoseDirectEncryptionAlgorithms, contentType: PrivateKeyContentType = 'application/jwk+json'): Promise => { let knownAlgorithm = Object.values(IANACOSEAlgorithms).find(( @@ -27,6 +31,21 @@ export const generate = async (alg: CoseSignatureAlgorithms | CoseDirectEncry ) => { return entry.Name === alg }) as any + if (alg === 'HPKE-Base-ML-KEM-768-SHA256-AES128GCM') { + const keys = ml_kem768.keygen(); + // { / COSE Key / + // 1: -666666, / ML-KEM Key Type / + // 3: -777777, / ML-KEM-768 Algorithm / + // -13: h'7803c0f9...3f6e2c70', / ML-KEM Private Key / + // -14: h'7803c0f9...3bba7abd', / ML-KEM Public Key / + // } + return new Map([ + [Key.Type, KeyType['ML-KEM']], + [Key.Algorithm, KeyTypeAlgorithms['ML-KEM']['ML-KEM-768']], + [KeyTypeParameters['ML-KEM'].Public, toArrayBuffer(keys.publicKey)], + [KeyTypeParameters['ML-KEM'].Secret, toArrayBuffer(keys.secretKey)] + ]) as T + } if (alg === 'HPKE-Base-P256-SHA256-AES128GCM') { knownAlgorithm = { Name: 'ECDH-ES+A128KW', diff --git a/src/cose/key/publicFromPrivate.ts b/src/cose/key/publicFromPrivate.ts index 3d397a8..b3b7d49 100644 --- a/src/cose/key/publicFromPrivate.ts +++ b/src/cose/key/publicFromPrivate.ts @@ -1,26 +1,38 @@ import { CoseKey } from "."; +import { Key, KeyType, KeyTypeParameters } from "../Params"; import { SecretKeyJwk } from "../sign1"; export const extracePublicKeyJwk = (secretKeyJwk: SecretKeyJwk) => { - if (secretKeyJwk.kty !== 'EC') { - throw new Error('Only EC keys are supported') + if (['EC'].includes(secretKeyJwk.kty || '')) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, key_ops, ...publicKeyJwk } = secretKeyJwk + return publicKeyJwk } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { d, p, q, dp, dq, qi, key_ops, ...publicKeyJwk } = secretKeyJwk - return publicKeyJwk + + throw new Error('Unsupported json web key type') + } export const extractPublicCoseKey = (secretKey: CoseKey) => { const publicCoseKeyMap = new Map(secretKey) - if (publicCoseKeyMap.get(1) !== 2) { - throw new Error('Only EC2 keys are supported') + if (publicCoseKeyMap.get(Key.Type) === KeyType.EC2) { + if (!publicCoseKeyMap.get(KeyTypeParameters['EC2'].Secret)) { + throw new Error('No secret component found for EC2 key, not a secret key.') + } + publicCoseKeyMap.delete(KeyTypeParameters['EC2'].Secret); + return publicCoseKeyMap } - if (!publicCoseKeyMap.get(-4)) { - throw new Error('privateKey is not a secret / private key (has no d / -4)') + + if (publicCoseKeyMap.get(Key.Type) === KeyType["ML-KEM"]) { + if (!publicCoseKeyMap.get(KeyTypeParameters['ML-KEM'].Secret)) { + throw new Error('No secret component found for ML-KEM key, not a secret key.') + } + publicCoseKeyMap.delete(KeyTypeParameters['ML-KEM'].Secret); + return publicCoseKeyMap } - publicCoseKeyMap.delete(-4); - return publicCoseKeyMap + + throw new Error('Unsupported cose key type') } export const publicFromPrivate = (secretKey: SecretKeyJwk | CoseKey) => { diff --git a/src/cose/key/thumbprint.ts b/src/cose/key/thumbprint.ts index 9a01c14..87f669c 100644 --- a/src/cose/key/thumbprint.ts +++ b/src/cose/key/thumbprint.ts @@ -4,10 +4,20 @@ import { encodeCanonical } from "../../cbor"; import subtleCryptoProvider from "../../crypto/subtleCryptoProvider"; +import { Key, KeyType, KeyTypeParameters } from '../Params' + // https://www.ietf.org/archive/id/draft-ietf-cose-key-thumbprint-01.html#section-6 const calculateCoseKeyThumbprint = async (coseKey: Map): Promise => { const onlyRequiredMap = new Map() - const requriedKeys = [1, -1, -2, -3] + const requriedKeys = [Key.Type] + if (coseKey.get(Key.Type) === KeyType.EC2) { + requriedKeys.push(KeyTypeParameters['EC2'].Curve) + requriedKeys.push(KeyTypeParameters['EC2'].PublicX) + requriedKeys.push(KeyTypeParameters['EC2'].PublicY) + } + if (coseKey.get(Key.Type) === KeyType["ML-KEM"]) { + requriedKeys.push(KeyTypeParameters['ML-KEM'].Public) + } for (const [key, value] of coseKey.entries()) { if (requriedKeys.includes(key as number)) { onlyRequiredMap.set(key, value) diff --git a/src/cose/requested-assignment.ts b/src/cose/requested-assignment.ts new file mode 100644 index 0000000..9b0f053 --- /dev/null +++ b/src/cose/requested-assignment.ts @@ -0,0 +1,9 @@ + +export const KeyTypes = { + [`ML-KEM`]: -666666 +} as Record + +export const Algorithms = { + [`ML-KEM-768`]: -777777, + ['HPKE-Base-ML-KEM-768-SHA256-AES128GCM']: -777777 +} as Record \ No newline at end of file diff --git a/test/post-quantum/README.md b/test/post-quantum/README.md new file mode 100644 index 0000000..356488c --- /dev/null +++ b/test/post-quantum/README.md @@ -0,0 +1,14 @@ +Experimental implementation of JOSE / COSE HPKE for ML-KEM-68 ... filled with bugs, not a real implementation. + +```cbor-diag +16([ + / protected / << + / algorithm / 1 : -777777 / HPKE-Base-ML-KEM-768-SHA256-AES128GCM / + >>, + / unprotected / { + / key identifier / 4: "urn:ietf:params:oauth:ckt:sha-256:QcJhXe4j82YETvLzXQ5pXDtin541byZup5l0WuSC820", + / encapsulated key / -4: h'f161ea5a094a55b21...6ae13e7e43613f' + }, + / ciphertext / h'f224bd528704969d0ad5...6d0d27121a67e808c' +]) +``` diff --git a/test/post-quantum/ml-kem-hpke.sanity.test.ts b/test/post-quantum/ml-kem-hpke.sanity.test.ts new file mode 100644 index 0000000..f2a2a12 --- /dev/null +++ b/test/post-quantum/ml-kem-hpke.sanity.test.ts @@ -0,0 +1,43 @@ + +import * as cose from '../../src' + +it('HPKE-Base-ML-KEM-768-SHA256-AES128GCM', async () => { + const secretKey: cose.key.CoseKey = await cose.key.generate("HPKE-Base-ML-KEM-768-SHA256-AES128GCM") + expect(secretKey.get(cose.Key.Type)).toBe(cose.KeyType['ML-KEM']) // requested assignment for key type ML-KEM + expect(secretKey.get(cose.Key.Algorithm)).toBe(cose.KeyTypeAlgorithms['ML-KEM']['HPKE-Base-ML-KEM-768-SHA256-AES128GCM']) // requested assignment for algorithm ML-KEM-768 + expect(secretKey.get(cose.KeyTypeParameters['ML-KEM'].Public)).toBeDefined() // public key parameter for key type ML-KEM + expect(secretKey.get(cose.KeyTypeParameters['ML-KEM'].Secret)).toBeDefined() // secret or private key parameter for key type ML-KEM + const publicKey = await cose.key.publicFromPrivate(secretKey) + expect(publicKey.get(cose.KeyTypeParameters['ML-KEM'].Secret)).toBeUndefined() // public keys have no secret component + + const message = "💀 My lungs taste the air of Time Blown past falling sands ⌛" + const plaintext = new TextEncoder().encode(message) + const encryptionKeys = { + keys: [ + await cose.key.convertCoseKeyToJsonWebKey(publicKey) + ] + } + const ciphertext = await cose.encrypt.direct({ + protectedHeader: cose.ProtectedHeader([ + [cose.Protected.Alg, cose.Direct['HPKE-Base-ML-KEM-768-SHA256-AES128GCM']], + ]), + unprotectedHeader: cose.UnprotectedHeader([]), + plaintext, + recipients: encryptionKeys + }) + + // const ctDiag = await cose.cbor.diagnose(ciphertext) + // console.log(ctDiag) + + const decryptionKeys = { + keys: [ + await cose.key.convertCoseKeyToJsonWebKey(secretKey) + ] + } + const recoveredPlaintext = await cose.decrypt.direct({ + ciphertext, + recipients: decryptionKeys + }) + expect(new TextDecoder().decode(recoveredPlaintext)).toBe(message) + +}) diff --git a/test/post-quantum/sanity.test.ts b/test/post-quantum/sanity.test.ts new file mode 100644 index 0000000..b1bcaa8 --- /dev/null +++ b/test/post-quantum/sanity.test.ts @@ -0,0 +1,21 @@ + +import { ml_kem512, ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem'; +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa'; + + +it('ml_kem768', () => { + const aliceKeys = ml_kem768.keygen(); + const alicePub = aliceKeys.publicKey; + const { cipherText, sharedSecret: bobShared } = ml_kem768.encapsulate(alicePub); + const aliceShared = ml_kem768.decapsulate(cipherText, aliceKeys.secretKey); + expect(aliceShared).toBeDefined() +}) + +it('ml_dsa65', () => { + const seed = new TextEncoder().encode('not a safe seed') + const aliceKeys = ml_dsa65.keygen(seed); + const msg = new Uint8Array(1); + const sig = ml_dsa65.sign(aliceKeys.secretKey, msg); + const isValid = ml_dsa65.verify(aliceKeys.publicKey, msg, sig); + expect(isValid).toBe(true) +}) \ No newline at end of file