From 9756a4a96ec3d79dc6d4dd5db697f9f443a34746 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Fri, 27 Sep 2024 20:04:10 +0200 Subject: [PATCH] feat: add direct ecdh-es jwe encryption/decryption (#2045) Signed-off-by: Timo Glastra --- .changeset/spotty-hounds-relax.md | 6 + packages/askar/src/utils/askarKeyTypes.ts | 3 +- packages/askar/src/wallet/AskarBaseWallet.ts | 130 +++++++++++++++++- .../src/wallet/__tests__/AskarWallet.test.ts | 41 ++++++ packages/core/src/wallet/Wallet.ts | 42 ++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 .changeset/spotty-hounds-relax.md diff --git a/.changeset/spotty-hounds-relax.md b/.changeset/spotty-hounds-relax.md new file mode 100644 index 0000000000..552092be4b --- /dev/null +++ b/.changeset/spotty-hounds-relax.md @@ -0,0 +1,6 @@ +--- +"@credo-ts/askar": patch +"@credo-ts/core": patch +--- + +feat: add direct ecdh-es jwe encryption/decryption diff --git a/packages/askar/src/utils/askarKeyTypes.ts b/packages/askar/src/utils/askarKeyTypes.ts index 5288ccc565..d5005d709b 100644 --- a/packages/askar/src/utils/askarKeyTypes.ts +++ b/packages/askar/src/utils/askarKeyTypes.ts @@ -4,6 +4,7 @@ import { KeyAlgs } from '@hyperledger/aries-askar-shared' export enum AskarKeyTypePurpose { KeyManagement = 'KeyManagement', Signing = 'Signing', + Encryption = 'Encryption', } const keyTypeToAskarAlg = { @@ -29,7 +30,7 @@ const keyTypeToAskarAlg = { }, [KeyType.P256]: { keyAlg: KeyAlgs.EcSecp256r1, - purposes: [AskarKeyTypePurpose.KeyManagement, AskarKeyTypePurpose.Signing], + purposes: [AskarKeyTypePurpose.KeyManagement, AskarKeyTypePurpose.Signing, AskarKeyTypePurpose.Encryption], }, [KeyType.K256]: { keyAlg: KeyAlgs.EcSecp256k1, diff --git a/packages/askar/src/wallet/AskarBaseWallet.ts b/packages/askar/src/wallet/AskarBaseWallet.ts index c6cba16ade..d4f976ea3e 100644 --- a/packages/askar/src/wallet/AskarBaseWallet.ts +++ b/packages/askar/src/wallet/AskarBaseWallet.ts @@ -11,6 +11,7 @@ import type { WalletExportImportConfig, Logger, SigningProviderRegistry, + WalletDirectEncryptCompactJwtEcdhEsOptions, } from '@credo-ts/core' import type { Session } from '@hyperledger/aries-askar-shared' @@ -28,7 +29,15 @@ import { KeyType, utils, } from '@credo-ts/core' -import { CryptoBox, Store, Key as AskarKey, keyAlgFromString } from '@hyperledger/aries-askar-shared' +import { + CryptoBox, + Store, + Key as AskarKey, + keyAlgFromString, + EcdhEs, + KeyAlgs, + Jwk, +} from '@hyperledger/aries-askar-shared' import BigNumber from 'bn.js' import { importSecureEnvironment } from '../secureEnvironment' @@ -459,6 +468,125 @@ export abstract class AskarBaseWallet implements Wallet { return returnValue } + /** + * Method that enables JWE encryption using ECDH-ES and AesA256Gcm and returns it as a compact JWE. + * This method is specifically added to support OpenID4VP response encryption using JARM and should later be + * refactored into a more generic method that supports encryption/decryption. + * + * @returns compact JWE + */ + public async directEncryptCompactJweEcdhEs({ + recipientKey, + encryptionAlgorithm, + apu, + apv, + data, + header, + }: WalletDirectEncryptCompactJwtEcdhEsOptions) { + if (encryptionAlgorithm !== 'A256GCM') { + throw new WalletError(`Encryption algorithm ${encryptionAlgorithm} is not supported. Only A256GCM is supported`) + } + + // Only one supported for now + const encAlg = KeyAlgs.AesA256Gcm + + // Create ephemeral key + const ephemeralKey = AskarKey.generate(keyAlgFromString(recipientKey.keyType)) + + const _header = { + ...header, + apv, + apu, + enc: 'A256GCM', + alg: 'ECDH-ES', + epk: ephemeralKey.jwkPublic, + } + + const encodedHeader = JsonEncoder.toBuffer(_header) + + const ecdh = new EcdhEs({ + algId: Uint8Array.from(Buffer.from(encAlg)), + apu: apu ? Uint8Array.from(TypedArrayEncoder.fromBase64(apu)) : Uint8Array.from([]), + apv: apv ? Uint8Array.from(TypedArrayEncoder.fromBase64(apv)) : Uint8Array.from([]), + }) + + const { ciphertext, tag, nonce } = ecdh.encryptDirect({ + encAlg, + ephemeralKey, + message: Uint8Array.from(data), + recipientKey: AskarKey.fromPublicBytes({ + algorithm: keyAlgFromString(recipientKey.keyType), + publicKey: recipientKey.publicKey, + }), + aad: Uint8Array.from(encodedHeader), + }) + + const compactJwe = `${TypedArrayEncoder.toBase64URL(encodedHeader)}..${TypedArrayEncoder.toBase64URL( + nonce + )}.${TypedArrayEncoder.toBase64URL(ciphertext)}.${TypedArrayEncoder.toBase64URL(tag)}` + return compactJwe + } + + /** + * Method that enables JWE decryption using ECDH-ES and AesA256Gcm and returns it as plaintext buffer with the header. + * The apv and apu values are extracted from the heaader, and thus on a higher level it should be checked that these + * values are correct. + */ + public async directDecryptCompactJweEcdhEs({ + compactJwe, + recipientKey, + }: { + compactJwe: string + recipientKey: Key + }): Promise<{ data: Buffer; header: Record }> { + // encryption key is not used (we don't use key wrapping) + const [encodedHeader /* encryptionKey */, , encodedIv, encodedCiphertext, encodedTag] = compactJwe.split('.') + + const header = JsonEncoder.fromBase64(encodedHeader) + + if (header.alg !== 'ECDH-ES') { + throw new WalletError('Only ECDH-ES alg value is supported') + } + if (header.enc !== 'A256GCM') { + throw new WalletError('Only A256GCM enc value is supported') + } + if (!header.epk || typeof header.epk !== 'object') { + throw new WalletError('header epk value must contain a JWK') + } + + // NOTE: we don't support custom key storage record at the moment. + let askarKey: AskarKey | null | undefined + if (isKeyTypeSupportedByAskarForPurpose(recipientKey.keyType, AskarKeyTypePurpose.KeyManagement)) { + askarKey = await this.withSession( + async (session) => (await session.fetchKey({ name: recipientKey.publicKeyBase58 }))?.key + ) + } + if (!askarKey) { + throw new WalletError('Key entry not found') + } + + // Only one supported for now + const encAlg = KeyAlgs.AesA256Gcm + + const ecdh = new EcdhEs({ + algId: Uint8Array.from(Buffer.from(encAlg)), + apu: header.apu ? Uint8Array.from(TypedArrayEncoder.fromBase64(header.apu)) : Uint8Array.from([]), + apv: header.apv ? Uint8Array.from(TypedArrayEncoder.fromBase64(header.apv)) : Uint8Array.from([]), + }) + + const plaintext = ecdh.decryptDirect({ + nonce: TypedArrayEncoder.fromBase64(encodedIv), + ciphertext: TypedArrayEncoder.fromBase64(encodedCiphertext), + encAlg, + ephemeralKey: Jwk.fromJson(header.epk), + recipientKey: askarKey, + tag: TypedArrayEncoder.fromBase64(encodedTag), + aad: TypedArrayEncoder.fromBase64(encodedHeader), + }) + + return { data: Buffer.from(plaintext), header } + } + public async generateNonce(): Promise { try { // generate an 80-bit nonce suitable for AnonCreds proofs diff --git a/packages/askar/src/wallet/__tests__/AskarWallet.test.ts b/packages/askar/src/wallet/__tests__/AskarWallet.test.ts index 64a2121cc0..be07580d39 100644 --- a/packages/askar/src/wallet/__tests__/AskarWallet.test.ts +++ b/packages/askar/src/wallet/__tests__/AskarWallet.test.ts @@ -19,6 +19,7 @@ import { TypedArrayEncoder, KeyDerivationMethod, Buffer, + JsonEncoder, } from '@credo-ts/core' import { Store } from '@hyperledger/aries-askar-shared' @@ -170,6 +171,46 @@ describe('AskarWallet basic operations', () => { }) await expect(askarWallet.verify({ key: k256Key, data: message, signature })).resolves.toStrictEqual(true) }) + + test('Encrypt and decrypt using JWE ECDH-ES', async () => { + const recipientKey = await askarWallet.createKey({ + keyType: KeyType.P256, + }) + + const apv = TypedArrayEncoder.toBase64URL(TypedArrayEncoder.fromString('nonce-from-auth-request')) + const apu = TypedArrayEncoder.toBase64URL(TypedArrayEncoder.fromString(await askarWallet.generateNonce())) + + const compactJwe = await askarWallet.directEncryptCompactJweEcdhEs({ + data: JsonEncoder.toBuffer({ vp_token: ['something'] }), + apu, + apv, + encryptionAlgorithm: 'A256GCM', + header: { + kid: 'some-kid', + }, + recipientKey, + }) + + const { data, header } = await askarWallet.directDecryptCompactJweEcdhEs({ + compactJwe, + recipientKey, + }) + + expect(header).toEqual({ + kid: 'some-kid', + apv, + apu, + enc: 'A256GCM', + alg: 'ECDH-ES', + epk: { + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }, + }) + expect(JsonEncoder.fromBuffer(data)).toEqual({ vp_token: ['something'] }) + }) }) describe.skip('Currently, all KeyTypes are supported by Askar natively', () => { diff --git a/packages/core/src/wallet/Wallet.ts b/packages/core/src/wallet/Wallet.ts index 8801b38eb4..aebe9c592b 100644 --- a/packages/core/src/wallet/Wallet.ts +++ b/packages/core/src/wallet/Wallet.ts @@ -61,6 +61,34 @@ export interface Wallet extends Disposable { getRandomValues(length: number): Uint8Array generateWalletKey(): Promise + // Methods to faciliate OpenID4VP response encryption, should be unified/generalized at some + // point. Ideally all the didcomm/oid4vc/encryption/decryption is generalized, but it's a bit complex + // @note methods are optional to not introduce breaking changes + + /** + * Method that enables JWT encryption using ECDH-ES and AesA256Gcm and returns it as a compact JWE. + * This method is specifically added to support OpenID4VP response encryption using JARM and should later be + * refactored into a more generic method that supports encryption/decryption. + * + * @returns compact JWE + */ + directEncryptCompactJweEcdhEs?(options: WalletDirectEncryptCompactJwtEcdhEsOptions): Promise + + /** + * Method that enabled JWT encryption using ECDH-ES and AesA256Gcm and returns it as a compact JWE. + * This method is specifically added to support OpenID4VP response encryption using JARM and should later be + * refactored into a more generic method that supports encryption/decryption. + * + * @returns compact JWE + */ + directDecryptCompactJweEcdhEs?({ + compactJwe, + recipientKey, + }: { + compactJwe: string + recipientKey: Key + }): Promise + /** * Get the key types supported by the wallet implementation. */ @@ -91,3 +119,17 @@ export interface UnpackedMessageContext { senderKey?: string recipientKey?: string } + +export interface WalletDirectEncryptCompactJwtEcdhEsOptions { + recipientKey: Key + encryptionAlgorithm: 'A256GCM' + apu?: string + apv?: string + data: Buffer + header: Record +} + +export interface WalletDirectDecryptCompactJwtEcdhEsReturn { + data: Buffer + header: Record +}