From b018794dc2b798648c9d3b0d4fab4060f5b2aaf0 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 8 Jun 2021 14:48:30 +0200 Subject: [PATCH] feat: enable remote ECDH for JWE [de]encrypters fixes #183 --- src/ECDH.ts | 25 ++++++++++++ src/__tests__/JWE.test.ts | 50 +++++++++++++++++++++++- src/__tests__/xc20pEncryption.test.ts | 15 +++++++- src/index.ts | 1 + src/xc20pEncryption.ts | 55 ++++++++++++++++++++------- 5 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 src/ECDH.ts diff --git a/src/ECDH.ts b/src/ECDH.ts new file mode 100644 index 00000000..816dc5bc --- /dev/null +++ b/src/ECDH.ts @@ -0,0 +1,25 @@ +import { sharedKey } from '@stablelib/x25519' + +/** + * A wrapper around `mySecretKey` that can compute a shared secret using `theirPublicKey` + * The promise should resolve to a Uint8Array containing the raw shared secret. + * + */ +export type ECDH = (theirPublicKey: Uint8Array) => Promise + +/** + * Wraps an X25519 secretKey into an ECDH method that can be used to compute a shared secret with a publicKey. + * @param mySecretKey A `Uint8Array` representing the bytes of my secret key + * @returns an `ECDH` method with the signature `(theirPublicKey: Uint8Array) => Promise` + */ +export function createX25519ECDH(mySecretKey: Uint8Array): ECDH { + if (mySecretKey.length !== 32) { + throw new Error('invalid_argument: incorrect secret key length for X25519') + } + return async (theirPublicKey: Uint8Array): Promise => { + if (theirPublicKey.length !== 32) { + throw new Error('invalid_argument: incorrect publicKey key length for X25519') + } + return sharedKey(mySecretKey, theirPublicKey) + } +} diff --git a/src/__tests__/JWE.test.ts b/src/__tests__/JWE.test.ts index 56699cae..b2118f20 100644 --- a/src/__tests__/JWE.test.ts +++ b/src/__tests__/JWE.test.ts @@ -1,4 +1,4 @@ -import { decryptJWE, createJWE, Encrypter } from '../JWE' +import { decryptJWE, createJWE, Encrypter, JWE } from '../JWE' import vectors from './jwe-vectors.js' import { xc20pDirEncrypter, @@ -6,12 +6,17 @@ import { x25519Encrypter, x25519Decrypter, xc20pAuthDecrypterEcdh1PuV3x25519WithXc20PkwV2, - xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2 + xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2, + createAnonEncrypter, + createAnonDecrypter, + createAuthEncrypter, + createAuthDecrypter } from '../xc20pEncryption' import { decodeBase64url, encodeBase64url } from '../util' import * as u8a from 'uint8arrays' import { randomBytes } from '@stablelib/random' import { generateKeyPairFromSeed } from '@stablelib/x25519' +import { createX25519ECDH, ECDH } from '../ECDH' describe('JWE', () => { describe('decryptJWE', () => { @@ -326,6 +331,47 @@ describe('JWE', () => { delete jwe.aad await expect(decryptJWE(jwe, decrypter)).rejects.toThrowError('Failed to decrypt') }) + + describe('using remote ECDH', () => { + const message = 'hello world' + const receiverPair = generateKeyPairFromSeed(randomBytes(32)) + const receiverRemoteECDH = createX25519ECDH(receiverPair.secretKey) + const senderPair = generateKeyPairFromSeed(randomBytes(32)) + const senderRemoteECDH: ECDH = createX25519ECDH(senderPair.secretKey) + + it('creates anon JWE with remote ECDH', async () => { + const encrypter = createAnonEncrypter(receiverPair.publicKey) + const jwe: JWE = await createJWE(u8a.fromString(message), [encrypter]) + const decrypter = createAnonDecrypter(receiverRemoteECDH) + const decryptedBytes = await decryptJWE(jwe, decrypter) + const receivedMessage = u8a.toString(decryptedBytes) + expect(receivedMessage).toEqual(message) + }) + + it('creates and decrypts auth JWE', async () => { + const encrypter = createAuthEncrypter(receiverPair.publicKey, senderRemoteECDH) + const jwe: JWE = await createJWE(u8a.fromString(message), [encrypter]) + const decrypter = createAuthDecrypter(receiverRemoteECDH, senderPair.publicKey) + const decryptedBytes = await decryptJWE(jwe, decrypter) + const receivedMessage = u8a.toString(decryptedBytes) + expect(receivedMessage).toEqual(message) + }) + + it(`throws error when using bad secret key size`, async () => { + expect.assertions(1) + const badSecretKey = randomBytes(64) + expect(() => { + createX25519ECDH(badSecretKey) + }).toThrow('invalid_argument') + }) + + it(`throws error when using bad public key size`, async () => { + expect.assertions(1) + const ecdh: ECDH = createX25519ECDH(randomBytes(32)) + const badPublicKey = randomBytes(64) + expect(ecdh(badPublicKey)).rejects.toThrow('invalid_argument') + }) + }) }) describe('Multiple recipients', () => { diff --git a/src/__tests__/xc20pEncryption.test.ts b/src/__tests__/xc20pEncryption.test.ts index 8f8ef977..40998b1c 100644 --- a/src/__tests__/xc20pEncryption.test.ts +++ b/src/__tests__/xc20pEncryption.test.ts @@ -3,6 +3,7 @@ import { decryptJWE, createJWE } from '../JWE' import * as u8a from 'uint8arrays' import { randomBytes } from '@stablelib/random' import { generateKeyPair } from '@stablelib/x25519' +import { createX25519ECDH } from '../ECDH' describe('xc20pEncryption', () => { describe('resolveX25519Encrypters', () => { @@ -13,6 +14,7 @@ describe('xc20pEncryption', () => { let resolver let decrypter1, decrypter2 + let decrypter1remote, decrypter2remote let didDocumentResult1, didDocumentResult2, didDocumentResult3, didDocumentResult4 @@ -22,6 +24,9 @@ describe('xc20pEncryption', () => { decrypter1 = x25519Decrypter(kp1.secretKey) decrypter2 = x25519Decrypter(kp2.secretKey) + decrypter1remote = x25519Decrypter(createX25519ECDH(kp1.secretKey)) + decrypter2remote = x25519Decrypter(createX25519ECDH(kp2.secretKey)) + didDocumentResult1 = { didDocument: { verificationMethod: [ @@ -75,7 +80,7 @@ describe('xc20pEncryption', () => { }) it('correctly resolves encrypters for DIDs', async () => { - expect.assertions(4) + expect.assertions(6) const encrypters = await resolveX25519Encrypters([did1, did2], resolver) const cleartext = randomBytes(8) const jwe = await createJWE(cleartext, encrypters) @@ -84,6 +89,8 @@ describe('xc20pEncryption', () => { expect(jwe.recipients[1].header.kid).toEqual(did2 + '#abc') expect(await decryptJWE(jwe, decrypter1)).toEqual(cleartext) expect(await decryptJWE(jwe, decrypter2)).toEqual(cleartext) + expect(await decryptJWE(jwe, decrypter1remote)).toEqual(cleartext) + expect(await decryptJWE(jwe, decrypter2remote)).toEqual(cleartext) }) it('throws error if key is not found', async () => { @@ -97,13 +104,15 @@ describe('xc20pEncryption', () => { }) it('resolves encrypters for DIDs with multiple valid keys ', async () => { - expect.assertions(6) + expect.assertions(8) const secondKp1 = generateKeyPair() const secondKp2 = generateKeyPair() const newDecrypter1 = x25519Decrypter(secondKp1.secretKey) const newDecrypter2 = x25519Decrypter(secondKp2.secretKey) + const newDecrypter1remote = x25519Decrypter(createX25519ECDH(secondKp1.secretKey)) + const newDecrypter2remote = x25519Decrypter(createX25519ECDH(secondKp2.secretKey)) didDocumentResult1.didDocument.verificationMethod.push({ id: did1 + '#def', @@ -130,6 +139,8 @@ describe('xc20pEncryption', () => { expect(jwe.recipients[3].header.kid).toEqual(did2 + '#def') expect(await decryptJWE(jwe, newDecrypter1)).toEqual(cleartext) expect(await decryptJWE(jwe, newDecrypter2)).toEqual(cleartext) + expect(await decryptJWE(jwe, newDecrypter1remote)).toEqual(cleartext) + expect(await decryptJWE(jwe, newDecrypter2remote)).toEqual(cleartext) }) }) }) diff --git a/src/index.ts b/src/index.ts index bb3f1c52..a1ee3b42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { } from './JWT' import { toEthereumAddress } from './Digest' export { JWE, createJWE, decryptJWE, Encrypter, Decrypter } from './JWE' +export { ECDH, createX25519ECDH } from './ECDH' export { xc20pDirEncrypter, xc20pDirDecrypter, diff --git a/src/xc20pEncryption.ts b/src/xc20pEncryption.ts index 35ea12eb..21137d29 100644 --- a/src/xc20pEncryption.ts +++ b/src/xc20pEncryption.ts @@ -5,6 +5,7 @@ import { concatKDF } from './Digest' import { bytesToBase64url, base58ToBytes, encodeBase64url, toSealed, base64ToBytes } from './util' import { Recipient, EncryptionResult, Encrypter, Decrypter, ProtectedHeader } from './JWE' import type { VerificationMethod, Resolvable } from 'did-resolver' +import { ECDH } from './ECDH' export type AuthEncryptParams = { kid?: string @@ -27,13 +28,15 @@ export type AnonEncryptParams = { * * NOTE: ECDH-1PU and XC20PKW are proposed drafts in IETF and not a standard yet and * are subject to change as new revisions or until the official CFRG specification are released. + * + * @beta */ export function createAuthEncrypter( recipientPublicKey: Uint8Array, - senderSecretKey: Uint8Array, + senderSecret: Uint8Array | ECDH, options: Partial = {} ): Encrypter { - return xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2(recipientPublicKey, senderSecretKey, options) + return xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2(recipientPublicKey, senderSecret, options) } /** @@ -42,9 +45,11 @@ export function createAuthEncrypter( * * NOTE: ECDH-ES+XC20PKW is a proposed draft in IETF and not a standard yet and * is subject to change as new revisions or until the official CFRG specification is released. + * + * @beta */ export function createAnonEncrypter(publicKey: Uint8Array, options: Partial = {}): Encrypter { - return options !== undefined ? x25519Encrypter(publicKey, options.kid) : x25519Encrypter(publicKey) + return x25519Encrypter(publicKey, options?.kid) } /** @@ -55,9 +60,11 @@ export function createAnonEncrypter(publicKey: Uint8Array, options: Partial EncryptionResult { @@ -158,7 +167,7 @@ export function x25519Encrypter(publicKey: Uint8Array, kid?: string): Encrypter */ export function xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2( recipientPublicKey: Uint8Array, - senderSecretKey: Uint8Array, + senderSecret: Uint8Array | ECDH, options: Partial = {} ): Encrypter { const alg = 'ECDH-1PU+XC20PKW' @@ -176,7 +185,12 @@ export function xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2( // ECDH-1PU requires additional shared secret between // static key of sender and static key of recipient - const zS = sharedKey(senderSecretKey, recipientPublicKey) + let zS + if (senderSecret instanceof Uint8Array) { + zS = sharedKey(senderSecret, recipientPublicKey) + } else { + zS = await senderSecret(recipientPublicKey) + } const sharedSecret = new Uint8Array(zE.length + zS.length) sharedSecret.set(zE) @@ -258,7 +272,7 @@ function validateHeader(header?: ProtectedHeader) { } } -export function x25519Decrypter(secretKey: Uint8Array): Decrypter { +export function x25519Decrypter(receiverSecret: Uint8Array | ECDH): Decrypter { const alg = 'ECDH-ES+XC20PKW' const keyLen = 256 const crv = 'X25519' @@ -272,7 +286,12 @@ export function x25519Decrypter(secretKey: Uint8Array): Decrypter { recipient = recipient if (recipient.header.epk?.crv !== crv || typeof recipient.header.epk.x == 'undefined') return null const publicKey = base64ToBytes(recipient.header.epk.x) - const sharedSecret = sharedKey(secretKey, publicKey) + let sharedSecret + if (receiverSecret instanceof Uint8Array) { + sharedSecret = sharedKey(receiverSecret, publicKey) + } else { + sharedSecret = await receiverSecret(publicKey) + } // Key Encryption Key const kek = concatKDF(sharedSecret, keyLen, alg) @@ -292,7 +311,7 @@ export function x25519Decrypter(secretKey: Uint8Array): Decrypter { * - [ECDH-1PU](https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03) */ export function xc20pAuthDecrypterEcdh1PuV3x25519WithXc20PkwV2( - recipientSecretKey: Uint8Array, + recipientSecret: Uint8Array | ECDH, senderPublicKey: Uint8Array ): Decrypter { const alg = 'ECDH-1PU+XC20PKW' @@ -310,8 +329,16 @@ export function xc20pAuthDecrypterEcdh1PuV3x25519WithXc20PkwV2( // ECDH-1PU requires additional shared secret between // static key of sender and static key of recipient const publicKey = base64ToBytes(recipient.header.epk.x) - const zE = sharedKey(recipientSecretKey, publicKey) - const zS = sharedKey(recipientSecretKey, senderPublicKey) + let zE: Uint8Array + let zS: Uint8Array + + if (recipientSecret instanceof Uint8Array) { + zE = sharedKey(recipientSecret, publicKey) + zS = sharedKey(recipientSecret, senderPublicKey) + } else { + zE = await recipientSecret(publicKey) + zS = await recipientSecret(senderPublicKey) + } const sharedSecret = new Uint8Array(zE.length + zS.length) sharedSecret.set(zE)