Skip to content

Commit

Permalink
feat: initial support for X25519 ECDH-ES+A256KW for JWE (#279)
Browse files Browse the repository at this point in the history
  • Loading branch information
mirceanis authored Apr 4, 2023
1 parent 78c2d3c commit 375b93c
Show file tree
Hide file tree
Showing 8 changed files with 554 additions and 54 deletions.
3 changes: 1 addition & 2 deletions jest.config.ts → jest.config.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Config } from 'jest'
import { defaults } from 'jest-config'

const config: Config = {
const config = {
moduleFileExtensions: [...defaults.moduleFileExtensions, 'mts'],
transform: {
'^.+\\.m?tsx?$': [
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"canonicalize": "^2.0.0",
"did-resolver": "^4.1.0",
"elliptic": "^6.5.4",
"isomorphic-webcrypto": "^2.3.8",
"js-sha3": "^0.8.0",
"multiformats": "^11.0.2",
"uint8arrays": "^4.0.3"
Expand Down
43 changes: 31 additions & 12 deletions src/JWE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type ProtectedHeader = Record<string, any> & Partial<RecipientHeader>
* The JWK representation of an ephemeral public key.
* See https://www.rfc-editor.org/rfc/rfc7518.html#section-6
*/
interface EphemeralPublicKey {
export interface EphemeralPublicKey {
kty?: string
//ECC
crv?: string
Expand All @@ -19,10 +19,15 @@ interface EphemeralPublicKey {
e?: string
}

export interface EphemeralKeyPair {
publicKey: EphemeralPublicKey
secretKey: Uint8Array
}

export interface RecipientHeader {
alg: string
iv: string
tag: string
alg?: string
iv?: string
tag?: string
epk?: EphemeralPublicKey
kid?: string
apv?: string
Expand Down Expand Up @@ -55,8 +60,14 @@ export interface EncryptionResult {
export interface Encrypter {
alg: string
enc: string
encrypt: (cleartext: Uint8Array, protectedHeader: ProtectedHeader, aad?: Uint8Array) => Promise<EncryptionResult>
encryptCek?: (cek: Uint8Array) => Promise<Recipient>
encrypt: (
cleartext: Uint8Array,
protectedHeader: ProtectedHeader,
aad?: Uint8Array,
ephemeralKeyPair?: EphemeralKeyPair
) => Promise<EncryptionResult>
encryptCek?: (cek: Uint8Array, ephemeralKeyPair?: EphemeralKeyPair) => Promise<Recipient>
genEpk?: () => EphemeralKeyPair
}

export interface Decrypter {
Expand Down Expand Up @@ -93,8 +104,9 @@ function encodeJWE({ ciphertext, tag, iv, protectedHeader, recipient }: Encrypti
export async function createJWE(
cleartext: Uint8Array,
encrypters: Encrypter[],
protectedHeader = {},
aad?: Uint8Array
protectedHeader: ProtectedHeader = {},
aad?: Uint8Array,
useSingleEphemeralKey = false
): Promise<JWE> {
if (encrypters[0].alg === 'dir') {
if (encrypters.length > 1) throw new Error('not_supported: Can only do "dir" encryption to one key.')
Expand All @@ -105,15 +117,22 @@ export async function createJWE(
if (!encrypters.reduce((acc, encrypter) => acc && encrypter.enc === tmpEnc, true)) {
throw new Error('invalid_argument: Incompatible encrypters passed')
}
let cek
let jwe
let cek: Uint8Array | undefined
let jwe: JWE | undefined
let epk: EphemeralKeyPair | undefined
if (useSingleEphemeralKey) {
epk = encrypters[0].genEpk?.()
const alg = encrypters[0].alg
protectedHeader = { ...protectedHeader, alg, epk: epk?.publicKey }
}

for (const encrypter of encrypters) {
if (!cek) {
const encryptionResult = await encrypter.encrypt(cleartext, protectedHeader, aad)
const encryptionResult = await encrypter.encrypt(cleartext, protectedHeader, aad, epk)
cek = encryptionResult.cek
jwe = encodeJWE(encryptionResult, aad)
} else {
const recipient = await encrypter.encryptCek?.(cek)
const recipient = await encrypter.encryptCek?.(cek, epk)
if (recipient) {
jwe?.recipients?.push(recipient)
}
Expand Down
61 changes: 57 additions & 4 deletions src/__tests__/JWE.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { fromString, toString } from 'uint8arrays'
import { randomBytes } from '@stablelib/random'
import { generateKeyPairFromSeed } from '@stablelib/x25519'
import { createJWE, Decrypter, decryptJWE, Encrypter, JWE } from '../JWE.js'
import { vectors } from './jwe-vectors.js'
import {
Expand All @@ -12,11 +15,9 @@ import {
xc20pDirDecrypter,
xc20pDirEncrypter,
} from '../xc20pEncryption.js'
import { decodeBase64url, encodeBase64url } from '../util.js'
import { fromString, toString } from 'uint8arrays'
import { randomBytes } from '@stablelib/random'
import { generateKeyPairFromSeed } from '@stablelib/x25519'
import { base64ToBytes, decodeBase64url, encodeBase64url } from '../util.js'
import { createX25519ECDH, ECDH } from '../ECDH.js'
import { x25519DecrypterWithA256KW, x25519EncrypterWithA256KW } from '../aesEncryption.js'

const u8a = { toString, fromString }

Expand Down Expand Up @@ -100,6 +101,19 @@ describe('JWE', () => {
await expect(decryptJWE(jwe as any, decrypter)).rejects.toThrowError('bad_jwe:')
})
})

describe('XC20P with X25519-ECDH-ES+A256KW', () => {
test.each(vectors['XC20P with X25519-ECDH-ES+A256KW'].pass)(
'decrypts valid jwe',
async ({ key, cleartext, jwe }) => {
expect.assertions(1)
const receiverSecret = base64ToBytes(key)
const decrypter = x25519DecrypterWithA256KW(receiverSecret)
const cleartextU8a = await decryptJWE(jwe as any, decrypter)
expect(u8a.toString(cleartextU8a)).toEqual(cleartext)
}
)
})
})

describe('createJWE', () => {
Expand Down Expand Up @@ -181,6 +195,45 @@ describe('JWE', () => {
})
})

describe('One recipient A256KW', () => {
let pubkey, secretkey, cleartext: Uint8Array, encrypter: Encrypter, decrypter: Decrypter

beforeEach(() => {
secretkey = randomBytes(32)
pubkey = generateKeyPairFromSeed(secretkey).publicKey
cleartext = u8a.fromString('hello world')
encrypter = x25519EncrypterWithA256KW(pubkey)
decrypter = x25519DecrypterWithA256KW(secretkey)
})

it('Creates with only ciphertext', async () => {
expect.assertions(3)
const jwe = await createJWE(cleartext, [encrypter], {}, undefined, true)
expect(jwe.aad).toBeUndefined()
expect(JSON.parse(decodeBase64url(jwe.protected)).enc).toEqual('XC20P')
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext)
})

it('Creates with data in protected header', async () => {
expect.assertions(3)
const jwe = await createJWE(cleartext, [encrypter], { more: 'protected' })
expect(jwe.aad).toBeUndefined()
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc: 'XC20P', more: 'protected' })
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext)
})

it('Creates with aad', async () => {
expect.assertions(4)
const aad = u8a.fromString('this data is authenticated')
const jwe = await createJWE(cleartext, [encrypter], { more: 'protected' }, aad)
expect(u8a.fromString(jwe.aad!!, 'base64url')).toEqual(aad)
expect(JSON.parse(decodeBase64url(jwe.protected))).toEqual({ enc: 'XC20P', more: 'protected' })
expect(await decryptJWE(jwe, decrypter)).toEqual(cleartext)
delete jwe.aad
await expect(decryptJWE(jwe, decrypter)).rejects.toThrowError('Failed to decrypt')
})
})

describe('Multiple recipients', () => {
let pubkey1, secretkey1, pubkey2, secretkey2, cleartext: Uint8Array
let encrypter1: Encrypter, decrypter1: Decrypter, encrypter2: Encrypter, decrypter2: Decrypter
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/jwe-vectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,4 +1002,31 @@ export const vectors = {
},
],
},
'XC20P with X25519-ECDH-ES+A256KW': {
pass: [
{
key: 'b9NnuOCB0hm7YGNvaE9DMhwH_wjZA1-gWD6dA0JWdL0',
cleartext:
'{"id":"1234567890","typ":"application/didcomm-plain+json","type":"http://example.com/protocols/lets_do_lunch/1.0/proposal","from":"did:example:alice","to":["did:example:bob"],"created_time":1516269022,"expires_time":1516385931,"body":{"messagespecificattribute":"and its value"}}',
jwe: {
ciphertext:
'KWS7gJU7TbyJlcT9dPkCw-ohNigGaHSukR9MUqFM0THbCTCNkY-g5tahBFyszlKIKXs7qOtqzYyWbPou2q77XlAeYs93IhF6NvaIjyNqYklvj-OtJt9W2Pj5CLOMdsR0C30wchGoXd6wEQZY4ttbzpxYznqPmJ0b9KW6ZP-l4_DSRYe9B-1oSWMNmqMPwluKbtguC-riy356Xbu2C9ShfWmpmjz1HyJWQhZfczuwkWWlE63g26FMskIZZd_jGpEhPFHKUXCFwbuiw_Iy3R0BIzmXXdK_w7PZMMPbaxssl2UeJmLQgCAP8j8TukxV96EKa6rGgULvlo7qibjJqsS5j03bnbxkuxwbfyu3OxwgVzFWlyHbUH6p',
protected:
'eyJlcGsiOnsia3R5IjoiT0tQIiwiY3J2IjoiWDI1NTE5IiwieCI6IkpIanNtSVJaQWFCMHpSR193TlhMVjJyUGdnRjAwaGRIYlc1cmo4ZzBJMjQifSwiYXB2IjoiTmNzdUFuclJmUEs2OUEtcmtaMEw5WFdVRzRqTXZOQzNaZzc0QlB6NTNQQSIsInR5cCI6ImFwcGxpY2F0aW9uL2RpZGNvbW0tZW5jcnlwdGVkK2pzb24iLCJlbmMiOiJYQzIwUCIsImFsZyI6IkVDREgtRVMrQTI1NktXIn0',
recipients: [
{
encrypted_key: '3n1olyBR3nY7ZGAprOx-b7wYAKza6cvOYjNwVg3miTnbLwPP_FmE1A',
header: {
kid: 'did:example:bob#key-x25519-1',
},
},
],
tag: '6ylC_iAs4JvDQzXeY6MuYQ',
iv: 'ESpmcyGiZpRjc5urDela21TOOTW8Wqd1',
},
},
],
fail: [],
invalid: [],
},
}
150 changes: 150 additions & 0 deletions src/aesEncryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { randomBytes } from '@stablelib/random'
import { generateKeyPair, generateKeyPairFromSeed, KeyPair as X25519KeyPair, sharedKey } from '@stablelib/x25519'
import { Decrypter, Encrypter, EncryptionResult, EphemeralKeyPair, ProtectedHeader, Recipient } from './JWE.js'
import { concatKDF } from './Digest.js'
import { base64ToBytes, bytesToBase64url } from './util.js'
import { genX25519EphemeralKeyPair, xc20pDirDecrypter, xc20pDirEncrypter } from './xc20pEncryption.js'
import crypto from 'isomorphic-webcrypto'
import { ECDH } from './ECDH'

export async function a256KeyWrapper(wrappingKey: Uint8Array) {
// TODO: check wrapping key size
const cryptoWrappingKey = await crypto.subtle.importKey(
'raw',
wrappingKey,
{
name: 'AES-KW',
length: 256,
},
false,
['wrapKey', 'unwrapKey']
)

return async (cek: Uint8Array): Promise<Uint8Array> => {
// create a CryptoKey instance from the cek. The algorithm doesn't matter since we'll be working with raw keys
const cryptoCek = await crypto.subtle.importKey('raw', cek, { hash: 'SHA-256', name: 'HMAC' }, true, ['sign'])
return new Uint8Array(await crypto.subtle.wrapKey('raw', cryptoCek, cryptoWrappingKey, 'AES-KW'))
}
}

export async function a256KeyUnwrapper(wrappingKey: Uint8Array) {
// TODO: check wrapping key size
const cryptoWrappingKey = await crypto.subtle.importKey(
'raw',
wrappingKey,
{
name: 'AES-KW',
length: 256,
},
false,
['wrapKey', 'unwrapKey']
)

return async (wrappedCek: Uint8Array): Promise<Uint8Array> => {
const cryptoKeyCek = await crypto.subtle.unwrapKey(
'raw',
wrappedCek,
cryptoWrappingKey,
'AES-KW',
// algorithm doesn't matter since we'll be exporting as raw
{ hash: 'SHA-256', name: 'HMAC' },
true,
['sign']
)

return new Uint8Array(await crypto.subtle.exportKey('raw', cryptoKeyCek))
}
}

export function x25519EncrypterWithA256KW(publicKey: Uint8Array, kid?: string): Encrypter {
const alg = 'ECDH-ES+A256KW'
const keyLen = 256
const crv = 'X25519'

async function encryptCek(cek: Uint8Array, ephemeralKeyPair?: EphemeralKeyPair): Promise<Recipient> {
const ephemeral: X25519KeyPair = ephemeralKeyPair
? generateKeyPairFromSeed(ephemeralKeyPair.secretKey)
: generateKeyPair()
const epk = { kty: 'OKP', crv, x: bytesToBase64url(ephemeral.publicKey) }
const sharedSecret = sharedKey(ephemeral.secretKey, publicKey)
// Key Encryption Key
const kek = concatKDF(sharedSecret, keyLen, alg)
const wrapper = await a256KeyWrapper(kek)
const res = await wrapper(cek)
const recipient: Recipient = {
encrypted_key: bytesToBase64url(res),
header: {},
}
if (kid) recipient.header.kid = kid
if (!ephemeralKeyPair) {
recipient.header.epk = epk
recipient.header.alg = alg
}
return recipient
}

async function encrypt(
cleartext: Uint8Array,
protectedHeader: ProtectedHeader = {},
aad?: Uint8Array,
ephemeralKeyPair?: EphemeralKeyPair
): Promise<EncryptionResult> {
// we won't want alg to be set to dir from xc20pDirEncrypter
Object.assign(protectedHeader, { alg: undefined })
// Content Encryption Key
const cek = randomBytes(32)
const recipient: Recipient = await encryptCek(cek, ephemeralKeyPair)
if (ephemeralKeyPair) {
protectedHeader.alg = alg
protectedHeader.epk = ephemeralKeyPair.publicKey
delete recipient.header.alg
delete recipient.header.epk
}
return {
...(await xc20pDirEncrypter(cek).encrypt(cleartext, protectedHeader, aad)),
recipient,
cek,
}
}

return { alg, enc: 'XC20P', encrypt, encryptCek, genEpk: genX25519EphemeralKeyPair }
}

export function x25519DecrypterWithA256KW(receiverSecret: Uint8Array | ECDH): Decrypter {
const alg = 'ECDH-ES+A256KW'
const keyLen = 256
const crv = 'X25519'

async function decrypt(
sealed: Uint8Array,
iv: Uint8Array,
aad?: Uint8Array,
recipient?: Recipient
): Promise<Uint8Array | null> {
recipient = <Recipient>recipient
const header = recipient.header
if (header.epk?.crv !== crv || typeof header.epk.x == 'undefined') return null
const publicKey = base64ToBytes(header.epk.x)
let sharedSecret
if (receiverSecret instanceof Uint8Array) {
sharedSecret = sharedKey(receiverSecret, publicKey)
} else {
sharedSecret = await receiverSecret(publicKey)
}

// Key Encryption Key
let producerInfo: Uint8Array | undefined = undefined
let consumerInfo: Uint8Array | undefined = undefined
if (recipient.header.apu) producerInfo = base64ToBytes(recipient.header.apu)
if (recipient.header.apv) consumerInfo = base64ToBytes(recipient.header.apv)
const kek = concatKDF(sharedSecret, keyLen, alg, producerInfo, consumerInfo)
// Content Encryption Key
const unwrap = await a256KeyUnwrapper(kek)
const cek = await unwrap(base64ToBytes(recipient.encrypted_key))
if (cek === null) return null

return xc20pDirDecrypter(cek).decrypt(sealed, iv, aad)
}

return { alg, enc: 'XC20P', decrypt }
}
Loading

0 comments on commit 375b93c

Please sign in to comment.