Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/bumpy-crabs-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@animo-id/mdoc": minor
---

feat: support more than one certificate in the certificate chain when signing an mdoc. The `certificate` parameter has been renamed to `certificates` and now expects an array with at least one certificate.
2 changes: 1 addition & 1 deletion src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class Issuer {
digestAlgorithm: DigestAlgorithm
validityInfo: ValidityInfo | ValidityInfoOptions
deviceKeyInfo: DeviceKeyInfo | DeviceKeyInfoOptions
certificate: Uint8Array
certificates: [Uint8Array, ...Uint8Array[]]
}): Promise<IssuerSigned> {
const signingKey = options.signingKey instanceof CoseKey ? options.signingKey : CoseKey.fromJwk(options.signingKey)
return await this.isb.sign({ ...options, signingKey })
Expand Down
6 changes: 4 additions & 2 deletions src/mdoc/builders/issuer-signed-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class IssuerSignedBuilder {
digestAlgorithm: DigestAlgorithm
validityInfo: ValidityInfo | ValidityInfoOptions
deviceKeyInfo: DeviceKeyInfo | DeviceKeyInfoOptions
certificate: Uint8Array
certificates: [Uint8Array, ...Uint8Array[]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the idea of this type, but for me at least I always end up casting my Array<Uint8Array> to [Uint8Array, ...Uint8Array[]] which is rather annoying. If you see the added benefit for the type we can keep it, but it annoys me more in general then it helps.

Copy link
Member Author

@TimoGlastra TimoGlastra Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me it ensures the user of this library needs to think about the length of the chain. If you pass it like this:

{
 certificates: [leafCertificates]
}

It will not complain

But if you pass it like this:

{
  certificates: certificateChain
}

it would require the user to have checked themselves that they have at least one cert. So i like it since it either requires a cast (excplicit) or enforce you to adhrere to the type (explicit)

Copy link
Member

@berendsliedrecht berendsliedrecht Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the type, but checking certificateChain.length > 1 does not transform the type in an "at-least-one-member" type. The example below does not work and still requires casting

function y(arr: [string, ...string[]]) {
    console.log(arr)
}

 const x= ['a']

if(x.length < 1) {
    throw new Error('a')
}

y(x) // <-- ERROR

Copy link
Member Author

@TimoGlastra TimoGlastra Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no agreed, you have to write it in a typescript compatible way. I like this pattern, as it requires your runtime validation to match your compile time validation

function isNonEmptyArray<T extends any>(array: T[]): array is [T, ...T[]] {
  return array.length > 1
}

function assertNonEmptyArray<T extends any>(array: T[]): asserts array is [T, ...T[]] {
  if (array.length === 0) {
  throw new Error('Expected array to at least contain one entry')
}
}

function y(arr: [string, ...string[]]) {
    console.log(arr)
}

 
 const x= ['a']

if (isNonEmptyArray(x)) {
  y(x) // <-- SUCCESS
}

assertNonEmptyArray(x)
y(x) // <-- SUCCESS

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change it, it's fine

}): Promise<IssuerSigned> {
const validityInfo =
options.validityInfo instanceof ValidityInfo ? options.validityInfo : ValidityInfo.create(options.validityInfo)
Expand All @@ -105,7 +105,9 @@ export class IssuerSignedBuilder {
})

const unprotectedHeaders = UnprotectedHeaders.create({
unprotectedHeaders: new Map([[Header.X5Chain, options.certificate]]),
unprotectedHeaders: new Map([
[Header.X5Chain, options.certificates.length === 1 ? options.certificates[0] : options.certificates],
]),
})

if (options.signingKey.keyId) {
Expand Down
32 changes: 30 additions & 2 deletions tests/builders/issuer-signed-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { X509Certificate } from '@peculiar/x509'
import { describe, expect, test } from 'vitest'
import { CoseKey, DateOnly, DeviceKey, type IssuerSigned, SignatureAlgorithm } from '../../src'
import { CoseKey, DateOnly, DeviceKey, IssuerSigned, SignatureAlgorithm } from '../../src'
import { IssuerSignedBuilder } from '../../src/mdoc/builders/issuer-signed-builder'
import { DEVICE_JWK_PUBLIC, ISSUER_CERTIFICATE, ISSUER_PRIVATE_KEY_JWK } from '../config'
import { mdocContext } from '../context'
Expand Down Expand Up @@ -49,7 +49,7 @@ describe('issuer signed builder', () => {

issuerSigned = await issuerSignedBuilder.sign({
signingKey: coseKey,
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -101,4 +101,32 @@ describe('issuer signed builder', () => {
const prettyClaims = issuerSigned.getPrettyClaims('org.iso.18013.5.1')
expect(prettyClaims).toEqual(claims)
})

test('should support certificate chain with multiple certificates', async () => {
const issuerSignedBuilder = new IssuerSignedBuilder('org.iso.18013.5.1.mDL', mdocContext).addIssuerNamespace(
'org.iso.18013.5.1',
{ family_name: 'Smith' }
)

const cert1 = new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)
const cert2 = new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)

const issuerSignedWithChain = await issuerSignedBuilder.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificates: [cert1, cert2],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
validityInfo: { signed, validFrom, validUntil },
})

expect(issuerSignedWithChain.issuerAuth.certificateChain).toHaveLength(2)
expect(issuerSignedWithChain.issuerAuth.certificateChain[0]).toEqual(cert1)
expect(issuerSignedWithChain.issuerAuth.certificateChain[1]).toEqual(cert2)

// Verify that the certificate chain can be decoded correctly
const encodedChain = issuerSignedWithChain.encode()
const decodedChain = IssuerSigned.decode(encodedChain)
expect(decodedChain.issuerAuth.certificateChain).toHaveLength(2)
})
})
4 changes: 2 additions & 2 deletions tests/issuing/issuance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ suite('Issuance', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -59,7 +59,7 @@ suite('Issuance', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down
2 changes: 1 addition & 1 deletion tests/models/issuer-signed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('Issuer signed', () => {

const issuerSigned = await isb.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down
12 changes: 6 additions & 6 deletions tests/verification/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ suite('Verification', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -122,7 +122,7 @@ suite('Verification', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -214,7 +214,7 @@ suite('Verification', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -301,7 +301,7 @@ suite('Verification', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -378,7 +378,7 @@ suite('Verification', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down Expand Up @@ -424,7 +424,7 @@ suite('Verification', () => {

const issuerSigned = await issuer.sign({
signingKey: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK),
certificate: new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData),
certificates: [new Uint8Array(new X509Certificate(ISSUER_CERTIFICATE).rawData)],
algorithm: SignatureAlgorithm.ES256,
digestAlgorithm: 'SHA-256',
deviceKeyInfo: { deviceKey: DeviceKey.fromJwk(DEVICE_JWK_PUBLIC) },
Expand Down