Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main' into funke-release
Browse files Browse the repository at this point in the history
  • Loading branch information
berendsliedrecht committed Feb 18, 2025
2 parents 388309d + 1a4182e commit 49087f9
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 79 deletions.
6 changes: 6 additions & 0 deletions .changeset/mighty-jars-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@credo-ts/core': minor
---

- Included `CRLDistributionPoints` as extension
- Access to extensions and adding them made easier
111 changes: 38 additions & 73 deletions packages/core/src/modules/x509/X509Certificate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { X509CreateCertificateOptions } from './X509ServiceOptions'
import type { IssuerAlternativeNameExtension } from './extensions'
import type { AgentContext } from '../../agent'

import { AsnParser } from '@peculiar/asn1-schema'
import {
id_ce_authorityKeyIdentifier,
id_ce_extKeyUsage,
id_ce_issuerAltName,
id_ce_keyUsage,
id_ce_subjectAltName,
id_ce_subjectKeyIdentifier,
Expand All @@ -22,31 +24,14 @@ import {
convertName,
createAuthorityKeyIdentifierExtension,
createBasicConstraintsExtension,
createCrlDistributionPointsExtension,
createExtendedKeyUsagesExtension,
createIssuerAlternativeNameExtension,
createKeyUsagesExtension,
createSubjectAlternativeNameExtension,
createSubjectKeyIdentifierExtension,
} from './utils'

type ExtensionObjectIdentifier = string
type CanBeCritical<T> = T & { critical?: boolean }

type SubjectAlternativeNameExtension = CanBeCritical<{ name: Array<{ type: 'url' | 'dns'; value: string }> }>
type AuthorityKeyIdentifierExtension = CanBeCritical<{ keyId: string }>
type SubjectKeyIdentifierExtension = CanBeCritical<{ keyId: string }>
type KeyUsageExtension = CanBeCritical<{ usage: number }>
type ExtendedKeyUsageExtension = CanBeCritical<{ usage: Array<X509ExtendedKeyUsage> }>

type ExtensionValues =
| SubjectAlternativeNameExtension
| AuthorityKeyIdentifierExtension
| SubjectKeyIdentifierExtension
| KeyUsageExtension
| ExtendedKeyUsageExtension

type Extension = Record<ExtensionObjectIdentifier, ExtensionValues>

export enum X509KeyUsage {
DigitalSignature = 1,
NonRepudiation = 2,
Expand All @@ -72,19 +57,19 @@ export enum X509ExtendedKeyUsage {
export type X509CertificateOptions = {
publicKey: Key
privateKey?: Uint8Array
extensions?: Array<Extension>
extensions?: Array<x509.Extension>
rawCertificate: Uint8Array
}

export class X509Certificate {
public publicKey: Key
public privateKey?: Uint8Array
public extensions?: Array<Extension>
private extensions: Array<x509.Extension>

public readonly rawCertificate: Uint8Array

public constructor(options: X509CertificateOptions) {
this.extensions = options.extensions
this.extensions = options.extensions ?? []
this.publicKey = options.publicKey
this.privateKey = options.privateKey
this.rawCertificate = options.rawCertificate
Expand All @@ -109,65 +94,46 @@ export class X509Certificate {

const key = new Key(publicKeyBytes, keyType)

const extensions = certificate.extensions
.map((e) => {
if (e instanceof x509.AuthorityKeyIdentifierExtension) {
return { [e.type]: { keyId: e.keyId as string, critical: e.critical } }
} else if (e instanceof x509.SubjectKeyIdentifierExtension) {
return { [e.type]: { keyId: e.keyId, critical: e.critical } }
} else if (e instanceof x509.SubjectAlternativeNameExtension) {
return {
[e.type]: {
name: JSON.parse(JSON.stringify(e.names)) as SubjectAlternativeNameExtension['name'],
critical: e.critical,
},
}
} else if (e instanceof x509.KeyUsagesExtension) {
return { [e.type]: { usage: e.usages as number, critical: e.critical } }
} else if (e instanceof x509.ExtendedKeyUsageExtension) {
return { [e.type]: { usage: e.usages as Array<X509ExtendedKeyUsage>, critical: e.critical } }
}

// TODO: We could throw an error when we don't understand the extension?
// This will break everytime we do not understand an extension though
return undefined
})
.filter((e): e is Exclude<typeof e, undefined> => e !== undefined)

return new X509Certificate({
publicKey: key,
privateKey,
extensions: extensions.length > 0 ? extensions : undefined,
extensions: certificate.extensions,
rawCertificate: new Uint8Array(certificate.rawData),
})
}

private getMatchingExtensions<T extends ExtensionValues>(objectIdentifier: string): Array<T> | undefined {
return this.extensions?.map((e) => e[objectIdentifier])?.filter(Boolean) as Array<T> | undefined
private getMatchingExtensions<T = { critical: boolean }>(objectIdentifier: string): Array<T> | undefined {
return this.extensions.filter((e) => e.type === objectIdentifier) as Array<T> | undefined
}

public get subjectAlternativeNames() {
const san = this.getMatchingExtensions<x509.SubjectAlternativeNameExtension>(id_ce_subjectAltName)
return san?.flatMap((s) => s.names.items).map((i) => ({ type: i.type, value: i.value })) ?? []
}

public get issuerAlternativeNames() {
const ian = this.getMatchingExtensions<IssuerAlternativeNameExtension>(id_ce_issuerAltName)
return ian?.flatMap((i) => i.names.items).map((i) => ({ type: i.type, value: i.value })) ?? []
}

public get sanDnsNames() {
const san = this.getMatchingExtensions<SubjectAlternativeNameExtension>(id_ce_subjectAltName)
return (
san
?.flatMap((e) => e.name)
?.filter((e) => e.type === 'dns')
?.map((e) => e.value) ?? []
)
return this.subjectAlternativeNames.filter((san) => san.type === 'dns').map((san) => san.value)
}

public get sanUriNames() {
const san = this.getMatchingExtensions<SubjectAlternativeNameExtension>(id_ce_subjectAltName)
return (
san
?.flatMap((e) => e.name)
?.filter((e) => e.type === 'url')
?.map((e) => e.value) ?? []
)
return this.subjectAlternativeNames.filter((ian) => ian.type === 'url').map((ian) => ian.value)
}

public get ianDnsNames() {
return this.issuerAlternativeNames.filter((san) => san.type === 'dns').map((san) => san.value)
}

public get ianUriNames() {
return this.issuerAlternativeNames.filter((ian) => ian.type === 'url').map((ian) => ian.value)
}

public get authorityKeyIdentifier() {
const keyIds = this.getMatchingExtensions<AuthorityKeyIdentifierExtension>(id_ce_authorityKeyIdentifier)?.map(
const keyIds = this.getMatchingExtensions<x509.AuthorityKeyIdentifierExtension>(id_ce_authorityKeyIdentifier)?.map(
(e) => e.keyId
)

Expand All @@ -179,7 +145,7 @@ export class X509Certificate {
}

public get subjectKeyIdentifier() {
const keyIds = this.getMatchingExtensions<SubjectKeyIdentifierExtension>(id_ce_subjectKeyIdentifier)?.map(
const keyIds = this.getMatchingExtensions<x509.SubjectKeyIdentifierExtension>(id_ce_subjectKeyIdentifier)?.map(
(e) => e.keyId
)

Expand All @@ -190,8 +156,8 @@ export class X509Certificate {
return keyIds?.[0]
}

public get keyUsage(): Array<X509KeyUsage> {
const keyUsages = this.getMatchingExtensions<KeyUsageExtension>(id_ce_keyUsage)?.map((e) => e.usage)
public get keyUsage() {
const keyUsages = this.getMatchingExtensions<x509.KeyUsagesExtension>(id_ce_keyUsage)?.map((e) => e.usages)

if (keyUsages && keyUsages.length > 1) {
throw new X509Error('Multiple Key Usages are not allowed')
Expand All @@ -203,20 +169,18 @@ export class X509Certificate {
.filter((flagValue) => (keyUsages[0] & flagValue) === flagValue)
.map((flagValue) => flagValue as X509KeyUsage)
}

return []
}

public get extendedKeyUsage(): Array<X509ExtendedKeyUsage> | undefined {
const extendedKeyUsages = this.getMatchingExtensions<ExtendedKeyUsageExtension>(id_ce_extKeyUsage)?.map(
(e) => e.usage
public get extendedKeyUsage() {
const extendedKeyUsages = this.getMatchingExtensions<x509.ExtendedKeyUsageExtension>(id_ce_extKeyUsage)?.map(
(e) => e.usages
)

if (extendedKeyUsages && extendedKeyUsages.length > 1) {
throw new X509Error('Multiple Key Usages are not allowed')
}

return extendedKeyUsages?.[0]
return extendedKeyUsages?.[0] as X509ExtendedKeyUsage | undefined
}

public isExtensionCritical(id: string): boolean {
Expand Down Expand Up @@ -261,6 +225,7 @@ export class X509Certificate {
extensions.push(createIssuerAlternativeNameExtension(options.extensions?.issuerAlternativeName))
extensions.push(createSubjectAlternativeNameExtension(options.extensions?.subjectAlternativeName))
extensions.push(createBasicConstraintsExtension(options.extensions?.basicConstraints))
extensions.push(createCrlDistributionPointsExtension(options.extensions?.crlDistributionPoints))

if (isSelfSignedCertificate) {
if (options.subject) {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/modules/x509/X509ServiceOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export type X509CertificateExtensionsOptions = AddMarkAsCritical<{
ca: boolean
pathLenConstraint?: number
}
crlDistributionPoints?: {
urls: Array<string>
}
}>

export interface X509CertificateIssuerAndSubjectOptions {
Expand Down
96 changes: 94 additions & 2 deletions packages/core/src/modules/x509/__tests__/X509Service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */

import type { AgentContext } from '../../../agent'

import { id_ce_extKeyUsage, id_ce_keyUsage } from '@peculiar/asn1-x509'
import { id_ce_basicConstraints, id_ce_extKeyUsage, id_ce_keyUsage } from '@peculiar/asn1-x509'
import * as x509 from '@peculiar/x509'

import { InMemoryWallet } from '../../../../../../tests/InMemoryWallet'
Expand Down Expand Up @@ -122,7 +124,6 @@ describe('X509Service', () => {
expect(certificate.publicKey.keyType).toStrictEqual(KeyType.P256)
expect(certificate.publicKey.publicKey.length).toStrictEqual(65)
expect(certificate.subject).toStrictEqual('CN=credo')
expect(certificate.extensions).toBeUndefined()
})

it('should create a valid self-signed certificate with a critical extension', async () => {
Expand Down Expand Up @@ -179,6 +180,97 @@ describe('X509Service', () => {
})
})

it('should create a valid self-signed certifcate as IACA Root + DCS for mDoc', async () => {
const authorityKey = await wallet.createKey({ keyType: KeyType.P256 })
const documentSignerKey = await wallet.createKey({ keyType: KeyType.P256 })

const mdocRootCertificate = await X509Service.createCertificate(agentContext, {
authorityKey,
issuer: { commonName: 'credo', countryName: 'NL' },
validity: {
notBefore: getLastMonth(),
notAfter: getNextMonth(),
},
extensions: {
subjectKeyIdentifier: {
include: true,
},
keyUsage: {
usages: [X509KeyUsage.KeyCertSign, X509KeyUsage.CrlSign],
markAsCritical: true,
},
issuerAlternativeName: {
name: [{ type: 'url', value: 'animo.id' }],
},
basicConstraints: {
ca: true,
pathLenConstraint: 0,
markAsCritical: true,
},
crlDistributionPoints: {
urls: ['https://animo.id'],
},
},
})

expect(mdocRootCertificate.isExtensionCritical(id_ce_basicConstraints)).toStrictEqual(true)
expect(mdocRootCertificate.isExtensionCritical(id_ce_keyUsage)).toStrictEqual(true)

expect(mdocRootCertificate).toMatchObject({
ianUriNames: expect.arrayContaining(['animo.id']),
keyUsage: expect.arrayContaining([X509KeyUsage.KeyCertSign, X509KeyUsage.CrlSign]),
subjectKeyIdentifier: TypedArrayEncoder.toHex(authorityKey.publicKey),
})

const mdocDocumentSignerCertificate = await X509Service.createCertificate(agentContext, {
authorityKey,
subjectPublicKey: new Key(documentSignerKey.publicKey, KeyType.P256),
issuer: mdocRootCertificate.issuer,
subject: { commonName: 'credo dcs', countryName: 'NL' },
validity: {
notBefore: getLastMonth(),
notAfter: getNextMonth(),
},
extensions: {
authorityKeyIdentifier: {
include: true,
},
subjectKeyIdentifier: {
include: true,
},
keyUsage: {
usages: [X509KeyUsage.DigitalSignature],
markAsCritical: true,
},
subjectAlternativeName: {
name: [{ type: 'url', value: 'paradym.id' }],
},
issuerAlternativeName: {
name: mdocRootCertificate.issuerAlternativeNames!,
},
extendedKeyUsage: {
usages: [X509ExtendedKeyUsage.MdlDs],
markAsCritical: true,
},
crlDistributionPoints: {
urls: ['https://animo.id'],
},
},
})

expect(mdocDocumentSignerCertificate.isExtensionCritical(id_ce_keyUsage)).toStrictEqual(true)
expect(mdocDocumentSignerCertificate.isExtensionCritical(id_ce_extKeyUsage)).toStrictEqual(true)

expect(mdocDocumentSignerCertificate).toMatchObject({
ianUriNames: expect.arrayContaining(['animo.id']),
sanUriNames: expect.arrayContaining(['paradym.id']),
keyUsage: expect.arrayContaining([X509KeyUsage.DigitalSignature]),
extendedKeyUsage: expect.arrayContaining([X509ExtendedKeyUsage.MdlDs]),
subjectKeyIdentifier: TypedArrayEncoder.toHex(documentSignerKey.publicKey),
authorityKeyIdentifier: TypedArrayEncoder.toHex(authorityKey.publicKey),
})
})

it('should create a valid leaf certificate', async () => {
const authorityKey = await wallet.createKey({ keyType: KeyType.P256 })
const subjectKey = await wallet.createKey({ keyType: KeyType.P256 })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import type { TextObject } from '@peculiar/x509'
import type { Extension as AsnExtension } from '@peculiar/asn1-x509'
import type { JsonGeneralNames, TextObject } from '@peculiar/x509'

import { id_ce_issuerAltName, id_ce_subjectAltName } from '@peculiar/asn1-x509'
import { AsnConvert } from '@peculiar/asn1-schema'
import { id_ce_issuerAltName, IssueAlternativeName } from '@peculiar/asn1-x509'
import { Extension, GeneralNames, ExtensionFactory } from '@peculiar/x509'

export class IssuerAlternativeNameExtension extends Extension {
public names!: GeneralNames

public static override NAME = 'Issuer Alternative Name'

public constructor(...args: unknown[]) {
super(id_ce_subjectAltName, args[1] as boolean, new GeneralNames(args[0] || []).rawData)
public constructor(data: JsonGeneralNames | ArrayBufferLike, critical?: boolean) {
if (data instanceof ArrayBuffer) {
super(data)
} else {
super(id_ce_issuerAltName, !!critical, new GeneralNames(data).rawData)
}
}

public onInit(asn: AsnExtension) {
super.onInit(asn)
const value = AsnConvert.parse(asn.extnValue, IssueAlternativeName)
this.names = new GeneralNames(value)
}

public override toTextObject(): TextObject {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/modules/x509/utils/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SubjectKeyIdentifierExtension,
SubjectAlternativeNameExtension,
BasicConstraintsExtension,
CRLDistributionPointsExtension,
} from '@peculiar/x509'

import { TypedArrayEncoder } from '../../../utils'
Expand Down Expand Up @@ -69,3 +70,11 @@ export const createBasicConstraintsExtension = (options: X509CertificateExtensio

return new BasicConstraintsExtension(options.ca, options.pathLenConstraint, options.markAsCritical)
}

export const createCrlDistributionPointsExtension = (
options: X509CertificateExtensionsOptions['crlDistributionPoints']
) => {
if (!options) return

return new CRLDistributionPointsExtension(options.urls, options.markAsCritical)
}

0 comments on commit 49087f9

Please sign in to comment.