Skip to content

Commit eb49374

Browse files
authored
feat: add support for signed attachments (openwallet-foundation#595)
Signed-off-by: Timo Glastra <[email protected]> BREAKING CHANGE: attachment method `getDataAsJson` is now located one level up. So instead of `attachment.data.getDataAsJson()` you should now call `attachment.getDataAsJson()`
1 parent 8e03f35 commit eb49374

File tree

17 files changed

+360
-37
lines changed

17 files changed

+360
-37
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { Buffer } from '../utils'
2+
import type { Jws, JwsGeneralFormat } from './JwsTypes'
3+
4+
import { inject, Lifecycle, scoped } from 'tsyringe'
5+
6+
import { InjectionSymbols } from '../constants'
7+
import { AriesFrameworkError } from '../error'
8+
import { JsonEncoder, BufferEncoder } from '../utils'
9+
import { Wallet } from '../wallet'
10+
import { WalletError } from '../wallet/error'
11+
12+
// TODO: support more key types, more generic jws format
13+
const JWS_KEY_TYPE = 'OKP'
14+
const JWS_CURVE = 'Ed25519'
15+
const JWS_ALG = 'EdDSA'
16+
17+
@scoped(Lifecycle.ContainerScoped)
18+
export class JwsService {
19+
private wallet: Wallet
20+
21+
public constructor(@inject(InjectionSymbols.Wallet) wallet: Wallet) {
22+
this.wallet = wallet
23+
}
24+
25+
public async createJws({ payload, verkey, header }: CreateJwsOptions): Promise<JwsGeneralFormat> {
26+
const base64Payload = BufferEncoder.toBase64URL(payload)
27+
const base64Protected = JsonEncoder.toBase64URL(this.buildProtected(verkey))
28+
29+
const signature = BufferEncoder.toBase64URL(
30+
await this.wallet.sign(BufferEncoder.fromString(`${base64Protected}.${base64Payload}`), verkey)
31+
)
32+
33+
return {
34+
protected: base64Protected,
35+
signature,
36+
header,
37+
}
38+
}
39+
40+
/**
41+
* Verify a a JWS
42+
*/
43+
public async verifyJws({ jws, payload }: VerifyJwsOptions): Promise<VerifyJwsResult> {
44+
const base64Payload = BufferEncoder.toBase64URL(payload)
45+
const signatures = 'signatures' in jws ? jws.signatures : [jws]
46+
47+
const signerVerkeys = []
48+
for (const jws of signatures) {
49+
const protectedJson = JsonEncoder.fromBase64(jws.protected)
50+
51+
const isValidKeyType = protectedJson?.jwk?.kty === JWS_KEY_TYPE
52+
const isValidCurve = protectedJson?.jwk?.crv === JWS_CURVE
53+
const isValidAlg = protectedJson?.alg === JWS_ALG
54+
55+
if (!isValidKeyType || !isValidCurve || !isValidAlg) {
56+
throw new AriesFrameworkError('Invalid protected header')
57+
}
58+
59+
const data = BufferEncoder.fromString(`${jws.protected}.${base64Payload}`)
60+
const signature = BufferEncoder.fromBase64(jws.signature)
61+
62+
const verkey = BufferEncoder.toBase58(BufferEncoder.fromBase64(protectedJson?.jwk?.x))
63+
signerVerkeys.push(verkey)
64+
65+
try {
66+
const isValid = await this.wallet.verify(verkey, data, signature)
67+
68+
if (!isValid) {
69+
return {
70+
isValid: false,
71+
signerVerkeys: [],
72+
}
73+
}
74+
} catch (error) {
75+
// WalletError probably means signature verification failed. Would be useful to add
76+
// more specific error type in wallet.verify method
77+
if (error instanceof WalletError) {
78+
return {
79+
isValid: false,
80+
signerVerkeys: [],
81+
}
82+
}
83+
84+
throw error
85+
}
86+
}
87+
88+
return { isValid: true, signerVerkeys }
89+
}
90+
91+
/**
92+
* @todo This currently only work with a single alg, key type and curve
93+
* This needs to be extended with other formats in the future
94+
*/
95+
private buildProtected(verkey: string) {
96+
return {
97+
alg: 'EdDSA',
98+
jwk: {
99+
kty: 'OKP',
100+
crv: 'Ed25519',
101+
x: BufferEncoder.toBase64URL(BufferEncoder.fromBase58(verkey)),
102+
},
103+
}
104+
}
105+
}
106+
107+
export interface CreateJwsOptions {
108+
verkey: string
109+
payload: Buffer
110+
header: Record<string, unknown>
111+
}
112+
113+
export interface VerifyJwsOptions {
114+
jws: Jws
115+
payload: Buffer
116+
}
117+
118+
export interface VerifyJwsResult {
119+
isValid: boolean
120+
signerVerkeys: string[]
121+
}

packages/core/src/crypto/JwsTypes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface JwsGeneralFormat {
2+
header: Record<string, unknown>
3+
signature: string
4+
protected: string
5+
}
6+
7+
export interface JwsFlattenedFormat {
8+
signatures: JwsGeneralFormat[]
9+
}
10+
11+
export type Jws = JwsGeneralFormat | JwsFlattenedFormat
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Wallet } from '@aries-framework/core'
2+
3+
import { getAgentConfig } from '../../../tests/helpers'
4+
import { DidKey, KeyType } from '../../modules/dids'
5+
import { JsonEncoder } from '../../utils'
6+
import { IndyWallet } from '../../wallet/IndyWallet'
7+
import { JwsService } from '../JwsService'
8+
9+
import * as didJwsz6Mkf from './__fixtures__/didJwsz6Mkf'
10+
import * as didJwsz6Mkv from './__fixtures__/didJwsz6Mkv'
11+
12+
describe('JwsService', () => {
13+
let wallet: Wallet
14+
let jwsService: JwsService
15+
16+
beforeAll(async () => {
17+
const config = getAgentConfig('JwsService')
18+
wallet = new IndyWallet(config)
19+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
20+
await wallet.initialize(config.walletConfig!)
21+
22+
jwsService = new JwsService(wallet)
23+
})
24+
25+
afterAll(async () => {
26+
await wallet.delete()
27+
})
28+
29+
describe('createJws', () => {
30+
it('creates a jws for the payload with the key associated with the verkey', async () => {
31+
const { verkey } = await wallet.createDid({ seed: didJwsz6Mkf.SEED })
32+
33+
const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON)
34+
const kid = DidKey.fromPublicKeyBase58(verkey, KeyType.ED25519).did
35+
36+
const jws = await jwsService.createJws({
37+
payload,
38+
verkey,
39+
header: { kid },
40+
})
41+
42+
expect(jws).toEqual(didJwsz6Mkf.JWS_JSON)
43+
})
44+
})
45+
46+
describe('verifyJws', () => {
47+
it('returns true if the jws signature matches the payload', async () => {
48+
const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON)
49+
50+
const { isValid, signerVerkeys } = await jwsService.verifyJws({
51+
payload,
52+
jws: didJwsz6Mkf.JWS_JSON,
53+
})
54+
55+
expect(isValid).toBe(true)
56+
expect(signerVerkeys).toEqual([didJwsz6Mkf.VERKEY])
57+
})
58+
59+
it('returns all verkeys that signed the jws', async () => {
60+
const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON)
61+
62+
const { isValid, signerVerkeys } = await jwsService.verifyJws({
63+
payload,
64+
jws: { signatures: [didJwsz6Mkf.JWS_JSON, didJwsz6Mkv.JWS_JSON] },
65+
})
66+
67+
expect(isValid).toBe(true)
68+
expect(signerVerkeys).toEqual([didJwsz6Mkf.VERKEY, didJwsz6Mkv.VERKEY])
69+
})
70+
it('returns false if the jws signature does not match the payload', async () => {
71+
const payload = JsonEncoder.toBuffer({ ...didJwsz6Mkf.DATA_JSON, did: 'another_did' })
72+
73+
const { isValid, signerVerkeys } = await jwsService.verifyJws({
74+
payload,
75+
jws: didJwsz6Mkf.JWS_JSON,
76+
})
77+
78+
expect(isValid).toBe(false)
79+
expect(signerVerkeys).toMatchObject([])
80+
})
81+
})
82+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export const SEED = '00000000000000000000000000000My2'
2+
export const VERKEY = 'kqa2HyagzfMAq42H5f9u3UMwnSBPQx2QfrSyXbUPxMn'
3+
4+
export const DATA_JSON = {
5+
did: 'did',
6+
did_doc: {
7+
'@context': 'https://w3id.org/did/v1',
8+
service: [
9+
{
10+
id: 'did:example:123456789abcdefghi#did-communication',
11+
type: 'did-communication',
12+
priority: 0,
13+
recipientKeys: ['someVerkey'],
14+
routingKeys: [],
15+
serviceEndpoint: 'https://agent.example.com/',
16+
},
17+
],
18+
},
19+
}
20+
21+
export const JWS_JSON = {
22+
header: { kid: 'did:key:z6MkfD6ccYE22Y9pHKtixeczk92MmMi2oJCP6gmNooZVKB9A' },
23+
protected:
24+
'eyJhbGciOiJFZERTQSIsImp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6IkN6cmtiNjQ1MzdrVUVGRkN5SXI4STgxUWJJRGk2MnNrbU41Rm41LU1zVkUifX0',
25+
signature: 'OsDP4FM8792J9JlessA9IXv4YUYjIGcIAnPPrEJmgxYomMwDoH-h2DMAF5YF2VtsHHyhGN_0HryDjWSEAZdYBQ',
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const SEED = '00000000000000000000000000000My1'
2+
export const VERKEY = 'GjZWsBLgZCR18aL468JAT7w9CZRiBnpxUPPgyQxh4voa'
3+
4+
export const DATA_JSON = {
5+
did: 'did',
6+
did_doc: {
7+
'@context': 'https://w3id.org/did/v1',
8+
service: [
9+
{
10+
id: 'did:example:123456789abcdefghi#did-communication',
11+
type: 'did-communication',
12+
priority: 0,
13+
recipientKeys: ['someVerkey'],
14+
routingKeys: [],
15+
serviceEndpoint: 'https://agent.example.com/',
16+
},
17+
],
18+
},
19+
}
20+
21+
export const JWS_JSON = {
22+
header: {
23+
kid: 'did:key:z6MkvBpZTRb7tjuUF5AkmhG1JDV928hZbg5KAQJcogvhz9ax',
24+
},
25+
protected:
26+
'eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3ZCcFpUUmI3dGp1VUY1QWttaEcxSkRWOTI4aFpiZzVLQVFKY29ndmh6OWF4IiwiandrIjp7Imt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiNmNaMmJaS21LaVVpRjlNTEtDVjhJSVlJRXNPTEhzSkc1cUJKOVNyUVlCayIsImtpZCI6ImRpZDprZXk6ejZNa3ZCcFpUUmI3dGp1VUY1QWttaEcxSkRWOTI4aFpiZzVLQVFKY29ndmh6OWF4In19',
27+
signature: 'eA3MPRpSTt5NR8EZkDNb849E9qfrlUm8-StWPA4kMp-qcH7oEc2-1En4fgpz_IWinEbVxCLbmKhWNyaTAuHNAg',
28+
}

packages/core/src/decorators/attachment/Attachment.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { JwsGeneralFormat } from '../../crypto/JwsTypes'
2+
13
import { Expose, Type } from 'class-transformer'
24
import {
35
IsBase64,
@@ -11,6 +13,7 @@ import {
1113
ValidateNested,
1214
} from 'class-validator'
1315

16+
import { Jws } from '../../crypto/JwsTypes'
1417
import { AriesFrameworkError } from '../../error'
1518
import { JsonEncoder } from '../../utils/JsonEncoder'
1619
import { uuid } from '../../utils/uuid'
@@ -29,37 +32,14 @@ export interface AttachmentDataOptions {
2932
base64?: string
3033
json?: Record<string, unknown>
3134
links?: string[]
32-
jws?: Record<string, unknown>
35+
jws?: Jws
3336
sha256?: string
3437
}
3538

3639
/**
3740
* A JSON object that gives access to the actual content of the attachment
3841
*/
3942
export class AttachmentData {
40-
public constructor(options: AttachmentDataOptions) {
41-
if (options) {
42-
this.base64 = options.base64
43-
this.json = options.json
44-
this.links = options.links
45-
this.jws = options.jws
46-
this.sha256 = options.sha256
47-
}
48-
}
49-
50-
/*
51-
* Helper function returning JSON representation of attachment data (if present). Tries to obtain the data from .base64 or .json, throws an error otherwise
52-
*/
53-
public getDataAsJson<T>(): T {
54-
if (typeof this.base64 === 'string') {
55-
return JsonEncoder.fromBase64(this.base64) as T
56-
} else if (this.json) {
57-
return this.json as T
58-
} else {
59-
throw new AriesFrameworkError('No attachment data found in `json` or `base64` data fields.')
60-
}
61-
}
62-
6343
/**
6444
* Base64-encoded data, when representing arbitrary content inline instead of via links. Optional.
6545
*/
@@ -84,14 +64,24 @@ export class AttachmentData {
8464
* A JSON Web Signature over the content of the attachment. Optional.
8565
*/
8666
@IsOptional()
87-
public jws?: Record<string, unknown>
67+
public jws?: Jws
8868

8969
/**
9070
* The hash of the content. Optional.
9171
*/
9272
@IsOptional()
9373
@IsHash('sha256')
9474
public sha256?: string
75+
76+
public constructor(options: AttachmentDataOptions) {
77+
if (options) {
78+
this.base64 = options.base64
79+
this.json = options.json
80+
this.links = options.links
81+
this.jws = options.jws
82+
this.sha256 = options.sha256
83+
}
84+
}
9585
}
9686

9787
/**
@@ -157,4 +147,34 @@ export class Attachment {
157147
@ValidateNested()
158148
@IsInstance(AttachmentData)
159149
public data!: AttachmentData
150+
151+
/*
152+
* Helper function returning JSON representation of attachment data (if present). Tries to obtain the data from .base64 or .json, throws an error otherwise
153+
*/
154+
public getDataAsJson<T>(): T {
155+
if (typeof this.data.base64 === 'string') {
156+
return JsonEncoder.fromBase64(this.data.base64) as T
157+
} else if (this.data.json) {
158+
return this.data.json as T
159+
} else {
160+
throw new AriesFrameworkError('No attachment data found in `json` or `base64` data fields.')
161+
}
162+
}
163+
164+
public addJws(jws: JwsGeneralFormat) {
165+
// If no JWS yet, assign to current JWS
166+
if (!this.data.jws) {
167+
this.data.jws = jws
168+
}
169+
// Is already jws array, add to it
170+
else if ('signatures' in this.data.jws) {
171+
this.data.jws.signatures.push(jws)
172+
}
173+
// If already single JWS, transform to general jws format
174+
else {
175+
this.data.jws = {
176+
signatures: [this.data.jws, jws],
177+
}
178+
}
179+
}
160180
}

0 commit comments

Comments
 (0)