From f9252f27b4b3069c4a93341cf2668570f80776fb Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Fri, 23 Feb 2024 14:08:46 -0600 Subject: [PATCH] add hpke direct --- src/cose/encrypt/direct.ts | 32 +++++++--------- src/cose/encrypt/hpke.ts | 75 ++++++++++++++++++++++++++++++++++++-- src/cose/encrypt/utils.ts | 25 ++++++++++++- test/hpke.test.ts | 43 ++++++++++++++++++++++ 4 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/cose/encrypt/direct.ts b/src/cose/encrypt/direct.ts index 016328a..0bdb7a4 100644 --- a/src/cose/encrypt/direct.ts +++ b/src/cose/encrypt/direct.ts @@ -1,6 +1,6 @@ import { convertCoseKeyToJsonWebKey, convertJsonWebKeyToCoseKey, generate, publicFromPrivate } from "../key" -import { JsonWebKey } from "../key" + import { Tagged, decode, decodeFirst, encodeAsync } from "cbor-web" import { EMPTY_BUFFER } from "../../cbor" @@ -9,19 +9,10 @@ import * as aes from './aes' import * as ecdh from './ecdh' -import { createAAD, COSE_Encrypt_Tag } from './utils' +import { createAAD, COSE_Encrypt_Tag, RequestDirectEncryption, RequestDirectDecryption } from './utils' -export type JWKS = { - keys: JsonWebKey[] -} - -export type RequestEncryption = { - protectedHeader: Map - unprotectedHeader: Map - plaintext: Uint8Array, - recipients: JWKS -} +import * as hpke from './hpke' const getCoseAlgFromRecipientJwk = (jwk: any) => { if (jwk.crv === 'P-256') { @@ -29,7 +20,7 @@ const getCoseAlgFromRecipientJwk = (jwk: any) => { } } -export const encrypt = async (req: RequestEncryption) => { +export const encrypt = async (req: RequestDirectEncryption) => { if (req.recipients.keys.length !== 1) { throw new Error('Direct encryption requires a single recipient') } @@ -37,6 +28,9 @@ export const encrypt = async (req: RequestEncryption) => { if (recipientPublicKeyJwk.crv !== 'P-256') { throw new Error('Only P-256 is supported currently') } + if (recipientPublicKeyJwk.alg === hpke.primaryAlgorithm.label) { + return hpke.encrypt.direct(req) + } const alg = req.protectedHeader.get(1) const protectedHeader = await encodeAsync(req.protectedHeader) const unprotectedHeader = req.unprotectedHeader; @@ -67,17 +61,17 @@ export const encrypt = async (req: RequestEncryption) => { return encodeAsync(new Tagged(COSE_Encrypt_Tag, COSE_Encrypt), { canonical: true }) } -export type RequestDecryption = { - ciphertext: any, - recipients: JWKS -} -export const decrypt = async (req: RequestDecryption) => { +export const decrypt = async (req: RequestDirectDecryption) => { const decoded = await decodeFirst(req.ciphertext) if (decoded.tag !== 96) { throw new Error('Only tag 96 cose encrypt are supported') } + const receiverPrivateKeyJwk = req.recipients.keys[0] + if (receiverPrivateKeyJwk.alg === hpke.primaryAlgorithm.label) { + return hpke.decrypt.direct(req) + } const [protectedHeader, unprotectedHeader, ciphertext, recipients] = decoded.value if (recipients.length !== 1) { throw new Error('Expected a single recipient for direct decryption') @@ -93,7 +87,7 @@ export const decrypt = async (req: RequestDecryption) => { // ensure the epk has the algorithm that is set in the protected header epk.set(3, recipientAlgorithm) const senderPublicKeyJwk = await convertCoseKeyToJsonWebKey(epk) - const receiverPrivateKeyJwk = req.recipients.keys[0] + const cek = await ecdh.deriveKey(protectedHeader, recipientProtectedHeader, senderPublicKeyJwk, receiverPrivateKeyJwk) const aad = await createAAD(protectedHeader, 'Encrypt', EMPTY_BUFFER) const iv = unprotectedHeader.get(5) diff --git a/src/cose/encrypt/hpke.ts b/src/cose/encrypt/hpke.ts index 68ae21a..bcdfe86 100644 --- a/src/cose/encrypt/hpke.ts +++ b/src/cose/encrypt/hpke.ts @@ -1,5 +1,5 @@ -import { createAAD, COSE_Encrypt_Tag, RequestWrapDecryption, RequestWrapEncryption } from './utils' +import { createAAD, COSE_Encrypt_Tag, RequestWrapDecryption, RequestWrapEncryption, RequestDirectEncryption, RequestDirectDecryption } from './utils' import { EMPTY_BUFFER } from "../../cbor" import { Tagged, decodeFirst, encodeAsync } from "cbor-web" @@ -16,6 +16,7 @@ export type JOSE_HPKE_ALG = `HPKE-Base-P256-SHA256-AES128GCM` | `HPKE-Base-P384- import * as aes from './aes' import { encode } from 'cbor-web'; + export type JWK = { kid?: string alg?: string @@ -123,7 +124,7 @@ export const primaryAlgorithm = { export const secondaryAlgorithm = { 'label': `HPKE-Base-P384-SHA384-AES256GCM`, - 'value': 35 + 'value': 37 } export const directAlgorithm = { @@ -131,7 +132,10 @@ export const directAlgorithm = { 'value': 36 } -const computeHPKEAad = (protectedHeader: any, protectedRecipientHeader: any) => { +const computeHPKEAad = (protectedHeader: any, protectedRecipientHeader: any, direct = false) => { + if (direct) { + return protectedHeader + } return encode([protectedHeader, protectedRecipientHeader]) } @@ -188,10 +192,44 @@ const encryptWrap = async (req: RequestWrapEncryption) => { return encodeAsync(new Tagged(COSE_Encrypt_Tag, COSE_Encrypt), { canonical: true }) } +export const encryptDirect = async (req: RequestDirectEncryption) => { + const protectedHeader = await encodeAsync(req.protectedHeader) + const unprotectedHeader = req.unprotectedHeader; + const [recipientPublicKeyJwk] = req.recipients.keys + const suite = suites[recipientPublicKeyJwk.alg as JOSE_HPKE_ALG] + const sender = await suite.createSenderContext({ + recipientPublicKey: await publicKeyFromJwk(recipientPublicKeyJwk), + }); + const recipientProtectedHeader = await encodeAsync(new Map([ + [1, primaryAlgorithm.value], + ])) + const hpkeSealAad = computeHPKEAad(protectedHeader, recipientProtectedHeader, true) + const ciphertext = await sender.seal(req.plaintext, hpkeSealAad) + const recipientCoseKey = new Map([ + [1, 5], // kty: EK + [- 1, sender.enc] + ]) + const recipientUnprotectedHeader = new Map([ + [4, recipientPublicKeyJwk.kid], // kid + [-1, recipientCoseKey], // epk + ]) + const recipients = [[recipientProtectedHeader, recipientUnprotectedHeader, EMPTY_BUFFER]] + const COSE_Encrypt = [ + protectedHeader, + unprotectedHeader, + ciphertext, + recipients + ] + return encodeAsync(new Tagged(COSE_Encrypt_Tag, COSE_Encrypt), { canonical: true }) +} + + export const encrypt = { + direct: encryptDirect, wrap: encryptWrap } + export const decryptWrap = async (req: RequestWrapDecryption) => { const decoded = await decodeFirst(req.ciphertext) if (decoded.tag !== 96) { @@ -224,6 +262,35 @@ export const decryptWrap = async (req: RequestWrapDecryption) => { return aes.decrypt(alg, ciphertext, new Uint8Array(iv), new Uint8Array(aad), new Uint8Array(contentEncryptionKey)) } +export const decryptDirect = async (req: RequestDirectDecryption) => { + const decoded = await decodeFirst(req.ciphertext) + if (decoded.tag !== 96) { + throw new Error('Only tag 96 cose encrypt are supported') + } + const [protectedHeader, unprotectedHeader, ciphertext, recipients] = decoded.value + const [recipient] = recipients + + const [recipientProtectedHeader, recipientUnprotectedHeader, recipientCipherText] = recipient + const kid = recipientUnprotectedHeader.get(4).toString(); + const receiverPrivateKeyJwk = req.recipients.keys.find((k) => { + return k.kid === kid + }) + const decodedRecipientProtectedHeader = await decodeFirst(recipientProtectedHeader) + const recipientAlgorithm = decodedRecipientProtectedHeader.get(1) + const epk = recipientUnprotectedHeader.get(-1) + // ensure the epk has the algorithm that is set in the protected header + epk.set(3, recipientAlgorithm) // EPK is allowed to have an alg + const suite = suites[receiverPrivateKeyJwk.alg as JOSE_HPKE_ALG] + const hpkeRecipient = await suite.createRecipientContext({ + recipientKey: await privateKeyFromJwk(receiverPrivateKeyJwk), + enc: epk.get(-1) // ek + }) + const hpkeSealAad = computeHPKEAad(protectedHeader, recipientProtectedHeader, true) + const plaintext = await hpkeRecipient.open(ciphertext, hpkeSealAad) + return plaintext +} + export const decrypt = { - wrap: decryptWrap + wrap: decryptWrap, + direct: decryptDirect } \ No newline at end of file diff --git a/src/cose/encrypt/utils.ts b/src/cose/encrypt/utils.ts index ff8508f..5e694bc 100644 --- a/src/cose/encrypt/utils.ts +++ b/src/cose/encrypt/utils.ts @@ -4,6 +4,7 @@ import { encodeAsync } from "cbor-web" // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-empty-function const nodeCrypto = import('crypto').catch(() => { }) as any +import { JsonWebKey } from "../key" export const COSE_Encrypt_Tag = 96 @@ -16,7 +17,6 @@ export const getRandomBytes = async (byteLength = 16) => { } } - export async function createAAD(protectedHeader: BufferSource, context: any, externalAAD: BufferSource) { const encStructure = [ context, @@ -26,6 +26,11 @@ export async function createAAD(protectedHeader: BufferSource, context: any, ext return encodeAsync(encStructure); } + +export type JWKS = { + keys: JsonWebKey[] +} + export type RequestWrapEncryption = { protectedHeader: Map unprotectedHeader: Map @@ -42,3 +47,21 @@ export type RequestWrapDecryption = { keys: any[] } } + + +export type RequestDirectEncryption = { + protectedHeader: Map + unprotectedHeader: Map + plaintext: Uint8Array, + recipients: { + keys: any[] + } +} + +export type RequestDirectDecryption = { + ciphertext: any, + recipients: { + keys: any[] + } +} + diff --git a/test/hpke.test.ts b/test/hpke.test.ts index ba547ae..70fa1a6 100644 --- a/test/hpke.test.ts +++ b/test/hpke.test.ts @@ -40,4 +40,47 @@ it('wrap', async () => { } }) expect(new TextDecoder().decode(decrypted)).toBe("💀 My lungs taste the air of Time Blown past falling sands ⌛") +}) + + +it('direct', async () => { + const protectedHeader = new Map([ + [1, 35], // alg : Direct || HPKE-Base-P256-SHA256-AES128GCM + ]) + const unprotectedHeader = new Map([]) + const plaintext = new TextEncoder().encode("💀 My lungs taste the air of Time Blown past falling sands ⌛") + const ct = await transmute.encrypt.direct({ + protectedHeader, + unprotectedHeader, + plaintext, + recipients: { + keys: [{ + "kid": "meriadoc.brandybuck@buckland.example", + "alg": "HPKE-Base-P256-SHA256-AES128GCM", + "kty": "EC", + "crv": "P-256", + "x": "Ze2loSV3wrroKUN_4zhwGhCqo3Xhu1td4QjeQ5wIVR0", + "y": "HlLtdXARY_f55A3fnzQbPcm6hgr34Mp8p-nuzQCE0Zw", + // encrypt to public keys only + // "d": "r_kHyZ-a06rmxM3yESK84r1otSg-aQcVStkRhA-iCM8" + }] + } + }) + const decoded = transmute.cbor.decodeFirstSync(ct); + expect(decoded.tag).toBe(96) + const decrypted = await transmute.decrypt.direct({ + ciphertext: ct, + recipients: { + keys: [{ + "kid": "meriadoc.brandybuck@buckland.example", + "alg": "HPKE-Base-P256-SHA256-AES128GCM", + "kty": "EC", + "crv": "P-256", + "x": "Ze2loSV3wrroKUN_4zhwGhCqo3Xhu1td4QjeQ5wIVR0", + "y": "HlLtdXARY_f55A3fnzQbPcm6hgr34Mp8p-nuzQCE0Zw", + "d": "r_kHyZ-a06rmxM3yESK84r1otSg-aQcVStkRhA-iCM8" + }] + } + }) + expect(new TextDecoder().decode(decrypted)).toBe("💀 My lungs taste the air of Time Blown past falling sands ⌛") }) \ No newline at end of file