From e33a203f215fc5af8bcffef88699b69c9c580fbc Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Sat, 2 Mar 2024 14:56:51 -0600 Subject: [PATCH] wip --- src/cose/key/generate.ts | 15 +- .../draft-jose-cose-hpke-cookbook.test.ts | 9 + test/jose-hpke/index.ts | 1 + test/jose-hpke/src/IntegratedEncryption.ts | 79 +++++ test/jose-hpke/src/KeyEncryption.ts | 302 ++++++++++++++++++ test/jose-hpke/src/index.ts | 5 + test/jose-hpke/src/keys.ts | 108 +++++++ test/jose-hpke/src/mixed.ts | 154 +++++++++ .../tests/IntegratedEncryption.test.ts | 31 ++ test/jose-hpke/tests/KeyEncryption.test.ts | 169 ++++++++++ test/jose-hpke/tests/cross.no.aad.test.ts | 65 ++++ test/jose-hpke/tests/cross.test.ts | 66 ++++ test/jose-hpke/tests/jwe.sanity.test.ts | 76 +++++ test/jose-hpke/tests/mixed.test.ts | 49 +++ 14 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 test/draft-jose-cose-hpke-cookbook/draft-jose-cose-hpke-cookbook.test.ts create mode 100644 test/jose-hpke/index.ts create mode 100644 test/jose-hpke/src/IntegratedEncryption.ts create mode 100644 test/jose-hpke/src/KeyEncryption.ts create mode 100644 test/jose-hpke/src/index.ts create mode 100644 test/jose-hpke/src/keys.ts create mode 100644 test/jose-hpke/src/mixed.ts create mode 100644 test/jose-hpke/tests/IntegratedEncryption.test.ts create mode 100644 test/jose-hpke/tests/KeyEncryption.test.ts create mode 100644 test/jose-hpke/tests/cross.no.aad.test.ts create mode 100644 test/jose-hpke/tests/cross.test.ts create mode 100644 test/jose-hpke/tests/jwe.sanity.test.ts create mode 100644 test/jose-hpke/tests/mixed.test.ts diff --git a/src/cose/key/generate.ts b/src/cose/key/generate.ts index b4530af..b1a0356 100644 --- a/src/cose/key/generate.ts +++ b/src/cose/key/generate.ts @@ -7,6 +7,7 @@ import { IANACOSEAlgorithms } from "../algorithms" import { CoseKey } from '.' export type CoseKeyAgreementAlgorithms = 'ECDH-ES+A128KW' +export type CoseDirectEncryptionAlgorithms = 'HPKE-Base-P256-SHA256-AES128GCM' export type CoseSignatureAlgorithms = 'ES256' | 'ES384' | 'ES512' export type ContentTypeOfJsonWebKey = 'application/jwk+json' export type ContentTypeOfCoseKey = 'application/cose-key' @@ -19,16 +20,22 @@ import { thumbprint } from "./thumbprint" import { formatJwk } from './formatJwk' -export const generate = async (alg: CoseSignatureAlgorithms, contentType: PrivateKeyContentType = 'application/jwk+json'): Promise => { - const knownAlgorithm = Object.values(IANACOSEAlgorithms).find(( +export const generate = async (alg: CoseSignatureAlgorithms | CoseDirectEncryptionAlgorithms, contentType: PrivateKeyContentType = 'application/jwk+json'): Promise => { + let knownAlgorithm = Object.values(IANACOSEAlgorithms).find(( entry ) => { return entry.Name === alg - }) + }) as any + if (alg === 'HPKE-Base-P256-SHA256-AES128GCM') { + knownAlgorithm = { + Name: 'ECDH-ES+A128KW', + Curve: 'P-256' + } + } if (!knownAlgorithm) { throw new Error('Algorithm is not supported.') } - const cryptoKeyPair = await generateKeyPair(knownAlgorithm.Name, { extractable: true }); + const cryptoKeyPair = await generateKeyPair(knownAlgorithm.Name, { extractable: true, crv: knownAlgorithm.Curve }); const secretKeyJwk = await exportJWK(cryptoKeyPair.privateKey) const jwkThumbprint = await calculateJwkThumbprint(secretKeyJwk) secretKeyJwk.kid = jwkThumbprint diff --git a/test/draft-jose-cose-hpke-cookbook/draft-jose-cose-hpke-cookbook.test.ts b/test/draft-jose-cose-hpke-cookbook/draft-jose-cose-hpke-cookbook.test.ts new file mode 100644 index 0000000..8f6ce1c --- /dev/null +++ b/test/draft-jose-cose-hpke-cookbook/draft-jose-cose-hpke-cookbook.test.ts @@ -0,0 +1,9 @@ + +import * as jose from '../jose-hpke' + +import * as cose from '../../src' + +it('generate private keys', async () => { + const k1 = await jose.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const k2 = await cose.key.generate('HPKE-Base-P256-SHA256-AES128GCM', 'application/cose-key') +}) diff --git a/test/jose-hpke/index.ts b/test/jose-hpke/index.ts new file mode 100644 index 0000000..e6918d5 --- /dev/null +++ b/test/jose-hpke/index.ts @@ -0,0 +1 @@ +export * from './src' \ No newline at end of file diff --git a/test/jose-hpke/src/IntegratedEncryption.ts b/test/jose-hpke/src/IntegratedEncryption.ts new file mode 100644 index 0000000..c283179 --- /dev/null +++ b/test/jose-hpke/src/IntegratedEncryption.ts @@ -0,0 +1,79 @@ +import { base64url } from "jose"; + +import { publicKeyFromJwk, suites, isKeyAlgorithmSupported, privateKeyFromJwk, JOSE_HPKE_ALG } from "./keys"; + +export const encrypt = async (plaintext: Uint8Array, publicKeyJwk: any, options = { serialization: 'CompactSerialization'}): Promise => { + if (!isKeyAlgorithmSupported(publicKeyJwk)) { + throw new Error('Public key algorithm is not supported') + } + const suite = suites[publicKeyJwk.alg as JOSE_HPKE_ALG] + const sender = await suite.createSenderContext({ + recipientPublicKey: await publicKeyFromJwk(publicKeyJwk), + }); + + const encapsulatedKey = base64url.encode(new Uint8Array(sender.enc)) + const protectedHeader = base64url.encode(JSON.stringify({ + alg: "dir", + enc: publicKeyJwk.alg, + "epk": { + "kty": "EK", + "ek": encapsulatedKey + } + })) + const hpkeSealAad = new TextEncoder().encode(protectedHeader) + const ciphertext = base64url.encode(new Uint8Array(await sender.seal(plaintext, hpkeSealAad))); + // https://datatracker.ietf.org/doc/html/rfc7516#section-3.1 + const compact = `${protectedHeader}...${ciphertext}.` + if (options.serialization === 'CompactSerialization'){ + return compact + } + if (options.serialization === 'GeneralJson'){ + const [protectedHeader, encryptedKey, initializationVector, ciphertext, tag] = compact.split('.') + return JSON.parse(JSON.stringify({ + protected: protectedHeader, + encrypted_key: encryptedKey.length === 0 ? undefined: encryptedKey, + iv: initializationVector.length === 0 ? undefined: initializationVector, + tag: tag.length === 0 ? undefined: tag, + ciphertext: ciphertext.length === 0 ? undefined: ciphertext + })) + } + throw new Error('Unsupported Serialization.') +} + +export const decrypt = async (jwe: string | any, privateKeyJwk: any, options = { serialization: 'CompactSerialization'}): Promise => { + if (typeof jwe === 'object' && options.serialization !== 'GeneralJson'){ + throw new Error('expected object for general json serialization decrypt.') + } + let compact = '' + if (options.serialization === 'CompactSerialization'){ + if (typeof jwe !== 'string'){ + throw new Error('expected string for compact serialization decrypt.') + } + compact = jwe + } + if (options.serialization === 'GeneralJson'){ + if (typeof jwe !== 'object'){ + throw new Error('expected object for general json serialization decrypt.') + } + compact = `${jwe.protected}...${jwe.ciphertext}.` + } + if (!isKeyAlgorithmSupported(privateKeyJwk)) { + throw new Error('Public key algorithm is not supported') + } + const suite = suites[privateKeyJwk.alg as JOSE_HPKE_ALG] + const [protectedHeader, _blankEncKey, _blankIv, ciphertext, _blankTag] = compact.split('.'); + const decodedProtectedHeader = JSON.parse(new TextDecoder().decode(base64url.decode(protectedHeader))) + if (decodedProtectedHeader.alg !== 'dir'){ + throw new Error('Expected alg:dir for integrated encryption.') + } + if (decodedProtectedHeader.enc !== privateKeyJwk.alg){ + throw new Error('Private key does not support this algorithm: ' + decodedProtectedHeader.enc) + } + const recipient = await suite.createRecipientContext({ + recipientKey: await privateKeyFromJwk(privateKeyJwk), + enc: base64url.decode(decodedProtectedHeader.epk.ek) + }) + const hpkeOpenAad = new TextEncoder().encode(protectedHeader) + const plaintext = await recipient.open(base64url.decode(ciphertext), hpkeOpenAad) + return new Uint8Array(plaintext) +} \ No newline at end of file diff --git a/test/jose-hpke/src/KeyEncryption.ts b/test/jose-hpke/src/KeyEncryption.ts new file mode 100644 index 0000000..414cfa7 --- /dev/null +++ b/test/jose-hpke/src/KeyEncryption.ts @@ -0,0 +1,302 @@ +import crypto from 'crypto'; +import { base64url } from "jose"; + +import { publicKeyFromJwk, privateKeyFromJwk, HPKERecipient, isKeyAlgorithmSupported, suites, JOSE_HPKE_ALG, JWKS, formatJWK } from "./keys"; + +import * as mixed from './mixed' + +import * as jose from 'jose' + +export type RequestGeneralEncrypt = { + protectedHeader: { enc: 'A128GCM' } + plaintext: Uint8Array + additionalAuthenticatedData?: Uint8Array + recipients: JWKS +} + +const sortJsonSerialization = (jwe: any)=> { + // https://datatracker.ietf.org/doc/html/rfc7516#section-3.2 + const { protected: protectedHeader, unprotected, header, encrypted_key, ciphertext, iv, aad, tag, recipients} = jwe + return JSON.parse(JSON.stringify({ + protected: protectedHeader, + unprotected, + header, + encrypted_key, + iv, + ciphertext, + tag, + aad, + recipients, + })) +} + +const prepareAad = (protectedHeader: any, aad?: Uint8Array) => { + let textAad = base64url.encode(JSON.stringify(protectedHeader)) + if (aad){ + textAad += '.' + base64url.encode(aad) + } + return textAad + +} + +export const encrypt = async ( + req: RequestGeneralEncrypt, + options = {serialization: 'GeneralJson'} +): Promise => { + + let jwe = {} as any; + const unprotectedHeader = { + recipients: [] as HPKERecipient[] + } + let protectedHeader = base64url.encode(JSON.stringify(req.protectedHeader)) + jwe.protected = protectedHeader + + let jweAad = prepareAad(req.protectedHeader, req.additionalAuthenticatedData) + + // generate a content encryption key for a content encryption algorithm + const contentEncryptionKey = crypto.randomBytes(16); // for A128GCM + + for (const recipient of req.recipients.keys) { + if (isKeyAlgorithmSupported(recipient)) { + const suite = suites[recipient.alg as JOSE_HPKE_ALG] + // prepare the hpke sender + const sender = await suite.createSenderContext({ + recipientPublicKey: await publicKeyFromJwk(recipient), + }); + // encode the encapsulated key for the recipient + const encapsulatedKey = base64url.encode(new Uint8Array(sender.enc)) + // prepare the add for the seal operation for the recipient + // ensure the recipient must process the protected header + // and understand the chosen "encyption algorithm" + + if (req.recipients.keys.length === 1){ + let newHeader = {...req.protectedHeader, epk: {kty: 'EK', ek: encapsulatedKey}} + jwe.protected = base64url.encode(JSON.stringify(newHeader)) + jweAad = prepareAad(newHeader, req.additionalAuthenticatedData) + } + + const hpkeSealAad = new TextEncoder().encode(jweAad) + // encrypt the content encryption key to the recipient, + // while binding the content encryption algorithm to the protected header + const encrypted_key = base64url.encode(new Uint8Array(await sender.seal(contentEncryptionKey, hpkeSealAad))); + jwe.encrypted_key = encrypted_key + if (req.recipients.keys.length !== 1){ + unprotectedHeader.recipients.push( + { + encrypted_key: encrypted_key, + header: { + kid: recipient.kid, + alg: recipient.alg, + epk: {kty: 'EK', ek: encapsulatedKey} as any, + } as any + } + ) + } + + } else if (recipient.alg === 'ECDH-ES+A128KW') { + // throw new Error('Mixed mode not supported') + const ek = await jose.generateKeyPair(recipient.alg, { crv: recipient.crv, extractable: true }) + const epk = await jose.exportJWK(ek.publicKey) + const sharedSecret = await mixed.deriveKey( recipient, await jose.exportJWK(ek.privateKey)) + const encrypted_key = mixed.wrap('A128KW', sharedSecret, contentEncryptionKey) + unprotectedHeader.recipients.push({ + encrypted_key: base64url.encode(encrypted_key), + header: { + kid: recipient.kid, + alg: recipient.alg, + epk: formatJWK(epk) + } + } as any) + } else { + throw new Error('Public key algorithm not supported: ' + recipient.alg) + } + + } + + // generate an initialization vector for use with the content encryption key + const initializationVector = crypto.getRandomValues(new Uint8Array(12)); // possibly wrong + const iv = base64url.encode(initializationVector) + + + // encrypt the plaintext with the content encryption algorithm + + + const encryption = await mixed.gcmEncrypt( + req.protectedHeader.enc, + req.plaintext, + contentEncryptionKey, + initializationVector, + new TextEncoder().encode(jweAad), + ) + + const ciphertext = base64url.encode(encryption.ciphertext) + const tag = base64url.encode(encryption.tag) + jwe.ciphertext = ciphertext; + jwe.iv = iv; + jwe.tag = tag; + // for each recipient public key, encrypt the content encryption key to the recipient public key + // and add the result to the unprotected header recipients property + + jwe.recipients = unprotectedHeader.recipients + if (jwe.recipients.length === 0){ + jwe.recipients = undefined + } + + if (req.additionalAuthenticatedData) { + jwe.aad = base64url.encode(req.additionalAuthenticatedData) + } + + const general = sortJsonSerialization(jwe); + if (options.serialization === 'GeneralJson'){ + return general + } + if (options.serialization === 'Compact'){ + if (general.recipients !== undefined){ + throw new Error('Compact serialization does not support multiple recipients') + } + const compact = `${general.protected}.${general.encrypted_key}.${general.iv}.${general.ciphertext}.${general.tag}` + return compact + } + + throw new Error('Unsupported serialization') +} + +export type RequestGeneralDecrypt = { + jwe: string | any, // need types + privateKeys: JWKS +} + + +const produceDecryptionResult = async (protectedHeader: string, ciphertext: string, tag: string, iv: string, cek: Uint8Array, aad ?: string) => { + const ct = base64url.decode(ciphertext) + const initializationVector = base64url.decode(iv); + const parsedProtectedHeader = JSON.parse(new TextDecoder().decode(base64url.decode(protectedHeader))) + + let jweAad = protectedHeader + if (aad){ + jweAad += '.' + aad + } + + const plaintext = await mixed.gcmDecrypt( + parsedProtectedHeader.enc, + cek, + ct, + initializationVector, + base64url.decode(tag), + new TextEncoder().encode(jweAad), + ) + const decryption = { plaintext: new Uint8Array(plaintext) } as any; + decryption.protectedHeader = parsedProtectedHeader; + if (aad){ + decryption.aad = base64url.decode(aad); + } + return decryption +} + +export const decrypt = async (req: RequestGeneralDecrypt, options = {serialization: 'GeneralJson'}): Promise => { + let { protected: protectedHeader, recipients, iv, ciphertext, aad, tag } = {} as any + let encrypted_key; + if (options.serialization === 'GeneralJson'){ + if (typeof req.jwe !== 'object'){ + throw new Error('GeneralJson decrypt requires jwe as object') + } + ({ protected: protectedHeader, encrypted_key, recipients, iv, ciphertext, aad, tag } = req.jwe); + } + + if (recipients === undefined && options.serialization !== 'Compact' && typeof req.jwe !== 'string'){ + if (req.privateKeys.keys.length !== 1){ + throw new Error('Expected single private key for single recipient general json') + } + const parsedProtectedHeader = JSON.parse(new TextDecoder().decode(base64url.decode(protectedHeader))) + recipients = [ + { + encrypted_key, + header: { + kid: req.privateKeys.keys[0].kid, + alg: req.privateKeys.keys[0].alg, + epk: parsedProtectedHeader.epk + } + } + ] + } + + if (options.serialization === 'Compact'){ + if (typeof req.jwe === 'object'){ + throw new Error('Compact decrypt requires jwe as string') + } + ([protectedHeader, encrypted_key, iv, ciphertext, tag] = req.jwe.split('.')) + const parsedProtectedHeader = JSON.parse(new TextDecoder().decode(base64url.decode(protectedHeader))) + recipients = [ + { + encrypted_key, + header: { + kid: req.privateKeys.keys[0].kid, + alg: req.privateKeys.keys[0].alg, + epk: parsedProtectedHeader.epk + } + } + ] + } + + // find a recipient for which we have a private key + let matchingRecipient = undefined + let matchingPrivateKey = undefined + for (const privateKey of req.privateKeys.keys) { + const recipient = recipients.find((r: HPKERecipient) => { + return r.header.kid === privateKey.kid + }) + if (recipient) { + // we have a private key for this recipient + matchingRecipient = recipient; + matchingPrivateKey = privateKey; + break + } + } + + if (!matchingRecipient || !matchingPrivateKey) { + throw new Error('No decryption key found for the given recipients') + } + + if (isKeyAlgorithmSupported(matchingPrivateKey)) { + // We could check here to see if the "enc" in the protected header + // matches the last part of the "alg" on the private key. + + const suite = suites[matchingPrivateKey.alg as JOSE_HPKE_ALG] + + // selected the encapsulated_key for the recipient + const { encrypted_key, header } = matchingRecipient; + const { epk: {ek: encapsulated_key} } = header + + // create the HPKE recipient + const recipient = await suite.createRecipientContext({ + recipientKey: await privateKeyFromJwk(matchingPrivateKey), + enc: base64url.decode(encapsulated_key) + }) + + // compute the additional data from the protected header + let jweAad = protectedHeader + if (aad){ + jweAad += '.' + aad + } + const hpkeOpenAad = new TextEncoder().encode(jweAad) + + // open the content encryption key for the given content encryption algorithm + // which is described in the protected header + const contentEncryptionKey = new Uint8Array(await recipient.open(base64url.decode(encrypted_key), hpkeOpenAad)) + + // determine the content encryption algorithm + // now that we know we have a key that supports it + return produceDecryptionResult(protectedHeader, ciphertext, tag, iv, contentEncryptionKey, aad); + } else if (matchingPrivateKey.alg === 'ECDH-ES+A128KW') { + // compute the shared secret from the recipient + const sharedSecret = await mixed.deriveKey( matchingRecipient.header.epk, matchingPrivateKey) + const encryptedKey = jose.base64url.decode(matchingRecipient.encrypted_key) + // unrwap the content encryption key + const contentEncryptionKey = mixed.unwrap('A128KW', sharedSecret, encryptedKey) + // the test is the same for both HPKE-Base-P256-SHA256-AES128GCM and ECDH-ES+A128KW with A128GCM + return produceDecryptionResult(protectedHeader, ciphertext, tag, iv, contentEncryptionKey, aad); + } else { + throw new Error('Private key algorithm not supported.') + } + +} \ No newline at end of file diff --git a/test/jose-hpke/src/index.ts b/test/jose-hpke/src/index.ts new file mode 100644 index 0000000..1372b0b --- /dev/null +++ b/test/jose-hpke/src/index.ts @@ -0,0 +1,5 @@ +import * as key from './keys' +import * as IntegratedEncryption from './IntegratedEncryption' +import * as KeyEncryption from './KeyEncryption' + +export { key, IntegratedEncryption, KeyEncryption } \ No newline at end of file diff --git a/test/jose-hpke/src/keys.ts b/test/jose-hpke/src/keys.ts new file mode 100644 index 0000000..ad33610 --- /dev/null +++ b/test/jose-hpke/src/keys.ts @@ -0,0 +1,108 @@ + +import crypto from 'crypto'; + +import { generateKeyPair, exportJWK, calculateJwkThumbprintUri } from "jose" + +import { AeadId, CipherSuite, KdfId, KemId } from "hpke-js"; + +export type JOSE_HPKE_ALG = `HPKE-Base-P256-SHA256-AES128GCM` | `HPKE-Base-P384-SHA256-AES128GCM` + +export type JWK = { + kid?:string + alg?: string + kty: string + crv: string +} + +export type JWKS = { + keys: JWK[] +} + +export type HPKERecipient = { + encrypted_key: string + header: { + kid?: string + alg?: string + epk?: JWK + encapsulated_key: string, + } +} + + +export const suites = { + ['HPKE-Base-P256-SHA256-AES128GCM']: new CipherSuite({ + kem: KemId.DhkemP256HkdfSha256, + kdf: KdfId.HkdfSha256, + aead: AeadId.Aes128Gcm, + }), + ['HPKE-Base-P384-SHA256-AES128GCM']: new CipherSuite({ + kem: KemId.DhkemP384HkdfSha384, + kdf: KdfId.HkdfSha256, + aead: AeadId.Aes128Gcm, + }) +} + +export const isKeyAlgorithmSupported = (recipient: JWK) => { + const supported_alg = Object.keys(suites) as string [] + return supported_alg.includes(`${recipient.alg}`) +} + +export const formatJWK = (jwk: any) => { + const { kid, alg, kty, crv, x, y, d } = jwk + return JSON.parse(JSON.stringify({ + kid, alg, kty, crv, x, y, d + })) +} + +export const publicFromPrivate = (privateKeyJwk: any) => { + const { kid, alg, kty, crv, x, y, ...rest } = privateKeyJwk + return { + kid, alg, kty, crv, x, y + } +} + +export const publicKeyFromJwk = async (publicKeyJwk: any) => { + const publicKey = await crypto.subtle.importKey( + 'jwk', + publicKeyJwk, + { + name: 'ECDH', + namedCurve: publicKeyJwk.crv, + }, + true, + [], + ) + return publicKey; +} + +export const privateKeyFromJwk = async (privateKeyJwk: any)=>{ + const privateKey = await crypto.subtle.importKey( + 'jwk', + privateKeyJwk, + { + name: 'ECDH', + namedCurve: privateKeyJwk.crv, + }, + true, + ['deriveBits', 'deriveKey'], + ) + return privateKey +} + +export const generate = async (alg: JOSE_HPKE_ALG) => { + if (!suites[alg]){ + throw new Error('Algorithm not supported') + } + let kp; + if (alg.includes('P256')){ + kp = await generateKeyPair('ECDH-ES+A256KW', { crv: 'P-256', extractable: true }) + } else if (alg.includes('P384')){ + kp = await generateKeyPair('ECDH-ES+A256KW', { crv: 'P-384', extractable: true }) + } else { + throw new Error('Could not generate private key for ' + alg) + } + const privateKeyJwk = await exportJWK(kp.privateKey); + privateKeyJwk.kid = await calculateJwkThumbprintUri(privateKeyJwk) + privateKeyJwk.alg = alg; + return formatJWK(privateKeyJwk) +} \ No newline at end of file diff --git a/test/jose-hpke/src/mixed.ts b/test/jose-hpke/src/mixed.ts new file mode 100644 index 0000000..5dc57a1 --- /dev/null +++ b/test/jose-hpke/src/mixed.ts @@ -0,0 +1,154 @@ + +import crypto from 'crypto'; + +import { publicKeyFromJwk, privateKeyFromJwk } from './keys'; +import { createHash, createSecretKey, createDecipheriv, createCipheriv } from 'node:crypto' + +// https://github.com/panva/jose/blob/08eff759a032585a950d79e6989dfcb373a8900e/src/lib/buffer_utils.ts#L49 +// had to pull all this stuff out, becuase its not exposed in the module... + +const digest: any = ( + algorithm: 'sha256' | 'sha384' | 'sha512', + data: Uint8Array, +): Uint8Array => createHash(algorithm).update(data).digest() + +const MAX_INT32 = 2 ** 32 + +function writeUInt32BE(buf: Uint8Array, value: number, offset?: number) { + if (value < 0 || value >= MAX_INT32) { + throw new RangeError(`value must be >= 0 and <= ${MAX_INT32 - 1}. Received ${value}`) + } + buf.set([value >>> 24, value >>> 16, value >>> 8, value & 0xff], offset) +} +export function uint32be(value: number) { + const buf = new Uint8Array(4) + writeUInt32BE(buf, value) + return buf +} + +export async function concatKdf(secret: Uint8Array, bits: number, value: Uint8Array) { + const iterations = Math.ceil((bits >> 3) / 32) + const res = new Uint8Array(iterations * 32) + for (let iter = 0; iter < iterations; iter++) { + const buf = new Uint8Array(4 + secret.length + value.length) + buf.set(uint32be(iter + 1)) + buf.set(secret, 4) + buf.set(value, 4 + secret.length) + res.set(await digest('sha256', buf), iter * 32) + } + return res.slice(0, bits >> 3) +} + + +export function concat(...buffers: Uint8Array[]): Uint8Array { + const size = buffers.reduce((acc, { length }) => acc + length, 0) + const buf = new Uint8Array(size) + let i = 0 + for (const buffer of buffers) { + buf.set(buffer, i) + i += buffer.length + } + return buf +} + +export function lengthAndInput(input: Uint8Array) { + return concat(uint32be(input.length), input) +} + +export const deriveKey = async (publicKeyJwk: any, privateKeyJwk: any) => { + const length = Math.ceil(parseInt('P-256'.substr(-3), 10) / 8) << 3 + const sharedSecret = new Uint8Array( + await crypto.subtle.deriveBits( + { + name: 'ECDH', + public: await publicKeyFromJwk(publicKeyJwk), + }, + await privateKeyFromJwk(privateKeyJwk), + length, + ), + ) + const algorithm = 'ECDH-ES+A128KW' + const keyLength = 128; + const apu = new Uint8Array(0) + const apv = new Uint8Array(0) + const encoder = new TextEncoder() + const value = concat( + lengthAndInput(encoder.encode(algorithm)), + lengthAndInput(apu), + lengthAndInput(apv), + uint32be(keyLength), + ) + return concatKdf(sharedSecret, keyLength, value); +} + +export const wrap: any = (alg: string, key: unknown, cek: Uint8Array) => { + const size = parseInt(alg.slice(1, 4), 10) + const algorithm = `aes${size}-wrap` + const keyObject = createSecretKey(key as any) + const cipher = createCipheriv(algorithm, keyObject, Buffer.alloc(8, 0xa6)) + return concat(cipher.update(cek), cipher.final()) +} + +export const unwrap: any = ( + alg: string, + key: Uint8Array, + encryptedKey: Uint8Array, +) => { + const size = parseInt(alg.slice(1, 4), 10) + const algorithm = `aes${size}-wrap` + const keyObject = createSecretKey(key as any) + const cipher = createDecipheriv(algorithm, keyObject, Buffer.alloc(8, 0xa6)) + return concat(cipher.update(encryptedKey), cipher.final()) +} + +export function gcmEncrypt( + enc: string, + plaintext: Uint8Array, + cek: Uint8Array, + iv: Uint8Array, + aad: Uint8Array, +) { + const keySize = parseInt(enc.slice(1, 4), 10) + const algorithm = `aes-${keySize}-gcm` + + const cipher = createCipheriv(algorithm, cek, iv, { authTagLength: 16 } as any) as any + if (aad.byteLength) { + cipher.setAAD(aad, { plaintextLength: plaintext.length }) + } + + const ciphertext = cipher.update(plaintext) + cipher.final() + const tag = cipher.getAuthTag() + + return { ciphertext, tag } +} + + +export function gcmDecrypt( + enc: string, + cek: Uint8Array, + ciphertext: Uint8Array, + iv: Uint8Array, + tag: Uint8Array, + aad: Uint8Array, +) { + + const keySize = parseInt(enc.slice(1, 4), 10) + const algorithm = `aes-${keySize}-gcm` + + try { + const decipher = createDecipheriv(algorithm, cek, iv, { authTagLength: 16 } as any) as any + decipher.setAuthTag(tag) + if (aad.byteLength) { + decipher.setAAD(aad, { plaintextLength: ciphertext.length }) + } + + const plaintext = decipher.update(ciphertext) + decipher.final() + return plaintext + } catch (e){ + console.log(e) + throw new Error('XXXX Decryption failed.') + } +} + diff --git a/test/jose-hpke/tests/IntegratedEncryption.test.ts b/test/jose-hpke/tests/IntegratedEncryption.test.ts new file mode 100644 index 0000000..ae8163c --- /dev/null +++ b/test/jose-hpke/tests/IntegratedEncryption.test.ts @@ -0,0 +1,31 @@ + +import * as hpke from '../src' + + +describe('encrypt / decrypt ', () => { + it('Compact', async () => { + const privateKeyJwk = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKeyJwk = await hpke.key.publicFromPrivate(privateKeyJwk) + const message = `It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.` + const plaintext = new TextEncoder().encode(message); + const jwe = await hpke.IntegratedEncryption.encrypt(plaintext, publicKeyJwk) + expect(jwe.split('.').length).toBe(5) // compact jwe is default + const recovered = await hpke.IntegratedEncryption.decrypt(jwe, privateKeyJwk) + expect(new TextDecoder().decode(recovered)).toBe(message); + }) + it('JSON', async () => { + const privateKeyJwk = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKeyJwk = await hpke.key.publicFromPrivate(privateKeyJwk) + const message = `It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.` + const plaintext = new TextEncoder().encode(message); + const jwe = await hpke.IntegratedEncryption.encrypt(plaintext, publicKeyJwk, { serialization: 'GeneralJson' }) + expect(jwe.protected).toBeDefined() + expect(jwe.ciphertext).toBeDefined() + expect(jwe.iv).toBeUndefined() + expect(jwe.tag).toBeUndefined() + expect(jwe.encrypted_key).toBeUndefined() + const recovered = await hpke.IntegratedEncryption.decrypt(jwe, privateKeyJwk, { serialization: 'GeneralJson' }) + expect(new TextDecoder().decode(recovered)).toBe(message); + + }) +}) diff --git a/test/jose-hpke/tests/KeyEncryption.test.ts b/test/jose-hpke/tests/KeyEncryption.test.ts new file mode 100644 index 0000000..a0313f9 --- /dev/null +++ b/test/jose-hpke/tests/KeyEncryption.test.ts @@ -0,0 +1,169 @@ + +import * as hpke from '../src' + + +describe('KeyEncryption', () => { + + it('Single Recipient JSON (with aad)', async () => { + // recipient 1 + const privateKey1 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKey1 = await hpke.key.publicFromPrivate(privateKey1) + const resolvePrivateKey = (kid: string) => { + if (kid === publicKey1.kid) { + return privateKey1 + } + throw new Error('Unknown kid') + } + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey1 + ] + } + const aad = new TextEncoder().encode('πŸ’€ aad') + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + const contentEncryptionAlgorithm = 'A128GCM' + const jwe = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + additionalAuthenticatedData: aad, + recipients: recipientPublicKeys + }, { serialization: 'GeneralJson' }); + const privateKey = resolvePrivateKey(publicKey1.kid) + const recipientPrivateKeys = { "keys": [privateKey] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe, privateKeys: recipientPrivateKeys }, { serialization: 'GeneralJson' }) + expect(decryption.protectedHeader.epk.kty).toBe('EK') + expect(decryption.protectedHeader.enc).toBe('A128GCM') + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + expect(new TextDecoder().decode(decryption.aad)).toBe('πŸ’€ aad'); + }) + + it('Single Recipient JSON (without aad)', async () => { + // recipient 1 + const privateKey1 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKey1 = await hpke.key.publicFromPrivate(privateKey1) + const resolvePrivateKey = (kid: string) => { + if (kid === publicKey1.kid) { + return privateKey1 + } + throw new Error('Unknown kid') + } + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey1 + ] + } + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + const contentEncryptionAlgorithm = 'A128GCM' + const jwe = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + recipients: recipientPublicKeys + }, { serialization: 'GeneralJson' }); + const privateKey = resolvePrivateKey(publicKey1.kid) + const recipientPrivateKeys = { "keys": [privateKey] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe, privateKeys: recipientPrivateKeys }, { serialization: 'GeneralJson' }) + expect(decryption.protectedHeader.epk.kty).toBe('EK') + expect(decryption.protectedHeader.enc).toBe('A128GCM') + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + expect(decryption.aad).toBeUndefined() + }) + + // expect alg to be in protected header + it.todo('Multiple Recipients all the same alg') + + // expect alg to be in unprotected header + it.todo('Multiple Recipients all the same alg') + + + it('Multiple Recipients General JSON', async () => { + // recipient 1 + const privateKey1 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKey1 = await hpke.key.publicFromPrivate(privateKey1) + + // recipient 2 + const privateKey2 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKey2 = await hpke.key.publicFromPrivate(privateKey2) + + const resolvePrivateKey = (kid: string) => { + if (kid === publicKey1.kid) { + return privateKey1 + } + if (kid === publicKey2.kid) { + return privateKey2 + } + throw new Error('Unknown kid') + } + + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey1, + publicKey2 + ] + } + + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + const aad = new TextEncoder().encode('πŸ’€ aad') + const contentEncryptionAlgorithm = 'A128GCM' + + const ciphertext = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + additionalAuthenticatedData: aad, + recipients: recipientPublicKeys + }); + + for (const recipient of recipientPublicKeys.keys) { + const privateKey = resolvePrivateKey(recipient.kid) + // simulate having only one of the recipient private keys + const recipientPrivateKeys = { "keys": [privateKey] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe: ciphertext, privateKeys: recipientPrivateKeys }) + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + expect(decryption.aad).toBeDefined() + expect(new TextDecoder().decode(decryption.aad)).toBe('πŸ’€ aad'); + } + + + + }) + + + it('Single Recipient Compact (no aad)', async () => { + // recipient 1 + const privateKey1 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKey1 = await hpke.key.publicFromPrivate(privateKey1) + const resolvePrivateKey = (kid: string) => { + if (kid === publicKey1.kid) { + return privateKey1 + } + throw new Error('Unknown kid') + } + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey1 + ] + } + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + const contentEncryptionAlgorithm = 'A128GCM' + const jwe = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + recipients: recipientPublicKeys + }, { serialization: 'Compact' }); + + expect(jwe.split('.').length).toBe(5) // compact jwe is supported + + const privateKey = resolvePrivateKey(publicKey1.kid) + // simulate having only one of the recipient private keys + const recipientPrivateKeys = { "keys": [privateKey] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe, privateKeys: recipientPrivateKeys }, { serialization: 'Compact' }) + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + expect(decryption.aad).toBeUndefined() + + + + }) +}) diff --git a/test/jose-hpke/tests/cross.no.aad.test.ts b/test/jose-hpke/tests/cross.no.aad.test.ts new file mode 100644 index 0000000..3a7f596 --- /dev/null +++ b/test/jose-hpke/tests/cross.no.aad.test.ts @@ -0,0 +1,65 @@ + +import * as hpke from '../src' + +import * as jose from 'jose' + + +it('encrypt (theirs) / decrypt (ours)', async () => { + const key1 = await jose.generateKeyPair('ECDH-ES+A128KW', { crv: 'P-256', extractable: true }) + const key2 = await jose.generateKeyPair('RSA-OAEP-384') + const message = new TextEncoder().encode('✨ It’s a dangerous business, Frodo, going out your door. ✨') + + + const privateKeyJwk = await jose.exportJWK(key1.privateKey) as any; + privateKeyJwk.alg = 'ECDH-ES+A128KW' + const jwe = await new jose.GeneralEncrypt( + message + ) + .setProtectedHeader({ enc: 'A128GCM' }) + .addRecipient(key1.publicKey) + .setUnprotectedHeader({ alg: 'ECDH-ES+A128KW' }) + .addRecipient(key2.publicKey) + .setUnprotectedHeader({ alg: 'RSA-OAEP-384' }) + .encrypt() + + const decrypted = await jose.generalDecrypt(jwe, await jose.importJWK(privateKeyJwk)); + expect(new TextDecoder().decode(decrypted.additionalAuthenticatedData)).toBe('') + expect(new TextDecoder().decode(decrypted.plaintext)).toBe('✨ It’s a dangerous business, Frodo, going out your door. ✨') + expect(decrypted.protectedHeader).toEqual({ + "enc": "A128GCM" + }) + + // simulate having only one of the recipient private keys + const recipientPrivateKeys = { "keys": [privateKeyJwk] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe, privateKeys: recipientPrivateKeys }) + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`✨ It’s a dangerous business, Frodo, going out your door. ✨`); + expect(new TextDecoder().decode(decryption.aad)).toBe(''); +}) + +it('encrypt (ours) / decrypt (theirs)', async () => { + // recipient 2 + const privateKey2 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + privateKey2.alg = 'ECDH-ES+A128KW' // overwrite algorithm + const publicKey2 = await hpke.key.publicFromPrivate(privateKey2) + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey2 + ] + } + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + + const contentEncryptionAlgorithm = 'A128GCM' + const jwe = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + additionalAuthenticatedData: undefined, + recipients: recipientPublicKeys + }); + const decrypted = await jose.generalDecrypt(jwe, await jose.importJWK(privateKey2)); + expect(new TextDecoder().decode(decrypted.additionalAuthenticatedData)).toBe('') + expect(new TextDecoder().decode(decrypted.plaintext)).toBe('It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.') + expect(decrypted.protectedHeader).toEqual({ + "enc": "A128GCM" + }) +}) \ No newline at end of file diff --git a/test/jose-hpke/tests/cross.test.ts b/test/jose-hpke/tests/cross.test.ts new file mode 100644 index 0000000..4e965dd --- /dev/null +++ b/test/jose-hpke/tests/cross.test.ts @@ -0,0 +1,66 @@ + +import * as hpke from '../src' + +import * as jose from 'jose' + + +it('encrypt (theirs) / decrypt (ours)', async () => { + const key1 = await jose.generateKeyPair('ECDH-ES+A128KW', { crv: 'P-256', extractable: true }) + const key2 = await jose.generateKeyPair('RSA-OAEP-384') + const message = new TextEncoder().encode('✨ It’s a dangerous business, Frodo, going out your door. ✨') + const aad = new TextEncoder().encode('πŸ’€ aad') + + const privateKeyJwk = await jose.exportJWK(key1.privateKey) as any; + privateKeyJwk.alg = 'ECDH-ES+A128KW' + const jwe = await new jose.GeneralEncrypt( + message + ) + .setAdditionalAuthenticatedData(aad) + .setProtectedHeader({ enc: 'A128GCM' }) + .addRecipient(key1.publicKey) + .setUnprotectedHeader({ alg: 'ECDH-ES+A128KW' }) + .addRecipient(key2.publicKey) + .setUnprotectedHeader({ alg: 'RSA-OAEP-384' }) + .encrypt() + + const decrypted = await jose.generalDecrypt(jwe, await jose.importJWK(privateKeyJwk)); + expect(new TextDecoder().decode(decrypted.additionalAuthenticatedData)).toBe('πŸ’€ aad') + expect(new TextDecoder().decode(decrypted.plaintext)).toBe('✨ It’s a dangerous business, Frodo, going out your door. ✨') + expect(decrypted.protectedHeader).toEqual({ + "enc": "A128GCM" + }) + + // simulate having only one of the recipient private keys + const recipientPrivateKeys = { "keys": [privateKeyJwk] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe, privateKeys: recipientPrivateKeys }) + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`✨ It’s a dangerous business, Frodo, going out your door. ✨`); + expect(new TextDecoder().decode(decryption.aad)).toBe('πŸ’€ aad'); +}) + +it('encrypt (ours) / decrypt (theirs)', async () => { + // recipient 2 + const privateKey2 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + privateKey2.alg = 'ECDH-ES+A128KW' // overwrite algorithm + const publicKey2 = await hpke.key.publicFromPrivate(privateKey2) + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey2 + ] + } + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + const aad = new TextEncoder().encode('πŸ’€ aad') + const contentEncryptionAlgorithm = 'A128GCM' + const jwe = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + additionalAuthenticatedData: aad, + recipients: recipientPublicKeys + }); + const decrypted = await jose.generalDecrypt(jwe, await jose.importJWK(privateKey2)); + expect(new TextDecoder().decode(decrypted.additionalAuthenticatedData)).toBe('πŸ’€ aad') + expect(new TextDecoder().decode(decrypted.plaintext)).toBe('It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.') + expect(decrypted.protectedHeader).toEqual({ + "enc": "A128GCM" + }) +}) \ No newline at end of file diff --git a/test/jose-hpke/tests/jwe.sanity.test.ts b/test/jose-hpke/tests/jwe.sanity.test.ts new file mode 100644 index 0000000..aab83c5 --- /dev/null +++ b/test/jose-hpke/tests/jwe.sanity.test.ts @@ -0,0 +1,76 @@ + +import * as jose from 'jose' + +import * as mixed from '../src/mixed' + +it('jwe json multiple recipient', async () => { + const key1 = await jose.generateKeyPair('ECDH-ES+A128KW', { crv: 'P-256', extractable: true }) + const key2 = await jose.generateKeyPair('RSA-OAEP-384') + const message = new TextEncoder().encode('✨ It’s a dangerous business, Frodo, going out your door. ✨') + const aad = new TextEncoder().encode('πŸ’€ aad') + const jwe = await new jose.GeneralEncrypt( + message + ) + .setAdditionalAuthenticatedData(aad) + .setProtectedHeader({ enc: 'A128GCM' }) + .addRecipient(key1.publicKey) + .setUnprotectedHeader({ alg: 'ECDH-ES+A128KW' }) + .addRecipient(key2.publicKey) + .setUnprotectedHeader({ alg: 'RSA-OAEP-384' }) + .encrypt() + + const { plaintext, protectedHeader, additionalAuthenticatedData } = await jose.generalDecrypt(jwe, key1.privateKey) as any; + expect(new TextDecoder().decode(additionalAuthenticatedData)).toBe('πŸ’€ aad') + expect(new TextDecoder().decode(plaintext)).toBe('✨ It’s a dangerous business, Frodo, going out your door. ✨') + + expect(protectedHeader.enc).toBe('A128GCM') + expect(protectedHeader.alg).toBeUndefined() + expect(protectedHeader.epk).toBeUndefined() + + // some extra tests here to confirm key wrapping basics + const [r0] = jwe.recipients as any; + const sharedSecret = await mixed.deriveKey(r0.header.epk, await jose.exportJWK(key1.privateKey)) + const encryptedKey = jose.base64url.decode(r0.encrypted_key) + const cek = mixed.unwrap('A128KW', sharedSecret, encryptedKey) + const kwkc = Buffer.from(mixed.wrap('A128KW', sharedSecret, cek)) + expect(encryptedKey).toEqual(kwkc) +}) + + +it('jwe json single recipient', async () => { + const key1 = await jose.generateKeyPair('ECDH-ES+A128KW', { crv: 'P-256', extractable: true }) + const message = new TextEncoder().encode('✨ It’s a dangerous business, Frodo, going out your door. ✨') + const aad = new TextEncoder().encode('πŸ’€ aad') + const jwe = await new jose.GeneralEncrypt( + message + ) + .setAdditionalAuthenticatedData(aad) + .setProtectedHeader({ enc: 'A128GCM' }) + .addRecipient(key1.publicKey) + .setUnprotectedHeader({ alg: 'ECDH-ES+A128KW' }) + .encrypt() + const { plaintext, protectedHeader, additionalAuthenticatedData } = await jose.generalDecrypt(jwe, key1.privateKey) as any; + expect(new TextDecoder().decode(additionalAuthenticatedData)).toBe('πŸ’€ aad') + expect(new TextDecoder().decode(plaintext)).toBe('✨ It’s a dangerous business, Frodo, going out your door. ✨') + expect(protectedHeader.alg).toBeUndefined() + expect(protectedHeader.enc).toBe('A128GCM') + expect(protectedHeader.epk.kty).toBe('EC') + expect(protectedHeader.epk.crv).toBe('P-256') + +}) + +it('jwe compact', async () => { + const key1 = await jose.generateKeyPair('ECDH-ES+A128KW', { crv: 'P-256', extractable: true }) + const jwe = await new jose.CompactEncrypt( + new TextEncoder().encode('It’s a dangerous business, Frodo, going out your door.'), + ) + .setProtectedHeader({ alg: 'ECDH-ES+A128KW', enc: 'A128GCM' }) + .encrypt(key1.publicKey) + const { plaintext, protectedHeader } = await jose.compactDecrypt(jwe, key1.privateKey) as any + expect(protectedHeader.alg).toBe('ECDH-ES+A128KW') + expect(protectedHeader.enc).toBe('A128GCM') + expect(protectedHeader.epk.kty).toBe('EC') + expect(protectedHeader.epk.crv).toBe('P-256') + // protected header also protectes the epk. + expect(new TextDecoder().decode(plaintext)).toBe('It’s a dangerous business, Frodo, going out your door.') +}) \ No newline at end of file diff --git a/test/jose-hpke/tests/mixed.test.ts b/test/jose-hpke/tests/mixed.test.ts new file mode 100644 index 0000000..206a0a6 --- /dev/null +++ b/test/jose-hpke/tests/mixed.test.ts @@ -0,0 +1,49 @@ +// import fs from 'fs' + +import * as hpke from '../src' + +it('encrypt / decrypt', async () => { + // recipient 1 + const privateKey1 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + const publicKey1 = await hpke.key.publicFromPrivate(privateKey1) + // recipient 2 + const privateKey2 = await hpke.key.generate('HPKE-Base-P256-SHA256-AES128GCM') + privateKey2.alg = 'ECDH-ES+A128KW' // overwrite algorithm + const publicKey2 = await hpke.key.publicFromPrivate(privateKey2) + const resolvePrivateKey = (kid: string): any => { + if (kid === publicKey1.kid) { + return privateKey1 + } + if (kid === publicKey2.kid) { + return privateKey2 + } + throw new Error('Unknown kid') + } + // recipients as a JWKS + const recipientPublicKeys = { + "keys": [ + publicKey1, + publicKey2 + ] + } + const plaintext = new TextEncoder().encode(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + const aad = new TextEncoder().encode('πŸ’€ aad') + const contentEncryptionAlgorithm = 'A128GCM' + const jwe = await hpke.KeyEncryption.encrypt({ + protectedHeader: { enc: contentEncryptionAlgorithm }, + plaintext, + additionalAuthenticatedData: aad, + recipients: recipientPublicKeys + }); + // console.log(JSON.stringify(jwe, null, 2)) + // fs.writeFileSync('./example.jwe.json', JSON.stringify(jwe, null, 2)) + for (const recipient of recipientPublicKeys.keys) { + const privateKey = resolvePrivateKey(recipient.kid) + // simulate having only one of the recipient private keys + const recipientPrivateKeys = { "keys": [privateKey] } + const decryption = await hpke.KeyEncryption.decrypt({ jwe, privateKeys: recipientPrivateKeys }) + expect(new TextDecoder().decode(decryption.plaintext)).toBe(`It’s a πŸ’€ dangerous business πŸ’€, Frodo, going out your door.`); + expect(new TextDecoder().decode(decryption.aad)).toBe('πŸ’€ aad'); + } + +}) \ No newline at end of file