Skip to content

Commit

Permalink
feat: add support for signed attachments (#595)
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <timo@animo.id>

BREAKING CHANGE: attachment method `getDataAsJson` is now located one level up. So instead of `attachment.data.getDataAsJson()` you should now call `attachment.getDataAsJson()`
  • Loading branch information
TimoGlastra committed Feb 8, 2022
1 parent 8e03f35 commit 1c9d6b9
Show file tree
Hide file tree
Showing 17 changed files with 360 additions and 37 deletions.
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

0 comments on commit 1c9d6b9

Please sign in to comment.