Skip to content

Commit

Permalink
feat: add direct ecdh-es jwe encryption/decryption (#2045)
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra authored Sep 27, 2024
1 parent 855d4ab commit 9756a4a
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 2 deletions.
6 changes: 6 additions & 0 deletions .changeset/spotty-hounds-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@credo-ts/askar": patch
"@credo-ts/core": patch
---

feat: add direct ecdh-es jwe encryption/decryption
3 changes: 2 additions & 1 deletion packages/askar/src/utils/askarKeyTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { KeyAlgs } from '@hyperledger/aries-askar-shared'
export enum AskarKeyTypePurpose {
KeyManagement = 'KeyManagement',
Signing = 'Signing',
Encryption = 'Encryption',
}

const keyTypeToAskarAlg = {
Expand All @@ -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,
Expand Down
130 changes: 129 additions & 1 deletion packages/askar/src/wallet/AskarBaseWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
WalletExportImportConfig,
Logger,
SigningProviderRegistry,
WalletDirectEncryptCompactJwtEcdhEsOptions,
} from '@credo-ts/core'
import type { Session } from '@hyperledger/aries-askar-shared'

Expand All @@ -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'
Expand Down Expand Up @@ -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<string, unknown> }> {
// 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<string> {
try {
// generate an 80-bit nonce suitable for AnonCreds proofs
Expand Down
41 changes: 41 additions & 0 deletions packages/askar/src/wallet/__tests__/AskarWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
TypedArrayEncoder,
KeyDerivationMethod,
Buffer,
JsonEncoder,
} from '@credo-ts/core'
import { Store } from '@hyperledger/aries-askar-shared'

Expand Down Expand Up @@ -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', () => {
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/wallet/Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@ export interface Wallet extends Disposable {
getRandomValues(length: number): Uint8Array
generateWalletKey(): Promise<string>

// 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<string>

/**
* 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<WalletDirectDecryptCompactJwtEcdhEsReturn>

/**
* Get the key types supported by the wallet implementation.
*/
Expand Down Expand Up @@ -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<string, unknown>
}

export interface WalletDirectDecryptCompactJwtEcdhEsReturn {
data: Buffer
header: Record<string, unknown>
}

0 comments on commit 9756a4a

Please sign in to comment.