From ff264405658f54a6f0f1a236284a03cb47027225 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Wed, 9 Jun 2021 13:37:08 +0200 Subject: [PATCH] feat: enable remote ECDH for JWE [de]encrypters (#186) * feat: enable remote ECDH for JWE [de]encrypters fixes #183 * docs: add some documentation regarding the use of remote ECDH --- src/ECDH.ts | 31 +++++++ src/__tests__/JWE.test.ts | 50 +++++++++- src/__tests__/xc20pEncryption.test.ts | 15 ++- src/index.ts | 1 + src/xc20pEncryption.ts | 126 ++++++++++++++++++++------ 5 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 src/ECDH.ts diff --git a/src/ECDH.ts b/src/ECDH.ts new file mode 100644 index 00000000..98bceebb --- /dev/null +++ b/src/ECDH.ts @@ -0,0 +1,31 @@ +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. + * + * This method is meant to be used when direct access to a secret key is impossible or not desired. + * + * @param theirPublicKey `Uint8Array` the other party's public key + * @returns a `Promise` that resolves to a `Uint8Array` representing the computed shared secret + */ +export type ECDH = (theirPublicKey: Uint8Array) => Promise + +/** + * Wraps an X25519 secret key into an ECDH method that can be used to compute a shared secret with a public key. + * @param mySecretKey A `Uint8Array` of length 32 representing the bytes of my secret key + * @returns an `ECDH` method with the signature `(theirPublicKey: Uint8Array) => Promise` + * + * @throws 'invalid_argument:...' if the secret key size is wrong + */ +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..3ae71ce9 100644 --- a/src/xc20pEncryption.ts +++ b/src/xc20pEncryption.ts @@ -5,70 +5,126 @@ 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' +/** + * Extra header parameters for JWE using authenticated encryption + */ export type AuthEncryptParams = { + /** + * recipient key ID + */ kid?: string + + /** + * See {@link https://datatracker.ietf.org/doc/html/draft-madden-jose-ecdh-1pu-03#section-2.1.1} + */ skid?: string - // base64url encoded + + /** + * See {@link https://datatracker.ietf.org/doc/html/rfc7518#section-4.6.1.2} + * base64url encoded + */ apu?: string - // base64url encoded + + /** + * See {@link https://datatracker.ietf.org/doc/html/rfc7518#section-4.6.1.3} + * base64url encoded + */ apv?: string } +/** + * Extra header parameters for JWE using anonymous encryption + */ export type AnonEncryptParams = { + /** + * recipient key ID + */ kid?: string } /** * Recommended encrypter for authenticated encryption (i.e. sender authentication and requires * sender private key to encrypt the data). - * Uses ECDH-1PU [v3](https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03) and - * XC20PKW [v2](https://tools.ietf.org/html/draft-amringer-jose-chacha-02). + * Uses {@link https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03 | ECDH-1PU v3 } and + * {@link https://tools.ietf.org/html/draft-amringer-jose-chacha-02 | XC20PKW v2 }. + * + * @param recipientPublicKey the byte array representing the recipient public key + * @param senderSecret either a Uint8Array representing the sender secret key or + * an ECDH function that wraps the key and can promise a shared secret given a public key + * @param options {@link AuthEncryptParams} used to specify extra header parameters + * + * @returns an {@link Encrypter} instance usable with {@link createJWE} * * 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) } /** * Recommended encrypter for anonymous encryption (i.e. no sender authentication). - * Uses ECDH-ES+XC20PKW [v2](https://tools.ietf.org/html/draft-amringer-jose-chacha-02). + * Uses {@link https://tools.ietf.org/html/draft-amringer-jose-chacha-02 | ECDH-ES+XC20PKW v2}. + * + * @param publicKey the byte array representing the recipient public key + * @param options {@link AnonEncryptParams} used to specify the recipient key ID (`kid`) + * + * @returns an {@link Encrypter} instance usable with {@link createJWE} * * 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) } /** * Recommended decrypter for authenticated encryption (i.e. sender authentication and requires * sender public key to decrypt the data). - * Uses ECDH-1PU [v3](https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03) and - * XC20PKW [v2](https://tools.ietf.org/html/draft-amringer-jose-chacha-02). + * Uses {@link https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03 | ECDH-1PU v3 } and + * {@link https://tools.ietf.org/html/draft-amringer-jose-chacha-02 | XC20PKW v2 }. + * + * @param recipientSecret either a Uint8Array representing the recipient secret key or + * an ECDH function that wraps the key and can promise a shared secret given a public key + * @param senderPublicKey the byte array representing the sender public key + * + * @returns a {@link Decrypter} instance usable with {@link decryptJWE} * * 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 createAuthDecrypter(recipientSecretKey: Uint8Array, senderPublicKey: Uint8Array): Decrypter { - return xc20pAuthDecrypterEcdh1PuV3x25519WithXc20PkwV2(recipientSecretKey, senderPublicKey) +export function createAuthDecrypter(recipientSecret: Uint8Array | ECDH, senderPublicKey: Uint8Array): Decrypter { + return xc20pAuthDecrypterEcdh1PuV3x25519WithXc20PkwV2(recipientSecret, senderPublicKey) } /** * Recommended decrypter for anonymous encryption (i.e. no sender authentication). - * Uses ECDH-ES+XC20PKW [v2](https://tools.ietf.org/html/draft-amringer-jose-chacha-02). + * Uses {@link https://tools.ietf.org/html/draft-amringer-jose-chacha-02 | ECDH-ES+XC20PKW v2 }. + * + * @param recipientSecret either a Uint8Array representing the recipient secret key or + * an ECDH function that wraps the key and can promise a shared secret given a public key + * + * @returns a {@link Decrypter} instance usable with {@link decryptJWE} * * 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 createAnonDecrypter(secretKey: Uint8Array): Decrypter { - return x25519Decrypter(secretKey) +export function createAnonDecrypter(recipientSecret: Uint8Array | ECDH): Decrypter { + return x25519Decrypter(recipientSecret) } function xc20pEncrypter(key: Uint8Array): (cleartext: Uint8Array, aad?: Uint8Array) => EncryptionResult { @@ -153,12 +209,12 @@ export function x25519Encrypter(publicKey: Uint8Array, kid?: string): Encrypter /** * Implements ECDH-1PU+XC20PKW with XChaCha20Poly1305 based on the following specs: - * - [XC20PKW](https://tools.ietf.org/html/draft-amringer-jose-chacha-02) - * - [ECDH-1PU](https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03) + * - {@link https://tools.ietf.org/html/draft-amringer-jose-chacha-02 | XC20PKW} + * - {@link https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03 | ECDH-1PU} */ export function xc20pAuthEncrypterEcdh1PuV3x25519WithXc20PkwV2( recipientPublicKey: Uint8Array, - senderSecretKey: Uint8Array, + senderSecret: Uint8Array | ECDH, options: Partial = {} ): Encrypter { const alg = 'ECDH-1PU+XC20PKW' @@ -176,7 +232,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 +319,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 +333,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) @@ -288,11 +354,11 @@ export function x25519Decrypter(secretKey: Uint8Array): Decrypter { /** * Implements ECDH-1PU+XC20PKW with XChaCha20Poly1305 based on the following specs: - * - [XC20PKW](https://tools.ietf.org/html/draft-amringer-jose-chacha-02) - * - [ECDH-1PU](https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03) + * - {@link https://tools.ietf.org/html/draft-amringer-jose-chacha-02 | XC20PKW} + * - {@link https://tools.ietf.org/html/draft-madden-jose-ecdh-1pu-03 | ECDH-1PU} */ export function xc20pAuthDecrypterEcdh1PuV3x25519WithXc20PkwV2( - recipientSecretKey: Uint8Array, + recipientSecret: Uint8Array | ECDH, senderPublicKey: Uint8Array ): Decrypter { const alg = 'ECDH-1PU+XC20PKW' @@ -310,8 +376,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)