Skip to content

Commit b1b1cfa

Browse files
authored
fix(didcomm): tolerate base64url in signed attachment payloads (#2761)
Signed-off-by: Ariel Gentile <gentilester@gmail.com>
1 parent 66a726a commit b1b1cfa

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

packages/didcomm/src/decorators/attachment/DidCommAttachment.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { JwsDetachedFormat, JwsFlattenedDetachedFormat, JwsGeneralFormat } from '@credo-ts/core'
22

3-
import { CredoError, JsonEncoder, type JsonValue, utils } from '@credo-ts/core'
3+
import { CredoError, JsonEncoder, type JsonValue, TypedArrayEncoder, utils } from '@credo-ts/core'
44
import { Expose, Type } from 'class-transformer'
55
import { IsDate, IsHash, IsInstance, IsInt, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator'
66

@@ -135,12 +135,39 @@ export class DidCommAttachment {
135135
@IsInstance(DidCommAttachmentData)
136136
public data!: DidCommAttachmentData
137137

138+
/*
139+
* Helper function returning the raw bytes of a base64 attachment payload.
140+
*
141+
* The DIDComm/Aries RFC 0017 spec mandates base64url for `data.base64`, so we try
142+
* decoding it as base64url first. Credo itself (and other implementations) has
143+
* historically emitted standard base64 with `+`/`/` and `=` padding, so we fall
144+
* back to that to preserve interop with older agents.
145+
*/
146+
public getDataAsUint8Array(): Uint8Array {
147+
if (typeof this.data.base64 !== 'string') {
148+
throw new CredoError('No base64 attachment data found.')
149+
}
150+
try {
151+
// `fromBase64Url` expects the no-padding variant, strip trailing `=` so
152+
// both padded and unpadded base64url inputs are accepted.
153+
return TypedArrayEncoder.fromBase64Url(this.data.base64.replace(/=+$/, ''))
154+
} catch (base64UrlError) {
155+
try {
156+
return TypedArrayEncoder.fromBase64(this.data.base64)
157+
} catch {
158+
throw new CredoError('Could not decode attachment data as base64url or base64 string.', {
159+
cause: base64UrlError,
160+
})
161+
}
162+
}
163+
}
164+
138165
/*
139166
* Helper function returning JSON representation of attachment data (if present). Tries to obtain the data from .base64 or .json, throws an error otherwise
140167
*/
141168
public getDataAsJson<T>(): T {
142169
if (typeof this.data.base64 === 'string') {
143-
return JsonEncoder.fromBase64(this.data.base64) as T
170+
return JsonEncoder.fromUint8Array(this.getDataAsUint8Array()) as T
144171
}
145172
if (this.data.json) {
146173
return this.data.json as T

packages/didcomm/src/decorators/attachment/__tests__/Attachment.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as didJwsz6Mkf from '../../../../../core/src/crypto/__tests__/__fixture
22
import * as didJwsz6Mkv from '../../../../../core/src/crypto/__tests__/__fixtures__/didJwsz6Mkv'
33
import { JsonEncoder } from '../../../../../core/src/utils/JsonEncoder'
44
import { JsonTransformer } from '../../../../../core/src/utils/JsonTransformer'
5+
import { TypedArrayEncoder } from '../../../../../core/src/utils/TypedArrayEncoder'
56
import { DidCommAttachment, DidCommAttachmentData } from '../DidCommAttachment'
67

78
const mockJson = {
@@ -96,6 +97,60 @@ describe('Decorators | DidCommAttachment', () => {
9697
expect(mockJson.data.json).toEqual(gotData)
9798
})
9899

100+
describe('getDataAsUint8Array', () => {
101+
// Bytes chosen so their base64 encoding exercises both `+` and `/` characters
102+
// (the ones that differ between the standard and url-safe alphabets) *and*
103+
// requires `=` padding. This way the url-alphabet / no-padding variants
104+
// below are genuinely different strings from the canonical base64, not
105+
// no-ops.
106+
const payload = new Uint8Array([0xff, 0xe0, 0x3f, 0xff, 0xfe])
107+
const padded = TypedArrayEncoder.toBase64(payload) // has '+', '/', and '=' padding
108+
const unpadded = padded.replace(/=+$/, '')
109+
const urlPadded = padded.replace(/\+/g, '-').replace(/\//g, '_')
110+
const urlUnpadded = unpadded.replace(/\+/g, '-').replace(/\//g, '_')
111+
112+
test.each([
113+
['standard base64 with padding', padded],
114+
['base64url with padding', urlPadded],
115+
['base64url without padding', urlUnpadded],
116+
])('decodes %s', (_label, encoded) => {
117+
const attachment = new DidCommAttachment({
118+
id: 'some-uuid',
119+
data: new DidCommAttachmentData({ base64: encoded }),
120+
})
121+
expect(attachment.getDataAsUint8Array()).toEqual(payload)
122+
})
123+
124+
it('throws when no base64 payload is present', () => {
125+
const attachment = new DidCommAttachment({
126+
id: 'some-uuid',
127+
data: new DidCommAttachmentData({ json: { hello: 'world' } }),
128+
})
129+
expect(() => attachment.getDataAsUint8Array()).toThrow(/No base64 attachment data found/)
130+
})
131+
132+
it('throws a clear error for genuinely invalid input', () => {
133+
const attachment = new DidCommAttachment({
134+
id: 'some-uuid',
135+
data: new DidCommAttachmentData({ base64: 'this is definitely not base64 $$$' }),
136+
})
137+
expect(() => attachment.getDataAsUint8Array()).toThrow(
138+
/Could not decode attachment data as base64url or base64 string/
139+
)
140+
})
141+
142+
it('getDataAsJson delegates to getDataAsUint8Array, accepting base64url-without-padding too', () => {
143+
const json = { hello: 'world' }
144+
const standardPadded = JsonEncoder.toBase64(json)
145+
const urlNoPad = standardPadded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
146+
const attachment = new DidCommAttachment({
147+
id: 'some-uuid',
148+
data: new DidCommAttachmentData({ base64: urlNoPad }),
149+
})
150+
expect(attachment.getDataAsJson()).toEqual(json)
151+
})
152+
})
153+
99154
describe('addJws', () => {
100155
it('correctly adds the jws to the data', async () => {
101156
const base64 = JsonEncoder.toBase64(didJwsz6Mkf.DATA_JSON)

packages/didcomm/src/modules/connections/DidExchangeProtocol.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ export class DidExchangeProtocol {
570570
throw new CredoError('DID Rotate attachment is missing base64 property for signed did.')
571571
}
572572

573-
const payload = TypedArrayEncoder.fromBase64(didRotateAttachment.data.base64)
573+
const payload = didRotateAttachment.getDataAsUint8Array()
574574
// JWS payload must be base64url encoded
575575

576576
const signedDid = TypedArrayEncoder.toUtf8String(payload)
@@ -675,7 +675,7 @@ export class DidExchangeProtocol {
675675
const { isValid, jwsSigners } = await this.jwsService.verifyJws(agentContext, {
676676
jws: {
677677
...jws,
678-
payload: TypedArrayEncoder.toBase64Url(TypedArrayEncoder.fromBase64(didDocumentAttachment.data.base64)),
678+
payload: TypedArrayEncoder.toBase64Url(didDocumentAttachment.getDataAsUint8Array()),
679679
},
680680
allowedJwsSignerMethods: ['did'],
681681
resolveJwsSigner: ({ jws: { header } }) => {
@@ -692,7 +692,7 @@ export class DidExchangeProtocol {
692692
},
693693
})
694694

695-
const json = JsonEncoder.fromBase64(didDocumentAttachment.data.base64)
695+
const json = didDocumentAttachment.getDataAsJson()
696696
const didDocument = JsonTransformer.fromJSON(json, DidDocument)
697697
const didDocumentKeys = didDocument.authentication
698698
?.map((authentication) => {

0 commit comments

Comments
 (0)