diff --git a/cookbook/jwe.mjs b/cookbook/jwe.mjs index 50279b47e8..4119ed244f 100644 --- a/cookbook/jwe.mjs +++ b/cookbook/jwe.mjs @@ -668,4 +668,44 @@ export default [ }, }, }, + { + title: + 'https://datatracker.ietf.org/doc/draft-ietf-jose-hpke-encrypt/ - HPKE Integrated Encryption', + deterministic: false, + input: { + plaintext: + 'You can trust us to stick with you through thick and thin–to the bitter end. And you can trust us to keep any secret of yours–closer than you keep it yourself. But you cannot trust us to let you face trouble alone, and go off without a word. We are your friends, Frodo.', + key: { + kty: 'EC', + use: 'enc', + alg: 'HPKE-0', + kid: 'yCnfbmYMZcWrKDt_DjNebRCB1vxVoqv4umJ4WK8RYjk', + crv: 'P-256', + x: 'gixQJ0qg4Ag-6HSMaIEDL_zbDhoXavMyKlmdn__AQVE', + y: 'ZxTgRLWaKONCL_GbZKLNPsW9EW6nBsN4AwQGEFAFFbM', + d: 'g2DXtKapi2oN2zL_RCWX8D4bWURHCKN2-ZNGC05ZaR8', + }, + alg: 'HPKE-0', + aad: 'The Fellowship of the Ring', + }, + encrypting_content: { + protected: { + alg: 'HPKE-0', + kid: 'yCnfbmYMZcWrKDt_DjNebRCB1vxVoqv4umJ4WK8RYjk', + }, + }, + output: { + compact: + 'eyJhbGciOiJIUEtFLTAiLCJraWQiOiJ5Q25mYm1ZTVpjV3JLRHRfRGpOZWJSQ0IxdnhWb3F2NHVtSjRXSzhSWWprIn0.BLAJX8adrFsDKaoJAc3iy2dq-6jEH3Uv-bSgqIoDeREqpWglMoTS67XsXere1ZYxiQKEFU6MbWe8O7vmdlSmcUk..NcN9ew5aijn8W7piLVRU8r2cOP0JKqxOF4RllVsJM4qsAfVXW5Ka6so9zdUmXXNOXyCEk0wV_s8ICAnD4LbRa5TkhTeuhijIfAt9bQ2fMLOeyed3WyArs8yaMraa9Zbh4i6SaHunM7jU_xoz_N2WbykSOSySmCO49H4mP3jLW9L_TYQfeVfYsrB8clqokZ8h-3eQGNwmOPtkjWdpAfaHUsp4-HC9nRd6yrTU6mV65Nn2iYynu3Xkgy2Lm-kQKDavIEW3PBpEeiw6mtPJE9o8sT-0lZ9kpWtqog2XbNGEfjSOjujvNe1b0g4-FdNFMFO_fo0rxe902W1pGT7znv4Q-xBkIydK4ZwjiFN6dAXutnococ37A0Hr5esPLwHRTTrBFw.', + json_flat: { + ciphertext: + 'LabI8_KIPDbymUSbyVctj8AfISXQ07sMt1xQ1lrS-0heU2jjejpQIK75K1KXcvwn15E6Kil_tJ6LBcYCu02O1H8_aooJGuoLw1vEzQn16h498YX9e2SA2IcVrJTkcCjL7YpF9fsAF3JEzGfsmmrpZPPVdxCn7g8dkGRcyulnHrNvBu4BFtub-URtf-nYCFIJHZ4k-ul9fDddquicFzCxQonx66-ZX5nbj6azHG65tAZntd6VFkRgihdxTvIpvTS4gfulQeKyShbiw-OCJNbzFdEnOKEMnsyqRjwG7iVrFEilFAMsvLJ14-lcuR5btIkUntIwlnsfUa2Ytk33znCfAFN0wYukdDvJe-V0nnNUFlOeLyYV0eEGisgC9dQQ1kFu3g', + encrypted_key: + 'BAOlZ-VnbhQu4NOlTlDAVYwUJB-Q6YcWwnRNWK6YLSiHHlW4rN0qUzBJ3Rc2_y8nkasn8nUVGBzdq7OhdKKiLq4', + aad: 'VGhlIEZlbGxvd3NoaXAgb2YgdGhlIFJpbmc', + protected: + 'eyJhbGciOiJIUEtFLTAiLCJraWQiOiJ5Q25mYm1ZTVpjV3JLRHRfRGpOZWJSQ0IxdnhWb3F2NHVtSjRXSzhSWWprIn0', + }, + }, + }, ] diff --git a/docs/jwt/decrypt/interfaces/JWTDecryptOptions.md b/docs/jwt/decrypt/interfaces/JWTDecryptOptions.md index 9b662099d6..8ace7a72b1 100644 --- a/docs/jwt/decrypt/interfaces/JWTDecryptOptions.md +++ b/docs/jwt/decrypt/interfaces/JWTDecryptOptions.md @@ -133,6 +133,14 @@ This option makes the JWT "iat" (Issued At) Claim presence required. *** +### psk? + +• `optional` **psk**: [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) + +HPKE Pre-Shared Key (PSK) for use in PSK mode. + +*** + ### requiredClaims? • `optional` **requiredClaims?**: `string`[] diff --git a/docs/types/interfaces/CompactJWEHeaderParameters.md b/docs/types/interfaces/CompactJWEHeaderParameters.md index c5e2cf5eb0..3343827649 100644 --- a/docs/types/interfaces/CompactJWEHeaderParameters.md +++ b/docs/types/interfaces/CompactJWEHeaderParameters.md @@ -26,18 +26,6 @@ JWE "alg" (Algorithm) Header Parameter *** -### enc - -• **enc**: `string` - -JWE "enc" (Encryption Algorithm) Header Parameter - -#### See - -[Algorithm Key Requirements](https://github.com/panva/jose/issues/210#jwe-alg) - -*** - ### crit? • `optional` **crit?**: `string`[] @@ -54,6 +42,18 @@ JWE "crit" (Critical) Header Parameter *** +### enc? + +• `optional` **enc**: `string` + +JWE "enc" (Encryption Algorithm) Header Parameter + +#### See + +[Algorithm Key Requirements](https://github.com/panva/jose/issues/210#jwe-alg) + +*** + ### jku? • `optional` **jku?**: `string` @@ -78,6 +78,14 @@ JWE "crit" (Critical) Header Parameter *** +### psk\_id? + +• `optional` **psk\_id**: `string` + +HPKE Pre-Shared Key Identifier (PSK ID) for use in PSK mode. + +*** + ### typ? • `optional` **typ?**: `string` diff --git a/docs/types/interfaces/DecryptOptions.md b/docs/types/interfaces/DecryptOptions.md index 035d51020d..25fe426a7e 100644 --- a/docs/types/interfaces/DecryptOptions.md +++ b/docs/types/interfaces/DecryptOptions.md @@ -75,3 +75,11 @@ Set to `Infinity` to disable the decompressed size limit. (PBES2 Key Management Algorithms only) Maximum allowed "p2c" (PBES2 Count) Header Parameter value. The PBKDF2 iteration count defines the algorithm's computational expense. By default this value is set to 10000. + +*** + +### psk? + +• `optional` **psk**: [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) + +HPKE Pre-Shared Key (PSK) for use in PSK mode. diff --git a/docs/types/interfaces/JWEHeaderParameters.md b/docs/types/interfaces/JWEHeaderParameters.md index 1b16dd110a..617d0f889f 100644 --- a/docs/types/interfaces/JWEHeaderParameters.md +++ b/docs/types/interfaces/JWEHeaderParameters.md @@ -78,6 +78,14 @@ JWE "enc" (Encryption Algorithm) Header Parameter *** +### psk\_id? + +• `optional` **psk\_id**: `string` + +HPKE Pre-Shared Key Identifier (PSK ID) for use in PSK mode. + +*** + ### typ? • `optional` **typ?**: `string` diff --git a/docs/types/interfaces/JWEKeyManagementHeaderParameters.md b/docs/types/interfaces/JWEKeyManagementHeaderParameters.md index 9592621c09..74313d4c6e 100644 --- a/docs/types/interfaces/JWEKeyManagementHeaderParameters.md +++ b/docs/types/interfaces/JWEKeyManagementHeaderParameters.md @@ -67,3 +67,19 @@ You should not use this parameter. It is only intended for testing and vector You should not use this parameter. It is only intended for testing and vector validation purposes. + +*** + +### psk? + +• `optional` **psk**: [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) + +HPKE Pre-Shared Key (PSK) for use in PSK mode. + +*** + +### psk\_id? + +• `optional` **psk\_id**: [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) + +HPKE Pre-Shared Key Identifier (PSK ID) for use in PSK mode. diff --git a/package-lock.json b/package-lock.json index e0d86ed15c..4a11fb56ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "ava": "^7.0.0", "esbuild": "^0.27.4", "glob": "^13.0.6", + "hpke": "^1.0.6", "npm-run-all2": "^8.0.4", "patch-package": "^8.0.1", "prettier": "^3.8.1", @@ -2367,6 +2368,16 @@ "node": ">= 0.4" } }, + "node_modules/hpke": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/hpke/-/hpke-1.0.6.tgz", + "integrity": "sha512-2T/TYYmlFRPWaBv6lTA/jGp/Y70aupUjDpvEn15z5iB0WiJLW4MyDsuUBXoCrCTW02xy4r8AiuuuAKNfD6ahsg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", diff --git a/package.json b/package.json index da4ff1c5e3..c2a9eb6087 100644 --- a/package.json +++ b/package.json @@ -236,6 +236,7 @@ "ava": "^7.0.0", "esbuild": "^0.27.4", "glob": "^13.0.6", + "hpke": "^1.0.6", "npm-run-all2": "^8.0.4", "patch-package": "^8.0.1", "prettier": "^3.8.1", diff --git a/src/jwe/flattened/decrypt.ts b/src/jwe/flattened/decrypt.ts index 25fddebd19..71c69bca2f 100644 --- a/src/jwe/flattened/decrypt.ts +++ b/src/jwe/flattened/decrypt.ts @@ -6,19 +6,23 @@ import type * as types from '../../types.d.ts' import { decode as b64u } from '../../util/base64url.js' -import { decrypt } from '../../lib/content_encryption.js' +import { decrypt, generateCek } from '../../lib/content_encryption.js' import { decodeBase64url } from '../../lib/helpers.js' -import { JOSEAlgNotAllowed, JOSENotSupported, JWEInvalid } from '../../util/errors.js' -import { isDisjoint } from '../../lib/type_checks.js' -import { isObject } from '../../lib/type_checks.js' +import { + JOSEAlgNotAllowed, + JOSENotSupported, + JWEDecryptionFailed, + JWEInvalid, +} from '../../util/errors.js' +import { isDisjoint, isObject } from '../../lib/type_checks.js' import { decryptKeyManagement } from '../../lib/key_management.js' import { decoder, concat, encode } from '../../lib/buffer_utils.js' -import { generateCek } from '../../lib/content_encryption.js' import { validateCrit } from '../../lib/validate_crit.js' import { validateAlgorithms } from '../../lib/validate_algorithms.js' import { normalizeKey } from '../../lib/normalize_key.js' import { checkKeyType } from '../../lib/check_key_type.js' import { decompress } from '../../lib/deflate.js' +import { isIntegratedEncryption, Open as hpkeOpen } from '../../lib/hpke.js' /** * Interface for Flattened JWE Decryption dynamic key resolution. No token components have been @@ -164,7 +168,9 @@ export async function flattenedDecrypt( throw new JWEInvalid('missing JWE Algorithm (alg) in JWE Header') } - if (typeof enc !== 'string' || !enc) { + const isHPKE = isIntegratedEncryption(alg) + + if (!isHPKE && (typeof enc !== 'string' || !enc)) { throw new JWEInvalid('missing JWE Encryption Algorithm (enc) in JWE Header') } @@ -181,7 +187,7 @@ export async function flattenedDecrypt( throw new JOSEAlgNotAllowed('"alg" (Algorithm) Header Parameter value not allowed') } - if (contentEncryptionAlgorithms && !contentEncryptionAlgorithms.has(enc)) { + if (!isHPKE && contentEncryptionAlgorithms && !contentEncryptionAlgorithms.has(enc!)) { throw new JOSEAlgNotAllowed('"enc" (Encryption Algorithm) Header Parameter value not allowed') } @@ -195,49 +201,123 @@ export async function flattenedDecrypt( key = await key(parsedProt, jwe) resolvedKey = true } - checkKeyType(alg === 'dir' ? enc : alg, key, 'decrypt') + checkKeyType(alg === 'dir' ? enc! : alg, key, 'decrypt') - const k = await normalizeKey(key, alg) - let cek: types.CryptoKey | Uint8Array - try { - cek = await decryptKeyManagement(alg, k, encryptedKey, joseHeader, options) - } catch (err) { - if (err instanceof TypeError || err instanceof JWEInvalid || err instanceof JOSENotSupported) { - throw err + if (isHPKE) { + if (enc !== undefined) { + throw new JWEInvalid( + 'JWE "enc" (Encryption Algorithm) Header Parameter must not be present for Integrated Encryption algorithms', + ) } - // https://www.rfc-editor.org/rfc/rfc7516#section-11.5 - // To mitigate the attacks described in RFC 3218, the - // recipient MUST NOT distinguish between format, padding, and length - // errors of encrypted keys. It is strongly recommended, in the event - // of receiving an improperly formatted key, that the recipient - // substitute a randomly generated CEK and proceed to the next step, to - // mitigate timing attacks. - cek = generateCek(enc) - } - let iv: Uint8Array | undefined - let tag: Uint8Array | undefined - if (jwe.iv !== undefined) { - iv = decodeBase64url(jwe.iv, 'iv', JWEInvalid) - } - if (jwe.tag !== undefined) { - tag = decodeBase64url(jwe.tag, 'tag', JWEInvalid) + if (jwe.iv !== undefined) { + throw new JWEInvalid( + 'JWE Initialization Vector must not be present for Integrated Encryption algorithms', + ) + } + + if (jwe.tag !== undefined) { + throw new JWEInvalid( + 'JWE Authentication Tag must not be present for Integrated Encryption algorithms', + ) + } + + if (!encryptedKey) { + throw new JWEInvalid('JWE Encrypted Key missing') + } } - const protectedHeader: Uint8Array = + const normalizedKey = await normalizeKey(key, alg) + + const protectedHeaderBytes: Uint8Array = jwe.protected !== undefined ? encode(jwe.protected) : new Uint8Array() let additionalData: Uint8Array - if (jwe.aad !== undefined) { - additionalData = concat(protectedHeader, encode('.'), encode(jwe.aad)) + additionalData = concat(protectedHeaderBytes, encode('.'), encode(jwe.aad)) } else { - additionalData = protectedHeader + additionalData = protectedHeaderBytes + } + + let ciphertext: Uint8Array + try { + ciphertext = b64u(jwe.ciphertext) + } catch { + throw new JWEInvalid('Failed to base64url decode the ciphertext') } - const ciphertext = decodeBase64url(jwe.ciphertext, 'ciphertext', JWEInvalid) - const plaintext = await decrypt(enc, cek, ciphertext, iv, tag, additionalData) + let plaintext: Uint8Array + + if (isHPKE) { + let pskId: Uint8Array | undefined + if (joseHeader.psk_id !== undefined) { + if (!parsedProt?.psk_id === undefined) { + throw new JWEInvalid( + 'JWE "psk_id" (Pre-Shared Key ID) Header Parameter MUST be integrity protected', + ) + } + try { + pskId = b64u(joseHeader.psk_id as string) + } catch { + throw new JWEInvalid('Failed to base64url decode the psk_id') + } + } - const result: types.FlattenedDecryptResult = { plaintext } + try { + plaintext = await hpkeOpen( + alg, + encryptedKey, + normalizedKey as CryptoKey, + new Uint8Array(), + additionalData, + ciphertext, + options?.psk, + pskId, + ) + } catch (err) { + if (err instanceof JOSENotSupported) throw err + throw new JWEDecryptionFailed() + } + } else { + let cek: types.CryptoKey | Uint8Array + try { + cek = await decryptKeyManagement(alg, normalizedKey, encryptedKey, joseHeader, options) + } catch (err) { + if ( + err instanceof TypeError || + err instanceof JWEInvalid || + err instanceof JOSENotSupported + ) { + throw err + } + // https://www.rfc-editor.org/rfc/rfc7516#section-11.5 + // To mitigate the attacks described in RFC 3218, the + // recipient MUST NOT distinguish between format, padding, and length + // errors of encrypted keys. It is strongly recommended, in the event + // of receiving an improperly formatted key, that the recipient + // substitute a randomly generated CEK and proceed to the next step, to + // mitigate timing attacks. + cek = generateCek(enc!) + } + + let iv: Uint8Array | undefined + let tag: Uint8Array | undefined + if (jwe.iv !== undefined) { + try { + iv = b64u(jwe.iv) + } catch { + throw new JWEInvalid('Failed to base64url decode the iv') + } + } + if (jwe.tag !== undefined) { + try { + tag = b64u(jwe.tag) + } catch { + throw new JWEInvalid('Failed to base64url decode the tag') + } + } + + plaintext = await decrypt(enc!, cek, ciphertext, iv, tag, additionalData) + } if (joseHeader.zip === 'DEF') { const maxDecompressedLength = options?.maxDecompressedLength ?? 250_000 @@ -252,12 +332,14 @@ export async function flattenedDecrypt( ) { throw new TypeError('maxDecompressedLength must be 0, a positive safe integer, or Infinity') } - result.plaintext = await decompress(plaintext, maxDecompressedLength).catch((cause) => { + plaintext = await decompress(plaintext, maxDecompressedLength).catch((cause) => { if (cause instanceof JWEInvalid) throw cause throw new JWEInvalid('Failed to decompress plaintext', { cause }) }) } + const result: types.FlattenedDecryptResult = { plaintext } + if (jwe.protected !== undefined) { result.protectedHeader = parsedProt } @@ -275,7 +357,7 @@ export async function flattenedDecrypt( } if (resolvedKey) { - return { ...result, key: k } + return { ...result, key: normalizedKey } } return result diff --git a/src/jwe/flattened/encrypt.ts b/src/jwe/flattened/encrypt.ts index 7fcbb81eed..8dfb0b758c 100644 --- a/src/jwe/flattened/encrypt.ts +++ b/src/jwe/flattened/encrypt.ts @@ -16,6 +16,7 @@ import { validateCrit } from '../../lib/validate_crit.js' import { normalizeKey } from '../../lib/normalize_key.js' import { checkKeyType } from '../../lib/check_key_type.js' import { compress } from '../../lib/deflate.js' +import { isIntegratedEncryption, Seal as hpkeSeal } from '../../lib/hpke.js' /** * The FlattenedEncrypt class is used to build and encrypt Flattened JWE objects. @@ -203,67 +204,104 @@ export class FlattenedEncrypt { throw new JWEInvalid('JWE "alg" (Algorithm) Header Parameter missing or invalid') } - if (typeof enc !== 'string' || !enc) { - throw new JWEInvalid('JWE "enc" (Encryption Algorithm) Header Parameter missing or invalid') - } + const isHPKE = isIntegratedEncryption(alg) let encryptedKey: Uint8Array | undefined + let cek: types.CryptoKey | Uint8Array | undefined + let hpkeKey: CryptoKey | undefined + + if (isHPKE) { + if (enc !== undefined) { + throw new JWEInvalid( + 'JWE "enc" (Encryption Algorithm) Header Parameter must not be present for Integrated Encryption algorithms', + ) + } - if (this.#cek && (alg === 'dir' || alg === 'ECDH-ES')) { - throw new TypeError( - `setContentEncryptionKey cannot be called with JWE "alg" (Algorithm) Header ${alg}`, - ) - } + if (this.#cek) { + throw new TypeError( + `setContentEncryptionKey cannot be called with JWE "alg" (Algorithm) Header ${alg}`, + ) + } - checkKeyType(alg === 'dir' ? enc : alg, key, 'encrypt') + if (this.#iv) { + throw new TypeError( + `setInitializationVector cannot be called with JWE "alg" (Algorithm) Header ${alg}`, + ) + } - let cek: types.CryptoKey | Uint8Array - { - let parameters: { [propName: string]: unknown } | undefined - const k = await normalizeKey(key, alg) - ;({ cek, encryptedKey, parameters } = await encryptKeyManagement( - alg, - enc, - k, - this.#cek, - this.#keyManagementParameters, - )) - - if (parameters) { - if (options && unprotected in options) { - if (!this.#unprotectedHeader) { - this.setUnprotectedHeader(parameters) + if (this.#protectedHeader?.alg !== alg) { + throw new JWEInvalid( + 'JWE "alg" (Algorithm) Header Parameter MUST be integrity protected for Integrated Encryption algorithms', + ) + } + + checkKeyType(alg, key, 'encrypt') + hpkeKey = (await normalizeKey(key, alg)) as CryptoKey + + if (this.#keyManagementParameters?.psk_id) { + this.#protectedHeader!.psk_id = b64u(this.#keyManagementParameters.psk_id) + } + } else { + if (typeof enc !== 'string' || !enc) { + throw new JWEInvalid('JWE "enc" (Encryption Algorithm) Header Parameter missing or invalid') + } + + if (this.#cek && (alg === 'dir' || alg === 'ECDH-ES')) { + throw new TypeError( + `setContentEncryptionKey cannot be called with JWE "alg" (Algorithm) Header ${alg}`, + ) + } + + checkKeyType(alg === 'dir' ? enc : alg, key, 'encrypt') + + { + let parameters: { [propName: string]: unknown } | undefined + const k = await normalizeKey(key, alg) + ;({ cek, encryptedKey, parameters } = await encryptKeyManagement( + alg, + enc, + k, + this.#cek, + this.#keyManagementParameters, + )) + + if (parameters) { + if (options && unprotected in options) { + if (!this.#unprotectedHeader) { + this.setUnprotectedHeader(parameters) + } else { + this.#unprotectedHeader = { ...this.#unprotectedHeader, ...parameters } + } + } else if (!this.#protectedHeader) { + this.setProtectedHeader(parameters) } else { - this.#unprotectedHeader = { ...this.#unprotectedHeader, ...parameters } + this.#protectedHeader = { ...this.#protectedHeader, ...parameters } } - } else if (!this.#protectedHeader) { - this.setProtectedHeader(parameters) - } else { - this.#protectedHeader = { ...this.#protectedHeader, ...parameters } } } } - let additionalData: Uint8Array - let protectedHeaderS: string - let protectedHeaderB: Uint8Array + let protectedHeaderS = '' + let protectedHeaderB: Uint8Array = new Uint8Array() let aadMember: string | undefined + if (this.#protectedHeader) { protectedHeaderS = b64u(JSON.stringify(this.#protectedHeader)) protectedHeaderB = encode(protectedHeaderS) - } else { - protectedHeaderS = '' - protectedHeaderB = new Uint8Array() } + let additionalData: Uint8Array if (this.#aad) { aadMember = b64u(this.#aad) - const aadMemberBytes = encode(aadMember) - additionalData = concat(protectedHeaderB, encode('.'), aadMemberBytes) + additionalData = concat(protectedHeaderB, encode('.'), encode(aadMember)) } else { additionalData = protectedHeaderB } + let ciphertextBytes: Uint8Array + let iv: Uint8Array | undefined + let tag: Uint8Array | undefined + let plaintext = this.#plaintext if (joseHeader.zip === 'DEF') { plaintext = await compress(plaintext).catch((cause) => { @@ -271,10 +309,28 @@ export class FlattenedEncrypt { }) } - const { ciphertext, tag, iv } = await encrypt(enc, plaintext, cek, this.#iv, additionalData) + if (isHPKE) { + const sealed = await hpkeSeal( + alg, + hpkeKey!, + new Uint8Array(), + additionalData, + plaintext, + this.#keyManagementParameters?.psk, + this.#keyManagementParameters?.psk_id, + ) + ciphertextBytes = sealed.ct + encryptedKey = sealed.enc + } else { + ;({ + ciphertext: ciphertextBytes, + tag, + iv, + } = await encrypt(enc!, plaintext, cek!, this.#iv, additionalData)) + } const jwe: types.FlattenedJWE = { - ciphertext: b64u(ciphertext), + ciphertext: b64u(ciphertextBytes), } if (iv) { @@ -293,7 +349,7 @@ export class FlattenedEncrypt { jwe.aad = aadMember } - if (this.#protectedHeader) { + if (protectedHeaderS) { jwe.protected = protectedHeaderS } diff --git a/src/jwe/general/encrypt.ts b/src/jwe/general/encrypt.ts index f5a2d5cb66..b2545ed7e6 100644 --- a/src/jwe/general/encrypt.ts +++ b/src/jwe/general/encrypt.ts @@ -15,6 +15,7 @@ import { encode as b64u } from '../../util/base64url.js' import { validateCrit } from '../../lib/validate_crit.js' import { normalizeKey } from '../../lib/normalize_key.js' import { checkKeyType } from '../../lib/check_key_type.js' +import { isIntegratedEncryption } from '../../lib/hpke.js' /** Used to build General JWE object's individual recipients. */ export interface Recipient { @@ -232,8 +233,18 @@ export class GeneralEncrypt { throw new JWEInvalid('JWE "alg" (Algorithm) Header Parameter missing or invalid') } - if (alg === 'dir' || alg === 'ECDH-ES') { - throw new JWEInvalid('"dir" and "ECDH-ES" alg may only be used with a single recipient') + if (alg === 'dir') { + throw new JWEInvalid('"dir" alg may only be used with a single recipient') + } + + if (alg === 'ECDH-ES') { + throw new JWEInvalid('"ECDH-ES" alg may only be used with a single recipient') + } + + if (isIntegratedEncryption(alg)) { + throw new JWEInvalid( + 'HPKE Integrated Encryption alg may only be used with a single recipient', + ) } if (typeof joseHeader.enc !== 'string' || !joseHeader.enc) { diff --git a/src/key/generate_key_pair.ts b/src/key/generate_key_pair.ts index eb730cdd0a..aa7cce0500 100644 --- a/src/key/generate_key_pair.ts +++ b/src/key/generate_key_pair.ts @@ -170,6 +170,18 @@ export async function generateKeyPair( } break } + case 'HPKE-0': + case 'HPKE-7': { + keyUsages = ['deriveBits'] + algorithm = { name: 'ECDH', namedCurve: 'P-256' } + break + } + case 'HPKE-3': + case 'HPKE-4': { + keyUsages = ['deriveBits'] + algorithm = { name: 'X25519' } + break + } default: throw new JOSENotSupported('Invalid or unsupported JWK "alg" (Algorithm) Parameter value') } diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 9ccf3a6a77..840db8acb8 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -255,6 +255,18 @@ const genericImport = async ( keyUsages = isPublic ? [] : ['deriveBits'] break } + case 'HPKE-0': + case 'HPKE-7': { + algorithm = { name: 'ECDH', namedCurve: 'P-256' } + keyUsages = isPublic ? [] : ['deriveBits'] + break + } + case 'HPKE-3': + case 'HPKE-4': { + algorithm = { name: 'X25519' } + keyUsages = isPublic ? [] : ['deriveBits'] + break + } case 'Ed25519': case 'EdDSA': algorithm = { name: 'Ed25519' } diff --git a/src/lib/content_encryption.ts b/src/lib/content_encryption.ts index a1a6276688..06007200c7 100644 --- a/src/lib/content_encryption.ts +++ b/src/lib/content_encryption.ts @@ -207,6 +207,48 @@ async function cbcDecrypt( return plaintext } +// --- AEAD encrypt/decrypt --- + +export async function aeadEncrypt( + algorithm: string, + key: CryptoKey, + iv: Uint8Array, + aad: Uint8Array, + plaintext: Uint8Array, +) { + return new Uint8Array( + await crypto.subtle.encrypt( + { + name: algorithm, + iv: iv as Uint8Array, + additionalData: aad as Uint8Array, + }, + key, + plaintext as Uint8Array, + ), + ) +} + +export async function aeadDecrypt( + algorithm: string, + key: CryptoKey, + iv: Uint8Array, + aad: Uint8Array, + ciphertext: Uint8Array, +) { + return new Uint8Array( + await crypto.subtle.decrypt( + { + name: algorithm, + iv: iv as Uint8Array, + additionalData: aad as Uint8Array, + }, + key, + ciphertext as Uint8Array, + ), + ) +} + // --- GCM encrypt/decrypt --- async function gcmEncrypt( @@ -230,18 +272,7 @@ async function gcmEncrypt( encKey = cek } - const encrypted = new Uint8Array( - await crypto.subtle.encrypt( - { - additionalData: aad as Uint8Array, - iv: iv as Uint8Array, - name: 'AES-GCM', - tagLength: 128, - }, - encKey, - plaintext as Uint8Array, - ), - ) + const encrypted = await aeadEncrypt('AES-GCM', encKey, iv, aad, plaintext) const tag = encrypted.slice(-16) const ciphertext = encrypted.slice(0, -16) @@ -272,18 +303,7 @@ async function gcmDecrypt( } try { - return new Uint8Array( - await crypto.subtle.decrypt( - { - additionalData: aad as Uint8Array, - iv: iv as Uint8Array, - name: 'AES-GCM', - tagLength: 128, - }, - encKey, - concat(ciphertext, tag) as Uint8Array, - ), - ) + return await aeadDecrypt('AES-GCM', encKey, iv, aad, concat(ciphertext, tag)) } catch { throw new JWEDecryptionFailed() } diff --git a/src/lib/ecdhes.ts b/src/lib/ecdhes.ts index b72ab9a3f1..4a5ab1ab4c 100644 --- a/src/lib/ecdhes.ts +++ b/src/lib/ecdhes.ts @@ -73,7 +73,14 @@ export async function deriveKey( const otherInfo = concat(algorithmID, partyUInfo, partyVInfo, suppPubInfo, suppPrivInfo) // Perform ECDH to get the shared secret Z - const Z = new Uint8Array( + const Z = await ecdhDeriveBits(publicKey, privateKey) + + // Apply Concat KDF to derive the final key material + return concatKdf(Z, keyLength, otherInfo) +} + +export async function ecdhDeriveBits(publicKey: CryptoKey, privateKey: CryptoKey) { + return new Uint8Array( await crypto.subtle.deriveBits( { name: publicKey.algorithm.name, @@ -83,12 +90,9 @@ export async function deriveKey( getEcdhBitLength(publicKey), ), ) - - // Apply Concat KDF to derive the final key material - return concatKdf(Z, keyLength, otherInfo) } -function getEcdhBitLength(publicKey: CryptoKey) { +export function getEcdhBitLength(publicKey: CryptoKey) { if (publicKey.algorithm.name === 'X25519') { return 256 } diff --git a/src/lib/hpke.ts b/src/lib/hpke.ts new file mode 100644 index 0000000000..58d610b84a --- /dev/null +++ b/src/lib/hpke.ts @@ -0,0 +1,363 @@ +import { concat, encode } from './buffer_utils.js' +import { ecdhDeriveBits } from './ecdhes.js' +import { aeadEncrypt, aeadDecrypt } from './content_encryption.js' +import { JOSENotSupported } from '../util/errors.js' + +// HPKE Modes +const MODE_BASE = 0x00 +const MODE_PSK = 0x01 + +// I2OSP: Integer to Octet String Primitive +function I2OSP(n: number, w: number): Uint8Array { + const ret = new Uint8Array(w) + let num = n + for (let i = w - 1; i >= 0; i--) { + ret[i] = num & 0xff + num >>>= 8 + } + return ret +} + +interface Suite { + kem: { + id: number + suite_id: Uint8Array + algorithm: EcKeyGenParams | KeyAlgorithm + Nsecret: number + Nenc: number + Npk: number + Ndh: number + } + aead: { + id: number + algorithm: string + keyFormat: string + Nk: number + Nn: number + } + id: Uint8Array +} + +const KDF_HASH = 'SHA-256' +const KDF_NH = 32 +const KDF_ID = 0x0001 + +function defineSuite( + kemId: number, + kemAlg: EcKeyGenParams | KeyAlgorithm, + kemNsecret: number, + kemNenc: number, + kemNpk: number, + kemNdh: number, + aeadId: number, + aeadAlg: string, + aeadKeyFormat: string, + aeadNk: number, + aeadNn: number, +): Suite { + return { + kem: { + id: kemId, + suite_id: concat(encode('KEM'), I2OSP(kemId, 2)), + algorithm: kemAlg, + Nsecret: kemNsecret, + Nenc: kemNenc, + Npk: kemNpk, + Ndh: kemNdh, + }, + aead: { + id: aeadId, + algorithm: aeadAlg, + keyFormat: aeadKeyFormat, + Nk: aeadNk, + Nn: aeadNn, + }, + id: concat(encode('HPKE'), I2OSP(kemId, 2), I2OSP(KDF_ID, 2), I2OSP(aeadId, 2)), + } +} + +let suites: Record + +function getSuite(alg: string): Suite { + suites ??= Object.create(null) + if (alg in suites) return suites[alg] + + switch (alg) { + case 'HPKE-0': + case 'HPKE-7': + // DHKEM(P-256, HKDF-SHA256) + HKDF-SHA256 + AES-GCM + suites[alg] = defineSuite( + 0x0010, + { name: 'ECDH', namedCurve: 'P-256' }, + 32, + 65, + 65, + 32, + alg === 'HPKE-0' ? 0x0001 : 0x0002, + 'AES-GCM', + 'raw', + alg === 'HPKE-0' ? 16 : 32, + 12, + ) + return suites[alg] + case 'HPKE-3': + // DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + AES-GCM-128 + suites[alg] = defineSuite( + 0x0020, + { name: 'X25519' }, + 32, + 32, + 32, + 32, + 0x0001, + 'AES-GCM', + 'raw', + 16, + 12, + ) + return suites[alg] + case 'HPKE-4': + // DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + ChaCha20Poly1305 + suites[alg] = defineSuite( + 0x0020, + { name: 'X25519' }, + 32, + 32, + 32, + 32, + 0x0003, + 'ChaCha20-Poly1305', + 'raw-secret', + 32, + 12, + ) + return suites[alg] + default: + throw new JOSENotSupported('Unsupported HPKE algorithm') + } +} + +// HKDF Extract +async function Extract(salt: Uint8Array, ikm: Uint8Array) { + const saltBuf: ArrayBuffer = + salt.byteLength === 0 ? new ArrayBuffer(KDF_NH) : (salt.buffer as ArrayBuffer) + return new Uint8Array( + await crypto.subtle.sign( + 'HMAC', + await crypto.subtle.importKey('raw', saltBuf, { name: 'HMAC', hash: KDF_HASH }, false, [ + 'sign', + ]), + ikm as Uint8Array, + ), + ) +} + +// HKDF Expand +async function Expand(prk: Uint8Array, info: Uint8Array, L: number) { + const N = Math.ceil(L / KDF_NH) + const key = await crypto.subtle.importKey( + 'raw', + prk as Uint8Array, + { name: 'HMAC', hash: KDF_HASH }, + false, + ['sign'], + ) + const T = new Uint8Array(N * KDF_NH) + let prev = new Uint8Array() + for (let i = 0; i < N; i++) { + const input = new Uint8Array(prev.byteLength + info.byteLength + 1) + input.set(prev) + input.set(info, prev.byteLength) + input[prev.byteLength + info.byteLength] = i + 1 + const ti = new Uint8Array(await crypto.subtle.sign('HMAC', key, input)) + T.set(ti, i * KDF_NH) + prev = ti + } + return T.slice(0, L) +} + +const HPKE_V1 = encode('HPKE-v1') + +function LabeledExtract( + suite_id: Uint8Array, + salt: Uint8Array, + label: Uint8Array, + ikm: Uint8Array, +) { + return Extract(salt, concat(HPKE_V1, suite_id, label, ikm)) +} + +function LabeledExpand( + suite_id: Uint8Array, + prk: Uint8Array, + label: Uint8Array, + info: Uint8Array, + L: number, +) { + return Expand(prk, concat(I2OSP(L, 2), HPKE_V1, suite_id, label, info), L) +} + +// DHKEM + +async function SerializePublicKey(key: CryptoKey) { + return new Uint8Array(await crypto.subtle.exportKey('raw', key)) +} + +async function DeserializePublicKey(key: Uint8Array, algorithm: EcKeyGenParams | KeyAlgorithm) { + return crypto.subtle.importKey('raw', key as Uint8Array, algorithm, true, []) +} + +async function ExtractAndExpand(suite: Suite, dh: Uint8Array, kemContext: Uint8Array) { + const eae_prk = await LabeledExtract(suite.kem.suite_id, new Uint8Array(), encode('eae_prk'), dh) + return LabeledExpand( + suite.kem.suite_id, + eae_prk, + encode('shared_secret'), + kemContext, + suite.kem.Nsecret, + ) +} + +async function Encap(suite: Suite, pkR: CryptoKey) { + const ekp = await crypto.subtle.generateKey(suite.kem.algorithm, true, ['deriveBits']) + const { privateKey: skE, publicKey: pkE } = ekp as CryptoKeyPair + + const dh = await ecdhDeriveBits(pkR, skE) + + const enc = await SerializePublicKey(pkE) + const pkRm = await SerializePublicKey(pkR) + const kemContext = concat(enc, pkRm) + const sharedSecret = await ExtractAndExpand(suite, dh, kemContext) + return { sharedSecret, enc } +} + +async function Decap(suite: Suite, enc: Uint8Array, skR: CryptoKey) { + let pkR: CryptoKey + if (typeof (crypto.subtle as any).getPublicKey === 'function') { + pkR = await (crypto.subtle as any).getPublicKey(skR, []) + } else { + if (!skR.extractable) { + throw new JOSENotSupported( + 'HPKE decryption requires the key to be extractable in this runtime.', + ) + } + const jwk = await crypto.subtle.exportKey('jwk', skR) + delete jwk.d + delete jwk.key_ops + pkR = await crypto.subtle.importKey('jwk', jwk, suite.kem.algorithm, true, []) + } + + const pkE = await DeserializePublicKey(enc, suite.kem.algorithm) + const dh = await ecdhDeriveBits(pkE, skR) + + const pkRm = await SerializePublicKey(pkR) + const kemContext = concat(enc, pkRm) + return ExtractAndExpand(suite, dh, kemContext) +} + +// KeySchedule + +function VerifyPSKInputs(psk: Uint8Array, pskId: Uint8Array) { + const gotPSK = psk.byteLength !== 0 + const gotPSKId = pskId.byteLength !== 0 + if (gotPSK !== gotPSKId) { + throw new Error('Inconsistent PSK inputs') + } + if (gotPSK && psk.byteLength < 32) { + throw new Error('PSK must be at least 32 bytes') + } +} + +async function KeySchedule( + suite: Suite, + sharedSecret: Uint8Array, + info: Uint8Array, + psk?: Uint8Array, + pskId?: Uint8Array, +) { + const _psk = psk ?? new Uint8Array() + const _pskId = pskId ?? new Uint8Array() + VerifyPSKInputs(_psk, _pskId) + + const mode = _psk.byteLength > 0 ? MODE_PSK : MODE_BASE + + const sid = suite.id + + const [pskIdHash, infoHash] = await Promise.all([ + LabeledExtract(sid, new Uint8Array(), encode('psk_id_hash'), _pskId), + LabeledExtract(sid, new Uint8Array(), encode('info_hash'), info), + ]) + + const keyScheduleCtx = concat(I2OSP(mode, 1), pskIdHash, infoHash) + const secret = await LabeledExtract(sid, sharedSecret, encode('secret'), _psk) + + const [key, baseNonce] = await Promise.all([ + LabeledExpand(sid, secret, encode('key'), keyScheduleCtx, suite.aead.Nk), + LabeledExpand(sid, secret, encode('base_nonce'), keyScheduleCtx, suite.aead.Nn), + ]) + + return { key, baseNonce } +} + +function importAeadKey(suite: Suite, key: Uint8Array, usage: 'encrypt' | 'decrypt') { + return crypto.subtle.importKey( + suite.aead.keyFormat as any, + key as Uint8Array, + suite.aead.algorithm, + false, + [usage], + ) +} + +// Single-shot API + +export async function Seal( + alg: string, + pkR: CryptoKey, + info: Uint8Array, + aad: Uint8Array, + pt: Uint8Array, + psk?: Uint8Array, + pskId?: Uint8Array, +): Promise<{ enc: Uint8Array; ct: Uint8Array }> { + const suite = getSuite(alg) + + const { sharedSecret, enc } = await Encap(suite, pkR) + const { key, baseNonce } = await KeySchedule(suite, sharedSecret, info, psk, pskId) + const ct = await aeadEncrypt( + suite.aead.algorithm, + await importAeadKey(suite, key, 'encrypt'), + baseNonce, + aad, + pt, + ) + + return { enc, ct } +} + +export async function Open( + alg: string, + enc: Uint8Array, + skR: CryptoKey, + info: Uint8Array, + aad: Uint8Array, + ct: Uint8Array, + psk?: Uint8Array, + pskId?: Uint8Array, +): Promise { + const suite = getSuite(alg) + + const sharedSecret = await Decap(suite, enc, skR) + const { key, baseNonce } = await KeySchedule(suite, sharedSecret, info, psk, pskId) + return aeadDecrypt( + suite.aead.algorithm, + await importAeadKey(suite, key, 'decrypt'), + baseNonce, + aad, + ct, + ) +} + +export function isIntegratedEncryption(alg: string): boolean { + return alg === 'HPKE-0' || alg === 'HPKE-3' || alg === 'HPKE-4' || alg === 'HPKE-7' +} diff --git a/src/lib/jwk_to_key.ts b/src/lib/jwk_to_key.ts index 634729c29b..6a4319713f 100644 --- a/src/lib/jwk_to_key.ts +++ b/src/lib/jwk_to_key.ts @@ -71,6 +71,11 @@ function subtleMapping(jwk: types.JWK): { algorithm = { name: 'ECDH', namedCurve: jwk.crv! } keyUsages = jwk.d ? ['deriveBits'] : [] break + case 'HPKE-0': + case 'HPKE-7': + algorithm = { name: 'ECDH', namedCurve: 'P-256' } + keyUsages = jwk.d ? ['deriveBits'] : [] + break default: throw new JOSENotSupported(unsupportedAlg) } @@ -90,6 +95,11 @@ function subtleMapping(jwk: types.JWK): { algorithm = { name: jwk.crv! } keyUsages = jwk.d ? ['deriveBits'] : [] break + case 'HPKE-3': + case 'HPKE-4': + algorithm = { name: 'X25519' } + keyUsages = jwk.d ? ['deriveBits'] : [] + break default: throw new JOSENotSupported(unsupportedAlg) } diff --git a/src/lib/normalize_key.ts b/src/lib/normalize_key.ts index 329142ef9f..01f0cd69b2 100644 --- a/src/lib/normalize_key.ts +++ b/src/lib/normalize_key.ts @@ -3,6 +3,7 @@ import { isJWK } from './type_checks.js' import { decode } from '../util/base64url.js' import { jwkToKey } from './jwk_to_key.js' import { isCryptoKey, isKeyObject } from './is_key_like.js' +import { isIntegratedEncryption } from './hpke.js' const unusableForAlg = 'given KeyObject instance cannot be used for this algorithm' @@ -36,8 +37,13 @@ const handleJWK = async ( if (cached?.[alg]) { return cached[alg] } - - const cryptoKey = await jwkToKey({ ...jwk, alg }) + const ext = + jwk.d && + isIntegratedEncryption(alg) && + typeof (crypto.subtle as any).getPublicKey !== 'function' + ? true + : undefined + const cryptoKey = await jwkToKey({ ...jwk, alg, ext }) if (freeze) Object.freeze(key) if (!cached) { cache.set(key, { [alg]: cryptoKey }) @@ -55,6 +61,7 @@ const handleKeyObject = (keyObject: ConvertableKeyObject, alg: string) => { } const isPublic = keyObject.type === 'public' + const hasGetPublicKey = typeof (crypto.subtle as any).getPublicKey === 'function' const extractable = isPublic ? true : false let cryptoKey: types.CryptoKey | undefined @@ -65,6 +72,9 @@ const handleKeyObject = (keyObject: ConvertableKeyObject, alg: string) => { case 'ECDH-ES+A192KW': case 'ECDH-ES+A256KW': break + case 'HPKE-3': + case 'HPKE-4': + break default: throw new TypeError(unusableForAlg) @@ -72,7 +82,7 @@ const handleKeyObject = (keyObject: ConvertableKeyObject, alg: string) => { cryptoKey = keyObject.toCryptoKey( keyObject.asymmetricKeyType, - extractable, + (alg === 'HPKE-3' || alg === 'HPKE-4') && !isPublic && !hasGetPublicKey ? true : extractable, isPublic ? [] : ['deriveBits'], ) } @@ -182,6 +192,20 @@ const handleKeyObject = (keyObject: ConvertableKeyObject, alg: string) => { isPublic ? [] : ['deriveBits'], ) } + + if (alg === 'HPKE-0' || alg === 'HPKE-7') { + if (namedCurve !== 'P-256') { + throw new TypeError('given KeyObject instance cannot be used for this algorithm') + } + cryptoKey = keyObject.toCryptoKey( + { + name: 'ECDH', + namedCurve, + }, + !isPublic && !hasGetPublicKey ? true : extractable, + isPublic ? [] : ['deriveBits'], + ) + } } if (!cryptoKey) { diff --git a/src/types.d.ts b/src/types.d.ts index ce5212a45e..efaa62413c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -314,6 +314,12 @@ export interface JWEKeyManagementHeaderParameters { * used in ECDH's ConcatKDF. */ apv?: Uint8Array + + /** HPKE Pre-Shared Key (PSK) for use in PSK mode. */ + psk?: Uint8Array + + /** HPKE Pre-Shared Key Identifier (PSK ID) for use in PSK mode. */ + psk_id?: Uint8Array /** * @deprecated You should not use this parameter. It is only intended for testing and vector * validation purposes. @@ -424,6 +430,9 @@ export interface JWEHeaderParameters extends JoseHeaderParameters { */ zip?: string + /** HPKE Pre-Shared Key Identifier (PSK ID) for use in PSK mode. */ + psk_id?: string + /** Any other JWE Header member. */ [propName: string]: unknown } @@ -485,6 +494,9 @@ export interface DecryptOptions extends CritOption { * Set to `Infinity` to disable the decompressed size limit. */ maxDecompressedLength?: number + + /** HPKE Pre-Shared Key (PSK) for use in PSK mode. */ + psk?: Uint8Array } /** JWE Encryption options. */ @@ -716,7 +728,7 @@ export interface JWTHeaderParameters extends CompactJWSHeaderParameters { /** Recognized Compact JWE Header Parameters, any other Header Members may also be present. */ export interface CompactJWEHeaderParameters extends JWEHeaderParameters { alg: string - enc: string + enc?: string } /** JSON Web Key Set */ diff --git a/tap/cookbook.ts b/tap/cookbook.ts index b5a11453a8..0c1e0c6a07 100644 --- a/tap/cookbook.ts +++ b/tap/cookbook.ts @@ -235,9 +235,15 @@ export default ( encrypt.setUnprotectedHeader(vector.encrypting_content.unprotected) } + if (vector.input.aad) { + encrypt.setAdditionalAuthenticatedData(encode(vector.input.aad)) + } + + const hpke = vector.input.alg.startsWith('HPKE') const privateKey = (await keys.importJWK( toJWK(vector.input.pwd || vector.input.key), dir ? vector.input.enc : vector.input.alg, + hpke ? { extractable: true } : undefined, )) as jose.KeyLike let publicKey if (privateKey.type === 'secret') { @@ -249,28 +255,33 @@ export default ( ) } - const result = await encrypt.encrypt(publicKey) - await flattened.decrypt(result, privateKey, { + const decryptOptions: jose.DecryptOptions = { keyManagementAlgorithms: [vector.input.alg], - contentEncryptionAlgorithms: [vector.input.enc], - }) + } + if (vector.input.enc) { + decryptOptions.contentEncryptionAlgorithms = [vector.input.enc] + } + + const result = await encrypt.encrypt(publicKey) + await flattened.decrypt(result, privateKey, decryptOptions) } const privateKey = await keys.importJWK( toJWK(vector.input.pwd || vector.input.key), dir ? vector.input.enc : vector.input.alg, + vector.input.alg.startsWith('HPKE') ? { extractable: true } : undefined, ) + const decryptOptions: jose.DecryptOptions = { + keyManagementAlgorithms: [vector.input.alg], + } + if (vector.input.enc) { + decryptOptions.contentEncryptionAlgorithms = [vector.input.enc] + } if (vector.output.json_flat) { - await flattened.decrypt(vector.output.json_flat, privateKey, { - keyManagementAlgorithms: [vector.input.alg], - contentEncryptionAlgorithms: [vector.input.enc], - }) + await flattened.decrypt(vector.output.json_flat, privateKey, decryptOptions) } if (vector.output.compact) { - await compact.decrypt(vector.output.compact, privateKey, { - keyManagementAlgorithms: [vector.input.alg], - contentEncryptionAlgorithms: [vector.input.enc], - }) + await compact.decrypt(vector.output.compact, privateKey, decryptOptions) } t.ok(1) } diff --git a/tap/env.ts b/tap/env.ts index 3c0382a6e7..805ff86a19 100644 --- a/tap/env.ts +++ b/tap/env.ts @@ -62,6 +62,15 @@ export function supported(identifier?: string, op?: string) { case 'ML-DSA-65': case 'ML-DSA-87': return isNode && isNodeVersionAtLeast(24, 7) + case 'HPKE-4': + switch (op) { + case 'private jwk import': + case 'public jwk import': + case 'pem import': + return true + default: + return isNode && isNodeVersionAtLeast(24, 7) + } } if (isBlink) { @@ -90,7 +99,7 @@ export function supported(identifier?: string, op?: string) { } } - if (isBun && identifier === 'X25519') { + if (isBun && (identifier === 'X25519' || identifier === 'HPKE-3')) { switch (op) { case 'private jwk import': case 'public jwk import': diff --git a/tap/hpke.ts b/tap/hpke.ts new file mode 100644 index 0000000000..7330cd8a6b --- /dev/null +++ b/tap/hpke.ts @@ -0,0 +1,200 @@ +import type QUnit from 'qunit' +import * as env from './env.js' +import type * as jose from '../src/index.js' + +const hasGetPublicKey = typeof (crypto.subtle as any).getPublicKey === 'function' + +export default ( + QUnit: QUnit, + lib: typeof jose, + keys: Pick, +) => { + const { module, test } = QUnit + module('hpke.ts') + + const kps: Record = {} + + const algorithms = ['HPKE-0', 'HPKE-3', 'HPKE-4', 'HPKE-7'] + + function title(alg: string, supported = true) { + let result = '' + if (!supported) { + result = '[not supported] ' + } + result += alg + return result + } + + for (const alg of algorithms) { + const execute = async (t: typeof QUnit.assert) => { + if (!kps[alg]) { + kps[alg] = await keys.generateKeyPair( + alg, + hasGetPublicKey ? undefined : { extractable: true }, + ) + } + + const cleartext = crypto.getRandomValues(new Uint8Array(16)) + + // Test CryptoKey inputs + { + const aad = crypto.getRandomValues(new Uint8Array(16)) + const jwe = await new lib.FlattenedEncrypt(cleartext) + .setProtectedHeader({ alg }) + .setAdditionalAuthenticatedData(aad) + .encrypt(kps[alg].publicKey) + + for (const key of [kps[alg].privateKey, async () => kps[alg].privateKey]) { + // @ts-ignore + const decrypted = await lib.flattenedDecrypt(jwe, key, { + keyManagementAlgorithms: [alg], + }) + t.deepEqual([...decrypted.plaintext], [...cleartext]) + t.deepEqual([...decrypted.additionalAuthenticatedData!], [...aad]) + } + } + + // Test JWK inputs + { + const extractableKp = hasGetPublicKey + ? await keys.generateKeyPair(alg, { extractable: true }) + : kps[alg] + const [eJwk, dJwk] = await Promise.all([ + keys.exportJWK(extractableKp.publicKey), + keys.exportJWK(extractableKp.privateKey), + ]) + const aad = crypto.getRandomValues(new Uint8Array(16)) + + const jwe = await new lib.FlattenedEncrypt(cleartext) + .setProtectedHeader({ alg }) + .setAdditionalAuthenticatedData(aad) + .encrypt(eJwk) + + { + const eJwkProps = structuredClone(eJwk) + eJwkProps.alg = alg + eJwkProps.use = 'enc' + eJwkProps.key_ops = [] + await new lib.FlattenedEncrypt(cleartext) + .setProtectedHeader({ alg }) + .setAdditionalAuthenticatedData(aad) + .encrypt(eJwkProps) + } + + if (hasGetPublicKey) { + const dJwkProps = structuredClone(dJwk) + dJwkProps.alg = alg + dJwkProps.use = 'enc' + dJwkProps.key_ops = ['deriveBits'] + + for (const key of [dJwk, async () => dJwk, dJwkProps, async () => dJwkProps]) { + // @ts-ignore + const decrypted = await lib.flattenedDecrypt(jwe, key, { + keyManagementAlgorithms: [alg], + }) + t.deepEqual([...decrypted.plaintext], [...cleartext]) + t.deepEqual([...decrypted.additionalAuthenticatedData!], [...aad]) + } + } + } + } + + const jwt = async (t: typeof QUnit.assert) => { + if (!kps[alg]) { + kps[alg] = await keys.generateKeyPair( + alg, + hasGetPublicKey ? undefined : { extractable: true }, + ) + } + + const [eKey, dKey] = [kps[alg].publicKey, kps[alg].privateKey] + + const token = await new lib.EncryptJWT({ foo: 'bar' }) + .setProtectedHeader({ alg }) + .encrypt(eKey) + + for (const key of [dKey, async () => dKey]) { + // @ts-ignore + const decrypted = await lib.jwtDecrypt(token, key, { + keyManagementAlgorithms: [alg], + }) + t.propContains(decrypted, { + payload: { foo: 'bar' }, + protectedHeader: { alg }, + }) + } + } + + const pskMode = async (t: typeof QUnit.assert) => { + if (!kps[alg]) { + kps[alg] = await keys.generateKeyPair( + alg, + hasGetPublicKey ? undefined : { extractable: true }, + ) + } + + const cleartext = crypto.getRandomValues(new Uint8Array(16)) + const psk = crypto.getRandomValues(new Uint8Array(32)) + const psk_id = crypto.getRandomValues(new Uint8Array(16)) + + const jwe = await new lib.FlattenedEncrypt(cleartext) + .setProtectedHeader({ alg }) + .setKeyManagementParameters({ psk, psk_id }) + .encrypt(kps[alg].publicKey) + + // psk_id must be in the protected header + const protectedHeader = JSON.parse( + new TextDecoder().decode(lib.base64url.decode(jwe.protected!)), + ) + t.ok(typeof protectedHeader.psk_id === 'string', 'psk_id is in the protected header') + + // decrypt: psk_id is taken from the header, only psk is an option + const decrypted = await lib.flattenedDecrypt(jwe, kps[alg].privateKey, { + keyManagementAlgorithms: [alg], + psk, + }) + t.deepEqual([...decrypted.plaintext], [...cleartext]) + + // decrypt without psk should fail + await t.rejects( + lib.flattenedDecrypt(jwe, kps[alg].privateKey, { + keyManagementAlgorithms: [alg], + }), + ) + + // decrypt with wrong psk should fail + const wrongPsk = crypto.getRandomValues(new Uint8Array(32)) + await t.rejects( + lib.flattenedDecrypt(jwe, kps[alg].privateKey, { + keyManagementAlgorithms: [alg], + psk: wrongPsk, + }), + ) + } + + const nonExtractable = async (t: typeof QUnit.assert) => { + const kp = await lib.generateKeyPair(alg) + const cleartext = crypto.getRandomValues(new Uint8Array(16)) + const jwe = await new lib.FlattenedEncrypt(cleartext) + .setProtectedHeader({ alg }) + .encrypt(kp.publicKey) + await t.rejects( + lib.flattenedDecrypt(jwe, kp.privateKey, { keyManagementAlgorithms: [alg] }), + (err: jose.errors.JOSEError) => err.code === 'ERR_JOSE_NOT_SUPPORTED', + ) + } + + if (env.supported(alg)) { + test(title(alg), execute) + test(`${title(alg)} JWT`, jwt) + test(`${title(alg)} PSK mode`, pskMode) + if (!hasGetPublicKey) { + test(`${title(alg)} non-extractable key`, nonExtractable) + } + } else { + test(title(alg, false), async (t) => { + await t.rejects(execute(t)) + }) + } + } +} diff --git a/tap/jwk.ts b/tap/jwk.ts index 7c7f233034..27aacacc1e 100644 --- a/tap/jwk.ts +++ b/tap/jwk.ts @@ -36,6 +36,10 @@ export default ( ['ML-DSA-44', KEYS['ML-DSA-44'].jwk], ['ML-DSA-65', KEYS['ML-DSA-65'].jwk], ['ML-DSA-87', KEYS['ML-DSA-87'].jwk], + ['HPKE-0', KEYS.P256.jwk], + ['HPKE-3', KEYS.X25519.jwk], + ['HPKE-4', KEYS.X25519.jwk], + ['HPKE-7', KEYS.P256.jwk], ] function publicJwk(jwk: JsonWebKey) { diff --git a/tap/keyobject-stub.ts b/tap/keyobject-stub.ts index 95b19ea496..2ddcfa79b9 100644 --- a/tap/keyobject-stub.ts +++ b/tap/keyobject-stub.ts @@ -106,6 +106,12 @@ const stub: Pick< Error('unreachable') } } + case 'HPKE-0': + case 'HPKE-7': + return generate('ec', { namedCurve: 'P-256' }) + case 'HPKE-3': + case 'HPKE-4': + return generate('x25519') case 'ML-DSA-44': case 'ML-DSA-65': case 'ML-DSA-87': diff --git a/tap/pem.ts b/tap/pem.ts index 70c8d6bfe6..60b7b1f1b8 100644 --- a/tap/pem.ts +++ b/tap/pem.ts @@ -79,6 +79,14 @@ export default ( ['ML-DSA-65', KEYS['ML-DSA-65'].spki], ['ML-DSA-87', KEYS['ML-DSA-87'].pkcs8], ['ML-DSA-87', KEYS['ML-DSA-87'].spki], + ['HPKE-0', KEYS.P256.pkcs8], + ['HPKE-0', KEYS.P256.spki], + ['HPKE-3', KEYS.X25519.pkcs8], + ['HPKE-3', KEYS.X25519.spki], + ['HPKE-4', KEYS.X25519.pkcs8], + ['HPKE-4', KEYS.X25519.spki], + ['HPKE-7', KEYS.P256.pkcs8], + ['HPKE-7', KEYS.P256.spki], ] function title(alg: string, crv: string | undefined, pem: string, supported = true) { diff --git a/tap/run.ts b/tap/run.ts index 3f9663f193..153ea29d2a 100644 --- a/tap/run.ts +++ b/tap/run.ts @@ -24,6 +24,7 @@ export default async ( import('./ecdh.js'), import('./generate_options.js'), import('./hmac.js'), + import('./hpke.js'), import('./jwk.js'), import('./jws.js'), import('./pbes2.js'), diff --git a/test/jwe/flattened.decrypt.test.ts b/test/jwe/flattened.decrypt.test.ts index 61c9e3c511..abd4c5b246 100644 --- a/test/jwe/flattened.decrypt.test.ts +++ b/test/jwe/flattened.decrypt.test.ts @@ -1,7 +1,7 @@ import test from 'ava' import * as crypto from 'crypto' -import { FlattenedEncrypt, flattenedDecrypt } from '../../src/index.js' +import { FlattenedEncrypt, flattenedDecrypt, generateKeyPair } from '../../src/index.js' test.before(async (t) => { const encode = TextEncoder.prototype.encode.bind(new TextEncoder()) @@ -261,3 +261,58 @@ test('decrypt with PBES2 is not allowed by default', async (t) => { code: 'ERR_JOSE_ALG_NOT_ALLOWED', }) }) + +const hasGetPublicKey = typeof (crypto.webcrypto.subtle as any).getPublicKey === 'function' + +for (const alg of ['HPKE-0', 'HPKE-7']) { + test(`HPKE decrypt with extractable key (${alg})`, async (t) => { + const kp = await generateKeyPair(alg, { extractable: true }) + const jwe = await new FlattenedEncrypt(new Uint8Array([1, 2, 3])) + .setProtectedHeader({ alg }) + .encrypt(kp.publicKey) + + const result = await flattenedDecrypt(jwe, kp.privateKey, { + keyManagementAlgorithms: [alg], + }) + t.deepEqual([...result.plaintext], [1, 2, 3]) + }) + + test(`HPKE decrypt with non-extractable key ${hasGetPublicKey ? 'succeeds' : 'fails'} (${alg})`, async (t) => { + const kp = await generateKeyPair(alg) + const extractableKp = await generateKeyPair(alg, { extractable: true }) + // encrypt with the extractable public key so we have a valid JWE for the non-extractable test + const jwe = await new FlattenedEncrypt(new Uint8Array([1, 2, 3])) + .setProtectedHeader({ alg }) + .encrypt(kp.publicKey) + + if (hasGetPublicKey) { + const result = await flattenedDecrypt(jwe, kp.privateKey, { + keyManagementAlgorithms: [alg], + }) + t.deepEqual([...result.plaintext], [1, 2, 3]) + } else { + await t.throwsAsync( + flattenedDecrypt(jwe, kp.privateKey, { keyManagementAlgorithms: [alg] }), + { + code: 'ERR_JOSE_NOT_SUPPORTED', + message: 'HPKE decryption requires the key to be extractable in this runtime.', + }, + ) + } + }) + + test(`HPKE with compression (${alg})`, async (t) => { + const kp = await generateKeyPair(alg, { extractable: true }) + const jwe = await new FlattenedEncrypt(new Uint8Array([1, 2, 3])) + .setProtectedHeader({ alg, zip: 'DEF' }) + .encrypt(kp.publicKey) + + t.is(jwe.iv, undefined) + t.is(jwe.tag, undefined) + + const result = await flattenedDecrypt(jwe, kp.privateKey, { + keyManagementAlgorithms: [alg], + }) + t.deepEqual([...result.plaintext], [1, 2, 3]) + }) +} diff --git a/test/unit/hpke.test.ts b/test/unit/hpke.test.ts new file mode 100644 index 0000000000..63dec32e6a --- /dev/null +++ b/test/unit/hpke.test.ts @@ -0,0 +1,200 @@ +import test from 'ava' +import { + CipherSuite, + KEM_DHKEM_P256_HKDF_SHA256, + KEM_DHKEM_X25519_HKDF_SHA256, + KDF_HKDF_SHA256, + AEAD_AES_128_GCM, + AEAD_AES_256_GCM, + AEAD_ChaCha20Poly1305, + type KEMFactory, + type KDFFactory, + type AEADFactory, +} from 'hpke' +import { Seal, Open } from '../../src/lib/hpke.js' + +interface SuiteConfig { + alg: string + kem: KEMFactory + kdf: KDFFactory + aead: AEADFactory + algorithm: EcKeyGenParams | KeyAlgorithm +} + +const configs: SuiteConfig[] = [ + { + alg: 'HPKE-0', + kem: KEM_DHKEM_P256_HKDF_SHA256, + kdf: KDF_HKDF_SHA256, + aead: AEAD_AES_128_GCM, + algorithm: { name: 'ECDH', namedCurve: 'P-256' }, + }, + { + alg: 'HPKE-7', + kem: KEM_DHKEM_P256_HKDF_SHA256, + kdf: KDF_HKDF_SHA256, + aead: AEAD_AES_256_GCM, + algorithm: { name: 'ECDH', namedCurve: 'P-256' }, + }, + { + alg: 'HPKE-3', + kem: KEM_DHKEM_X25519_HKDF_SHA256, + kdf: KDF_HKDF_SHA256, + aead: AEAD_AES_128_GCM, + algorithm: { name: 'X25519' }, + }, + { + alg: 'HPKE-4', + kem: KEM_DHKEM_X25519_HKDF_SHA256, + kdf: KDF_HKDF_SHA256, + aead: AEAD_ChaCha20Poly1305, + algorithm: { name: 'X25519' }, + }, +] + +async function generateKeyPair(algorithm: EcKeyGenParams | KeyAlgorithm) { + return (await crypto.subtle.generateKey(algorithm, true, ['deriveBits'])) as CryptoKeyPair +} + +for (const { alg, kem, kdf, aead, algorithm } of configs) { + const isSupported = + alg !== 'HPKE-4' || SubtleCrypto.supports?.('generateKey', 'ChaCha20-Poly1305') + + const run = isSupported ? test : test.skip + + run(`${alg}: hpke module encrypts, jose decrypts (base mode)`, async (t) => { + const kp = await generateKeyPair(algorithm) + + const suite = new CipherSuite(kem, kdf, aead) + const plaintext = crypto.getRandomValues(new Uint8Array(32)) + const aadBytes = crypto.getRandomValues(new Uint8Array(16)) + const info = new Uint8Array() + + const { encapsulatedSecret, ciphertext } = await suite.Seal(kp.publicKey, plaintext, { + aad: aadBytes, + info, + }) + + const decrypted = await Open( + alg, + new Uint8Array(encapsulatedSecret), + kp.privateKey, + info, + aadBytes, + new Uint8Array(ciphertext), + ) + + t.deepEqual([...decrypted], [...plaintext]) + }) + + run(`${alg}: jose encrypts, hpke module decrypts (base mode)`, async (t) => { + const kp = await generateKeyPair(algorithm) + + const suite = new CipherSuite(kem, kdf, aead) + const plaintext = crypto.getRandomValues(new Uint8Array(32)) + const aadBytes = crypto.getRandomValues(new Uint8Array(16)) + const info = new Uint8Array() + + const { enc, ct } = await Seal(alg, kp.publicKey, info, aadBytes, plaintext) + + const decrypted = await suite.Open(kp, enc, ct, { + aad: aadBytes, + info, + }) + + t.deepEqual([...decrypted], [...plaintext]) + }) + + run(`${alg}: hpke module encrypts, jose decrypts (PSK mode)`, async (t) => { + const kp = await generateKeyPair(algorithm) + + const suite = new CipherSuite(kem, kdf, aead) + const plaintext = crypto.getRandomValues(new Uint8Array(32)) + const aadBytes = crypto.getRandomValues(new Uint8Array(16)) + const info = new Uint8Array() + const psk = crypto.getRandomValues(new Uint8Array(32)) + const pskId = crypto.getRandomValues(new Uint8Array(16)) + + const { encapsulatedSecret, ciphertext } = await suite.Seal(kp.publicKey, plaintext, { + aad: aadBytes, + info, + psk, + pskId, + }) + + const decrypted = await Open( + alg, + new Uint8Array(encapsulatedSecret), + kp.privateKey, + info, + aadBytes, + new Uint8Array(ciphertext), + psk, + pskId, + ) + + t.deepEqual([...decrypted], [...plaintext]) + }) + + run(`${alg}: jose encrypts, hpke module decrypts (PSK mode)`, async (t) => { + const kp = await generateKeyPair(algorithm) + + const suite = new CipherSuite(kem, kdf, aead) + const plaintext = crypto.getRandomValues(new Uint8Array(32)) + const aadBytes = crypto.getRandomValues(new Uint8Array(16)) + const info = new Uint8Array() + const psk = crypto.getRandomValues(new Uint8Array(32)) + const pskId = crypto.getRandomValues(new Uint8Array(16)) + + const { enc, ct } = await Seal(alg, kp.publicKey, info, aadBytes, plaintext, psk, pskId) + + const decrypted = await suite.Open(kp, enc, ct, { + aad: aadBytes, + info, + psk, + pskId, + }) + + t.deepEqual([...decrypted], [...plaintext]) + }) + + run(`${alg}: interop with non-empty info`, async (t) => { + const kp = await generateKeyPair(algorithm) + + const suite = new CipherSuite(kem, kdf, aead) + const plaintext = crypto.getRandomValues(new Uint8Array(32)) + const aadBytes = crypto.getRandomValues(new Uint8Array(16)) + const info = new TextEncoder().encode('test application info') + + // hpke module -> jose + { + const { encapsulatedSecret, ciphertext } = await suite.Seal(kp.publicKey, plaintext, { + aad: aadBytes, + info, + }) + + const decrypted = await Open( + alg, + new Uint8Array(encapsulatedSecret), + kp.privateKey, + info, + aadBytes, + new Uint8Array(ciphertext), + ) + + t.deepEqual([...decrypted], [...plaintext]) + } + + // jose -> hpke module + { + const { enc, ct } = await Seal(alg, kp.publicKey, info, aadBytes, plaintext) + + const decrypted = await suite.Open(kp, enc, ct, { + aad: aadBytes, + info, + }) + + t.deepEqual([...decrypted], [...plaintext]) + } + }) +}