diff --git a/packages/core/src/crypto/JwsService.ts b/packages/core/src/crypto/JwsService.ts new file mode 100644 index 0000000000..04c08a3669 --- /dev/null +++ b/packages/core/src/crypto/JwsService.ts @@ -0,0 +1,121 @@ +import type { Buffer } from '../utils' +import type { Jws, JwsGeneralFormat } from './JwsTypes' + +import { inject, Lifecycle, scoped } from 'tsyringe' + +import { InjectionSymbols } from '../constants' +import { AriesFrameworkError } from '../error' +import { JsonEncoder, BufferEncoder } from '../utils' +import { Wallet } from '../wallet' +import { WalletError } from '../wallet/error' + +// TODO: support more key types, more generic jws format +const JWS_KEY_TYPE = 'OKP' +const JWS_CURVE = 'Ed25519' +const JWS_ALG = 'EdDSA' + +@scoped(Lifecycle.ContainerScoped) +export class JwsService { + private wallet: Wallet + + public constructor(@inject(InjectionSymbols.Wallet) wallet: Wallet) { + this.wallet = wallet + } + + public async createJws({ payload, verkey, header }: CreateJwsOptions): Promise { + const base64Payload = BufferEncoder.toBase64URL(payload) + const base64Protected = JsonEncoder.toBase64URL(this.buildProtected(verkey)) + + const signature = BufferEncoder.toBase64URL( + await this.wallet.sign(BufferEncoder.fromString(`${base64Protected}.${base64Payload}`), verkey) + ) + + return { + protected: base64Protected, + signature, + header, + } + } + + /** + * Verify a a JWS + */ + public async verifyJws({ jws, payload }: VerifyJwsOptions): Promise { + const base64Payload = BufferEncoder.toBase64URL(payload) + const signatures = 'signatures' in jws ? jws.signatures : [jws] + + const signerVerkeys = [] + for (const jws of signatures) { + const protectedJson = JsonEncoder.fromBase64(jws.protected) + + const isValidKeyType = protectedJson?.jwk?.kty === JWS_KEY_TYPE + const isValidCurve = protectedJson?.jwk?.crv === JWS_CURVE + const isValidAlg = protectedJson?.alg === JWS_ALG + + if (!isValidKeyType || !isValidCurve || !isValidAlg) { + throw new AriesFrameworkError('Invalid protected header') + } + + const data = BufferEncoder.fromString(`${jws.protected}.${base64Payload}`) + const signature = BufferEncoder.fromBase64(jws.signature) + + const verkey = BufferEncoder.toBase58(BufferEncoder.fromBase64(protectedJson?.jwk?.x)) + signerVerkeys.push(verkey) + + try { + const isValid = await this.wallet.verify(verkey, data, signature) + + if (!isValid) { + return { + isValid: false, + signerVerkeys: [], + } + } + } catch (error) { + // WalletError probably means signature verification failed. Would be useful to add + // more specific error type in wallet.verify method + if (error instanceof WalletError) { + return { + isValid: false, + signerVerkeys: [], + } + } + + throw error + } + } + + return { isValid: true, signerVerkeys } + } + + /** + * @todo This currently only work with a single alg, key type and curve + * This needs to be extended with other formats in the future + */ + private buildProtected(verkey: string) { + return { + alg: 'EdDSA', + jwk: { + kty: 'OKP', + crv: 'Ed25519', + x: BufferEncoder.toBase64URL(BufferEncoder.fromBase58(verkey)), + }, + } + } +} + +export interface CreateJwsOptions { + verkey: string + payload: Buffer + header: Record +} + +export interface VerifyJwsOptions { + jws: Jws + payload: Buffer +} + +export interface VerifyJwsResult { + isValid: boolean + signerVerkeys: string[] +} diff --git a/packages/core/src/crypto/JwsTypes.ts b/packages/core/src/crypto/JwsTypes.ts new file mode 100644 index 0000000000..6f3b65d9eb --- /dev/null +++ b/packages/core/src/crypto/JwsTypes.ts @@ -0,0 +1,11 @@ +export interface JwsGeneralFormat { + header: Record + signature: string + protected: string +} + +export interface JwsFlattenedFormat { + signatures: JwsGeneralFormat[] +} + +export type Jws = JwsGeneralFormat | JwsFlattenedFormat diff --git a/packages/core/src/crypto/__tests__/JwsService.test.ts b/packages/core/src/crypto/__tests__/JwsService.test.ts new file mode 100644 index 0000000000..d36f402bb6 --- /dev/null +++ b/packages/core/src/crypto/__tests__/JwsService.test.ts @@ -0,0 +1,82 @@ +import type { Wallet } from '@aries-framework/core' + +import { getAgentConfig } from '../../../tests/helpers' +import { DidKey, KeyType } from '../../modules/dids' +import { JsonEncoder } from '../../utils' +import { IndyWallet } from '../../wallet/IndyWallet' +import { JwsService } from '../JwsService' + +import * as didJwsz6Mkf from './__fixtures__/didJwsz6Mkf' +import * as didJwsz6Mkv from './__fixtures__/didJwsz6Mkv' + +describe('JwsService', () => { + let wallet: Wallet + let jwsService: JwsService + + beforeAll(async () => { + const config = getAgentConfig('JwsService') + wallet = new IndyWallet(config) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await wallet.initialize(config.walletConfig!) + + jwsService = new JwsService(wallet) + }) + + afterAll(async () => { + await wallet.delete() + }) + + describe('createJws', () => { + it('creates a jws for the payload with the key associated with the verkey', async () => { + const { verkey } = await wallet.createDid({ seed: didJwsz6Mkf.SEED }) + + const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON) + const kid = DidKey.fromPublicKeyBase58(verkey, KeyType.ED25519).did + + const jws = await jwsService.createJws({ + payload, + verkey, + header: { kid }, + }) + + expect(jws).toEqual(didJwsz6Mkf.JWS_JSON) + }) + }) + + describe('verifyJws', () => { + it('returns true if the jws signature matches the payload', async () => { + const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON) + + const { isValid, signerVerkeys } = await jwsService.verifyJws({ + payload, + jws: didJwsz6Mkf.JWS_JSON, + }) + + expect(isValid).toBe(true) + expect(signerVerkeys).toEqual([didJwsz6Mkf.VERKEY]) + }) + + it('returns all verkeys that signed the jws', async () => { + const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON) + + const { isValid, signerVerkeys } = await jwsService.verifyJws({ + payload, + jws: { signatures: [didJwsz6Mkf.JWS_JSON, didJwsz6Mkv.JWS_JSON] }, + }) + + expect(isValid).toBe(true) + expect(signerVerkeys).toEqual([didJwsz6Mkf.VERKEY, didJwsz6Mkv.VERKEY]) + }) + it('returns false if the jws signature does not match the payload', async () => { + const payload = JsonEncoder.toBuffer({ ...didJwsz6Mkf.DATA_JSON, did: 'another_did' }) + + const { isValid, signerVerkeys } = await jwsService.verifyJws({ + payload, + jws: didJwsz6Mkf.JWS_JSON, + }) + + expect(isValid).toBe(false) + expect(signerVerkeys).toMatchObject([]) + }) + }) +}) diff --git a/packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkf.ts b/packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkf.ts new file mode 100644 index 0000000000..8524f12301 --- /dev/null +++ b/packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkf.ts @@ -0,0 +1,26 @@ +export const SEED = '00000000000000000000000000000My2' +export const VERKEY = 'kqa2HyagzfMAq42H5f9u3UMwnSBPQx2QfrSyXbUPxMn' + +export const DATA_JSON = { + did: 'did', + did_doc: { + '@context': 'https://w3id.org/did/v1', + service: [ + { + id: 'did:example:123456789abcdefghi#did-communication', + type: 'did-communication', + priority: 0, + recipientKeys: ['someVerkey'], + routingKeys: [], + serviceEndpoint: 'https://agent.example.com/', + }, + ], + }, +} + +export const JWS_JSON = { + header: { kid: 'did:key:z6MkfD6ccYE22Y9pHKtixeczk92MmMi2oJCP6gmNooZVKB9A' }, + protected: + 'eyJhbGciOiJFZERTQSIsImp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6IkN6cmtiNjQ1MzdrVUVGRkN5SXI4STgxUWJJRGk2MnNrbU41Rm41LU1zVkUifX0', + signature: 'OsDP4FM8792J9JlessA9IXv4YUYjIGcIAnPPrEJmgxYomMwDoH-h2DMAF5YF2VtsHHyhGN_0HryDjWSEAZdYBQ', +} diff --git a/packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkv.ts b/packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkv.ts new file mode 100644 index 0000000000..fe31ea8808 --- /dev/null +++ b/packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkv.ts @@ -0,0 +1,28 @@ +export const SEED = '00000000000000000000000000000My1' +export const VERKEY = 'GjZWsBLgZCR18aL468JAT7w9CZRiBnpxUPPgyQxh4voa' + +export const DATA_JSON = { + did: 'did', + did_doc: { + '@context': 'https://w3id.org/did/v1', + service: [ + { + id: 'did:example:123456789abcdefghi#did-communication', + type: 'did-communication', + priority: 0, + recipientKeys: ['someVerkey'], + routingKeys: [], + serviceEndpoint: 'https://agent.example.com/', + }, + ], + }, +} + +export const JWS_JSON = { + header: { + kid: 'did:key:z6MkvBpZTRb7tjuUF5AkmhG1JDV928hZbg5KAQJcogvhz9ax', + }, + protected: + 'eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3ZCcFpUUmI3dGp1VUY1QWttaEcxSkRWOTI4aFpiZzVLQVFKY29ndmh6OWF4IiwiandrIjp7Imt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiNmNaMmJaS21LaVVpRjlNTEtDVjhJSVlJRXNPTEhzSkc1cUJKOVNyUVlCayIsImtpZCI6ImRpZDprZXk6ejZNa3ZCcFpUUmI3dGp1VUY1QWttaEcxSkRWOTI4aFpiZzVLQVFKY29ndmh6OWF4In19', + signature: 'eA3MPRpSTt5NR8EZkDNb849E9qfrlUm8-StWPA4kMp-qcH7oEc2-1En4fgpz_IWinEbVxCLbmKhWNyaTAuHNAg', +} diff --git a/packages/core/src/decorators/attachment/Attachment.ts b/packages/core/src/decorators/attachment/Attachment.ts index 24044e9d05..b39fa52d8d 100644 --- a/packages/core/src/decorators/attachment/Attachment.ts +++ b/packages/core/src/decorators/attachment/Attachment.ts @@ -1,3 +1,5 @@ +import type { JwsGeneralFormat } from '../../crypto/JwsTypes' + import { Expose, Type } from 'class-transformer' import { IsBase64, @@ -11,6 +13,7 @@ import { ValidateNested, } from 'class-validator' +import { Jws } from '../../crypto/JwsTypes' import { AriesFrameworkError } from '../../error' import { JsonEncoder } from '../../utils/JsonEncoder' import { uuid } from '../../utils/uuid' @@ -29,7 +32,7 @@ export interface AttachmentDataOptions { base64?: string json?: Record links?: string[] - jws?: Record + jws?: Jws sha256?: string } @@ -37,29 +40,6 @@ export interface AttachmentDataOptions { * A JSON object that gives access to the actual content of the attachment */ export class AttachmentData { - public constructor(options: AttachmentDataOptions) { - if (options) { - this.base64 = options.base64 - this.json = options.json - this.links = options.links - this.jws = options.jws - this.sha256 = options.sha256 - } - } - - /* - * Helper function returning JSON representation of attachment data (if present). Tries to obtain the data from .base64 or .json, throws an error otherwise - */ - public getDataAsJson(): T { - if (typeof this.base64 === 'string') { - return JsonEncoder.fromBase64(this.base64) as T - } else if (this.json) { - return this.json as T - } else { - throw new AriesFrameworkError('No attachment data found in `json` or `base64` data fields.') - } - } - /** * Base64-encoded data, when representing arbitrary content inline instead of via links. Optional. */ @@ -84,7 +64,7 @@ export class AttachmentData { * A JSON Web Signature over the content of the attachment. Optional. */ @IsOptional() - public jws?: Record + public jws?: Jws /** * The hash of the content. Optional. @@ -92,6 +72,16 @@ export class AttachmentData { @IsOptional() @IsHash('sha256') public sha256?: string + + public constructor(options: AttachmentDataOptions) { + if (options) { + this.base64 = options.base64 + this.json = options.json + this.links = options.links + this.jws = options.jws + this.sha256 = options.sha256 + } + } } /** @@ -157,4 +147,34 @@ export class Attachment { @ValidateNested() @IsInstance(AttachmentData) public data!: AttachmentData + + /* + * Helper function returning JSON representation of attachment data (if present). Tries to obtain the data from .base64 or .json, throws an error otherwise + */ + public getDataAsJson(): T { + if (typeof this.data.base64 === 'string') { + return JsonEncoder.fromBase64(this.data.base64) as T + } else if (this.data.json) { + return this.data.json as T + } else { + throw new AriesFrameworkError('No attachment data found in `json` or `base64` data fields.') + } + } + + public addJws(jws: JwsGeneralFormat) { + // If no JWS yet, assign to current JWS + if (!this.data.jws) { + this.data.jws = jws + } + // Is already jws array, add to it + else if ('signatures' in this.data.jws) { + this.data.jws.signatures.push(jws) + } + // If already single JWS, transform to general jws format + else { + this.data.jws = { + signatures: [this.data.jws, jws], + } + } + } } diff --git a/packages/core/src/decorators/attachment/Attachment.test.ts b/packages/core/src/decorators/attachment/__tests__/Attachment.test.ts similarity index 65% rename from packages/core/src/decorators/attachment/Attachment.test.ts rename to packages/core/src/decorators/attachment/__tests__/Attachment.test.ts index b983de2a53..487c846291 100644 --- a/packages/core/src/decorators/attachment/Attachment.test.ts +++ b/packages/core/src/decorators/attachment/__tests__/Attachment.test.ts @@ -1,7 +1,8 @@ -import { JsonEncoder } from '../../utils/JsonEncoder' -import { JsonTransformer } from '../../utils/JsonTransformer' - -import { Attachment, AttachmentData } from './Attachment' +import * as didJwsz6Mkf from '../../../crypto/__tests__/__fixtures__/didJwsz6Mkf' +import * as didJwsz6Mkv from '../../../crypto/__tests__/__fixtures__/didJwsz6Mkv' +import { JsonEncoder } from '../../../utils/JsonEncoder' +import { JsonTransformer } from '../../../utils/JsonTransformer' +import { Attachment, AttachmentData } from '../Attachment' const mockJson = { '@id': 'ceffce22-6471-43e4-8945-b604091981c9', @@ -84,14 +85,42 @@ describe('Decorators | Attachment', () => { it('should return the data correctly if only JSON exists', () => { const decorator = JsonTransformer.fromJSON(mockJson, Attachment) - const gotData = decorator.data.getDataAsJson() + const gotData = decorator.getDataAsJson() expect(decorator.data.json).toEqual(gotData) }) it('should return the data correctly if only Base64 exists', () => { const decorator = JsonTransformer.fromJSON(mockJsonBase64, Attachment) - const gotData = decorator.data.getDataAsJson() + const gotData = decorator.getDataAsJson() expect(mockJson.data.json).toEqual(gotData) }) + + describe('addJws', () => { + it('correctly adds the jws to the data', async () => { + const base64 = JsonEncoder.toBase64(didJwsz6Mkf.DATA_JSON) + const attachment = new Attachment({ + id: 'some-uuid', + data: new AttachmentData({ + base64, + }), + }) + + expect(attachment.data.jws).toBeUndefined() + + attachment.addJws(didJwsz6Mkf.JWS_JSON) + expect(attachment.data.jws).toEqual(didJwsz6Mkf.JWS_JSON) + + attachment.addJws(didJwsz6Mkv.JWS_JSON) + expect(attachment.data.jws).toEqual({ signatures: [didJwsz6Mkf.JWS_JSON, didJwsz6Mkv.JWS_JSON] }) + + expect(JsonTransformer.toJSON(attachment)).toMatchObject({ + '@id': 'some-uuid', + data: { + base64: JsonEncoder.toBase64(didJwsz6Mkf.DATA_JSON), + jws: { signatures: [didJwsz6Mkf.JWS_JSON, didJwsz6Mkv.JWS_JSON] }, + }, + }) + }) + }) }) diff --git a/packages/core/src/modules/credentials/messages/IssueCredentialMessage.ts b/packages/core/src/modules/credentials/messages/IssueCredentialMessage.ts index c5e4689137..f24a0b7625 100644 --- a/packages/core/src/modules/credentials/messages/IssueCredentialMessage.ts +++ b/packages/core/src/modules/credentials/messages/IssueCredentialMessage.ts @@ -48,7 +48,7 @@ export class IssueCredentialMessage extends AgentMessage { const attachment = this.credentialAttachments.find((attachment) => attachment.id === INDY_CREDENTIAL_ATTACHMENT_ID) // Extract credential from attachment - const credentialJson = attachment?.data?.getDataAsJson() ?? null + const credentialJson = attachment?.getDataAsJson() ?? null return credentialJson } diff --git a/packages/core/src/modules/credentials/messages/OfferCredentialMessage.ts b/packages/core/src/modules/credentials/messages/OfferCredentialMessage.ts index c1b1953e8d..cb3e5e05aa 100644 --- a/packages/core/src/modules/credentials/messages/OfferCredentialMessage.ts +++ b/packages/core/src/modules/credentials/messages/OfferCredentialMessage.ts @@ -63,7 +63,7 @@ export class OfferCredentialMessage extends AgentMessage { const attachment = this.offerAttachments.find((attachment) => attachment.id === INDY_CREDENTIAL_OFFER_ATTACHMENT_ID) // Extract credential offer from attachment - const credentialOfferJson = attachment?.data?.getDataAsJson() ?? null + const credentialOfferJson = attachment?.getDataAsJson() ?? null return credentialOfferJson } diff --git a/packages/core/src/modules/credentials/messages/RequestCredentialMessage.ts b/packages/core/src/modules/credentials/messages/RequestCredentialMessage.ts index e377f6b5f8..57373f010f 100644 --- a/packages/core/src/modules/credentials/messages/RequestCredentialMessage.ts +++ b/packages/core/src/modules/credentials/messages/RequestCredentialMessage.ts @@ -49,7 +49,7 @@ export class RequestCredentialMessage extends AgentMessage { (attachment) => attachment.id === INDY_CREDENTIAL_REQUEST_ATTACHMENT_ID ) // Extract proof request from attachment - const credentialReqJson = attachment?.data?.getDataAsJson() ?? null + const credentialReqJson = attachment?.getDataAsJson() ?? null return credentialReqJson } diff --git a/packages/core/src/modules/dids/domain/index.ts b/packages/core/src/modules/dids/domain/index.ts index bf0ff1c854..e6cca204e5 100644 --- a/packages/core/src/modules/dids/domain/index.ts +++ b/packages/core/src/modules/dids/domain/index.ts @@ -1,4 +1,5 @@ export * from './service' export * from './verificationMethod' export * from './DidDocument' +export * from './DidKey' export * from './DidDocumentBuilder' diff --git a/packages/core/src/modules/dids/index.ts b/packages/core/src/modules/dids/index.ts index 9890f21e98..77ddf8c78c 100644 --- a/packages/core/src/modules/dids/index.ts +++ b/packages/core/src/modules/dids/index.ts @@ -1,3 +1,4 @@ export * from './types' +export * from './domain' export * from './DidsModule' export * from './services' diff --git a/packages/core/src/modules/proofs/messages/PresentationMessage.ts b/packages/core/src/modules/proofs/messages/PresentationMessage.ts index 04f56ac045..a306026abe 100644 --- a/packages/core/src/modules/proofs/messages/PresentationMessage.ts +++ b/packages/core/src/modules/proofs/messages/PresentationMessage.ts @@ -59,7 +59,7 @@ export class PresentationMessage extends AgentMessage { public get indyProof(): IndyProof | null { const attachment = this.presentationAttachments.find((attachment) => attachment.id === INDY_PROOF_ATTACHMENT_ID) - const proofJson = attachment?.data?.getDataAsJson() ?? null + const proofJson = attachment?.getDataAsJson() ?? null return proofJson } diff --git a/packages/core/src/modules/proofs/messages/RequestPresentationMessage.ts b/packages/core/src/modules/proofs/messages/RequestPresentationMessage.ts index 585e40b1ac..36ef861737 100644 --- a/packages/core/src/modules/proofs/messages/RequestPresentationMessage.ts +++ b/packages/core/src/modules/proofs/messages/RequestPresentationMessage.ts @@ -58,7 +58,7 @@ export class RequestPresentationMessage extends AgentMessage { (attachment) => attachment.id === INDY_PROOF_REQUEST_ATTACHMENT_ID ) // Extract proof request from attachment - const proofRequestJson = attachment?.data?.getDataAsJson() ?? null + const proofRequestJson = attachment?.getDataAsJson() ?? null const proofRequest = JsonTransformer.fromJSON(proofRequestJson, ProofRequest) return proofRequest diff --git a/packages/core/src/utils/BufferEncoder.ts b/packages/core/src/utils/BufferEncoder.ts index 18407796d2..55eb63da55 100644 --- a/packages/core/src/utils/BufferEncoder.ts +++ b/packages/core/src/utils/BufferEncoder.ts @@ -53,7 +53,7 @@ export class BufferEncoder { * * @param str the string to decode into buffer format */ - public static fromString(str: string): Uint8Array { + public static fromString(str: string): Buffer { return Buffer.from(str) } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000000..a67603c605 --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './BufferEncoder' +export * from './JsonEncoder' +export * from './buffer' diff --git a/packages/core/src/wallet/index.ts b/packages/core/src/wallet/index.ts new file mode 100644 index 0000000000..c9f6729d0c --- /dev/null +++ b/packages/core/src/wallet/index.ts @@ -0,0 +1 @@ +export * from './Wallet'