Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for signed attachments #595

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions packages/core/src/crypto/JwsService.ts
Original file line number Diff line number Diff line change
@@ -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<JwsGeneralFormat> {
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<VerifyJwsResult> {
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<string, unknown>
}

export interface VerifyJwsOptions {
jws: Jws
payload: Buffer
}

export interface VerifyJwsResult {
isValid: boolean
signerVerkeys: string[]
}
11 changes: 11 additions & 0 deletions packages/core/src/crypto/JwsTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface JwsGeneralFormat {
header: Record<string, unknown>
signature: string
protected: string
}

export interface JwsFlattenedFormat {
signatures: JwsGeneralFormat[]
}

export type Jws = JwsGeneralFormat | JwsFlattenedFormat
82 changes: 82 additions & 0 deletions packages/core/src/crypto/__tests__/JwsService.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
})
26 changes: 26 additions & 0 deletions packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkf.ts
Original file line number Diff line number Diff line change
@@ -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',
}
28 changes: 28 additions & 0 deletions packages/core/src/crypto/__tests__/__fixtures__/didJwsz6Mkv.ts
Original file line number Diff line number Diff line change
@@ -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',
}
70 changes: 45 additions & 25 deletions packages/core/src/decorators/attachment/Attachment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { JwsGeneralFormat } from '../../crypto/JwsTypes'

import { Expose, Type } from 'class-transformer'
import {
IsBase64,
Expand All @@ -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'
Expand All @@ -29,37 +32,14 @@ export interface AttachmentDataOptions {
base64?: string
json?: Record<string, unknown>
links?: string[]
jws?: Record<string, unknown>
jws?: Jws
sha256?: string
}

/**
* 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>(): 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.
*/
Expand All @@ -84,14 +64,24 @@ export class AttachmentData {
* A JSON Web Signature over the content of the attachment. Optional.
*/
@IsOptional()
public jws?: Record<string, unknown>
public jws?: Jws

/**
* The hash of the content. Optional.
*/
@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
}
}
}

/**
Expand Down Expand Up @@ -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>(): 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],
}
}
}
}
Loading