Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: openid4vci mdoc-issuanc #2069

Merged
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions demo-openid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@
"inquirer": "^8.2.5"
},
"devDependencies": {
"@credo-ts/openid4vc": "workspace:*",
"@credo-ts/askar": "workspace:*",
"@credo-ts/core": "workspace:*",
"@credo-ts/node": "workspace:*",
"@credo-ts/openid4vc": "workspace:*",
"@types/express": "^4.17.13",
"@types/figlet": "^1.5.4",
"@types/inquirer": "^8.2.6",
"clear": "^0.1.0",
"figlet": "^1.5.2",
"ts-node": "^10.4.0"
"ts-node": "^10.9.2"
}
}
6 changes: 6 additions & 0 deletions demo-openid/src/Holder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export class Holder extends BaseAgent<ReturnType<typeof getOpenIdHolderModules>>
const holder = new Holder(3000, 'OpenId4VcHolder ' + Math.random().toString())
await holder.initializeAgent('96213c3d7fc8d4d6754c7a0fd969598e')

// Set trusted issuer certificates. Required fro verifying mdoc credentials
const trustedCertificates: string[] = []
await holder.agent.x509.setTrustedCertificates(
trustedCertificates.length === 0 ? undefined : (trustedCertificates as [string, ...string[]])
)

return holder
}

Expand Down
51 changes: 50 additions & 1 deletion demo-openid/src/Issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
OpenId4VcCredentialHolderDidBinding,
OpenId4VciCredentialRequestToCredentialMapper,
OpenId4VciCredentialSupportedWithId,
OpenId4VciSignMdocCredential,
OpenId4VcIssuerRecord,
} from '@credo-ts/openid4vc'

Expand All @@ -16,6 +17,9 @@ import {
W3cCredentialSubject,
W3cIssuer,
w3cDate,
X509Service,
KeyType,
X509ModuleConfig,
} from '@credo-ts/core'
import { OpenId4VcIssuerModule, OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc'
import { ariesAskar } from '@hyperledger/aries-askar-nodejs'
Expand All @@ -42,18 +46,29 @@ export const universityDegreeCredentialSdJwt = {
vct: 'UniversityDegreeCredential',
} satisfies OpenId4VciCredentialSupportedWithId

export const universityDegreeCredentialMdoc = {
id: 'UniversityDegreeCredential-mdoc',
format: OpenId4VciCredentialFormatProfile.MsoMdoc,
doctype: 'UniversityDegreeCredential',
} satisfies OpenId4VciCredentialSupportedWithId

export const credentialsSupported = [
universityDegreeCredential,
openBadgeCredential,
universityDegreeCredentialSdJwt,
universityDegreeCredentialMdoc,
] satisfies OpenId4VciCredentialSupportedWithId[]

function getCredentialRequestToCredentialMapper({
issuerDidKey,
}: {
issuerDidKey: DidKey
}): OpenId4VciCredentialRequestToCredentialMapper {
return async ({ holderBinding, credentialConfigurationIds }) => {
return async ({ holderBinding, credentialConfigurationIds, agentContext }) => {
const trustedCertificates = agentContext.dependencyManager.resolve(X509ModuleConfig).trustedCertificates
if (trustedCertificates?.length !== 1) {
throw new Error(`Expected exactly one trusted certificate. Received ${trustedCertificates?.length}.`)
}
const credentialConfigurationId = credentialConfigurationIds[0]

if (credentialConfigurationId === universityDegreeCredential.id) {
Expand Down Expand Up @@ -110,6 +125,21 @@ function getCredentialRequestToCredentialMapper({
}
}

if (credentialConfigurationId === universityDegreeCredentialMdoc.id) {
return {
credentialSupportedId: universityDegreeCredentialMdoc.id,
format: ClaimFormat.MsoMdoc,
docType: universityDegreeCredentialMdoc.doctype,
issuerCertificate: trustedCertificates[0],
holderKey: holderBinding.key,
namespaces: {
'Leopold-Franzens-University': {
degree: 'bachelor',
},
},
} satisfies OpenId4VciSignMdocCredential
}

throw new Error('Invalid request')
}
}
Expand Down Expand Up @@ -147,6 +177,25 @@ export class Issuer extends BaseAgent<{
public static async build(): Promise<Issuer> {
const issuer = new Issuer(2000, 'OpenId4VcIssuer ' + Math.random().toString())
await issuer.initializeAgent('96213c3d7fc8d4d6754c7a0fd969598f')

const currentDate = new Date()
currentDate.setDate(currentDate.getDate() - 1)
const nextDay = new Date(currentDate)
nextDay.setDate(currentDate.getDate() + 2)

const selfSignedCertificate = await X509Service.createSelfSignedCertificate(issuer.agent.context, {
key: await issuer.agent.context.wallet.createKey({ keyType: KeyType.P256 }),
notBefore: currentDate,
notAfter: nextDay,
extensions: [],
name: 'C=DE',
})

const issuerCertficicate = selfSignedCertificate.toString('pem')
await issuer.agent.x509.setTrustedCertificates([issuerCertficicate])
console.log('Set the following certficate for the holder to verify mdoc credentials.')
console.log(issuerCertficicate)

issuer.issuerRecord = await issuer.agent.modules.openId4VcIssuer.createIssuer({
credentialsSupported,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@peculiar/x509": "^1.11.0",
"@protokoll/mdoc-client": "0.2.27",
"@protokoll/mdoc-client": "0.2.33",
"@sd-jwt/core": "^0.7.0",
"@sd-jwt/decode": "^0.7.0",
"@sd-jwt/jwt-status-list": "^0.7.0",
Expand Down
10 changes: 5 additions & 5 deletions packages/openid4vc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
},
"dependencies": {
"@credo-ts/core": "workspace:*",
"@sphereon/did-auth-siop": "0.16.1-next.66",
"@sphereon/oid4vc-common": "0.16.1-next.66",
"@sphereon/oid4vci-client": "0.16.1-next.66",
"@sphereon/oid4vci-common": "0.16.1-next.66",
"@sphereon/oid4vci-issuer": "0.16.1-next.66",
"@sphereon/did-auth-siop": "0.16.1-next.168",
"@sphereon/oid4vc-common": "0.16.1-next.168",
"@sphereon/oid4vci-client": "0.16.1-next.168",
"@sphereon/oid4vci-common": "0.16.1-next.168",
"@sphereon/oid4vci-issuer": "0.16.1-next.168",
"@sphereon/ssi-types": "0.29.1-unstable.121",
"class-transformer": "^0.5.1",
"rxjs": "^7.8.0"
Expand Down
30 changes: 30 additions & 0 deletions packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
AuthorizationDetails,
AuthorizationDetailsJwtVcJson,
AuthorizationDetailsJwtVcJsonLdAndLdpVc,
AuthorizationDetailsMsoMdoc,
AuthorizationDetailsSdJwtVc,
CredentialResponse,
Jwt,
Expand All @@ -37,6 +38,8 @@ import {
Jwk,
JwsService,
Logger,
Mdoc,
MdocApi,
SdJwtVcApi,
SignatureSuiteRegistry,
TypedArrayEncoder,
Expand Down Expand Up @@ -178,6 +181,14 @@ export class OpenId4VciHolderService {
vct: offeredCredential.vct,
claims: offeredCredential.claims,
} satisfies AuthorizationDetailsSdJwtVc
} else if (format === OpenId4VciCredentialFormatProfile.MsoMdoc) {
return {
type,
format,
locations,
claims: offeredCredential.claims,
doctype: offeredCredential.doctype,
} satisfies AuthorizationDetailsMsoMdoc
} else {
throw new CredoError(`Cannot create authorization_details. Unsupported credential format '${format}'.`)
}
Expand Down Expand Up @@ -662,6 +673,7 @@ export class OpenId4VciHolderService {
case OpenId4VciCredentialFormatProfile.JwtVcJson:
case OpenId4VciCredentialFormatProfile.JwtVcJsonLd:
case OpenId4VciCredentialFormatProfile.SdJwtVc:
case OpenId4VciCredentialFormatProfile.MsoMdoc:
signatureAlgorithm = options.possibleProofOfPossessionSignatureAlgorithms.find((signatureAlgorithm) =>
proofSigningAlgsSupported.includes(signatureAlgorithm)
)
Expand Down Expand Up @@ -782,6 +794,24 @@ export class OpenId4VciHolderService {
}

return { credential, notificationMetadata }
} else if (format === OpenId4VciCredentialFormatProfile.MsoMdoc) {
if (typeof credentialResponse.successBody.credential !== 'string')
throw new CredoError(
`Received a credential of format ${
OpenId4VciCredentialFormatProfile.MsoMdoc
}, but the credential is not a string. ${JSON.stringify(credentialResponse.successBody.credential)}`
)

const mdocApi = agentContext.dependencyManager.resolve(MdocApi)
const mdoc = Mdoc.fromBase64Url(credentialResponse.successBody.credential)
const verificationResult = await mdocApi.verify(mdoc, {})

if (!verificationResult.isValid) {
agentContext.config.logger.error('Failed to validate credential', { verificationResult })
throw new CredoError(`Failed to validate mdoc credential. Results = ${verificationResult.error}`)
}

return { credential: mdoc, notificationMetadata }
}

throw new CredoError(`Unsupported credential format ${credentialResponse.successBody.format}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ export type OpenId4VciSupportedCredentialFormats =
| OpenId4VciCredentialFormatProfile.JwtVcJsonLd
| OpenId4VciCredentialFormatProfile.SdJwtVc
| OpenId4VciCredentialFormatProfile.LdpVc
| OpenId4VciCredentialFormatProfile.MsoMdoc

export const openId4VciSupportedCredentialFormats: OpenId4VciSupportedCredentialFormats[] = [
OpenId4VciCredentialFormatProfile.JwtVcJson,
OpenId4VciCredentialFormatProfile.JwtVcJsonLd,
OpenId4VciCredentialFormatProfile.SdJwtVc,
OpenId4VciCredentialFormatProfile.LdpVc,
OpenId4VciCredentialFormatProfile.MsoMdoc,
]

export interface OpenId4VciNotificationMetadata {
Expand Down
67 changes: 61 additions & 6 deletions packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
OpenId4VcIssuerMetadata,
OpenId4VciSignSdJwtCredential,
OpenId4VciSignW3cCredential,
OpenId4VciSignMdocCredential,
} from './OpenId4VcIssuerServiceOptions'
import type { OpenId4VcIssuanceSessionRecord } from './repository'
import type {
Expand All @@ -14,7 +15,7 @@ import type {
OpenId4VciCredentialOfferPayload,
OpenId4VciCredentialRequest,
} from '../shared'
import type { AgentContext, DidDocument, Query, QueryOptions } from '@credo-ts/core'
import type { AgentContext, DidDocument, Key, Query, QueryOptions } from '@credo-ts/core'
import type {
CredentialOfferPayloadV1_0_11,
CredentialOfferPayloadV1_0_13,
Expand Down Expand Up @@ -47,6 +48,9 @@ import {
KeyType,
utils,
W3cCredentialService,
MdocApi,
parseDid,
DidResolverService,
} from '@credo-ts/core'
import { VcIssuerBuilder } from '@sphereon/oid4vci-issuer'

Expand Down Expand Up @@ -499,6 +503,11 @@ export class OpenId4VcIssuerService {
offeredCredential.format === credentialRequest.format
) {
return offeredCredential.vct === credentialRequest.vct
} else if (
credentialRequest.format === OpenId4VciCredentialFormatProfile.MsoMdoc &&
offeredCredential.format === credentialRequest.format
) {
return offeredCredential.doctype === credentialRequest.doctype
}

return false
Expand All @@ -518,6 +527,18 @@ export class OpenId4VcIssuerService {
}
}

private getMsoMdocCredentialSigningCallback = (
agentContext: AgentContext,
options: OpenId4VciSignMdocCredential
): CredentialSignerCallback<DidDocument> => {
return async () => {
const mdocApi = agentContext.dependencyManager.resolve(MdocApi)

const mdoc = await mdocApi.sign(options)
return getSphereonVerifiableCredential(mdoc)
}
}

private getW3cCredentialSigningCallback = (
agentContext: AgentContext,
options: OpenId4VciSignW3cCredential
Expand Down Expand Up @@ -586,7 +607,10 @@ export class OpenId4VcIssuerService {
}
}

private async getHolderBindingFromRequest(credentialRequest: OpenId4VciCredentialRequest) {
private async getHolderBindingFromRequest(
agentContext: AgentContext,
credentialRequest: OpenId4VciCredentialRequest
) {
if (!credentialRequest.proof?.jwt) throw new CredoError('Received a credential request without a proof')

const jwt = Jwt.fromSerializedJwt(credentialRequest.proof.jwt)
Expand All @@ -600,15 +624,27 @@ export class OpenId4VcIssuerService {
)
}

const parsedDid = parseDid(jwt.header.kid)
if (!parsedDid.fragment) {
throw new Error(`didUrl '${parsedDid.didUrl}' does not contain a '#'. Unable to derive key from did document.`)
}

const didResolver = agentContext.dependencyManager.resolve(DidResolverService)
const didDocument = await didResolver.resolveDidDocument(agentContext, parsedDid.didUrl)
const key = getKeyFromVerificationMethod(didDocument.dereferenceKey(parsedDid.didUrl, ['assertionMethod']))

return {
method: 'did',
didUrl: jwt.header.kid,
} satisfies OpenId4VcCredentialHolderBinding
key,
} satisfies OpenId4VcCredentialHolderBinding & { key: Key }
} else if (jwt.header.jwk) {
const jwk = getJwkFromJson(jwt.header.jwk)
return {
method: 'jwk',
jwk: getJwkFromJson(jwt.header.jwk),
} satisfies OpenId4VcCredentialHolderBinding
jwk: jwk,
key: jwk.key,
} satisfies OpenId4VcCredentialHolderBinding & { key: Key }
} else {
throw new CredoError('Either kid or jwk must be present in credential request proof header')
}
Expand Down Expand Up @@ -655,7 +691,7 @@ export class OpenId4VcIssuerService {
([credentialConfigurationId]) => credentialConfigurationId
) as [string, ...string[]]

const holderBinding = await this.getHolderBindingFromRequest(credentialRequest)
const holderBinding = await this.getHolderBindingFromRequest(agentContext, credentialRequest)
const signOptions = await mapper({
agentContext,
issuanceSession,
Expand Down Expand Up @@ -712,6 +748,25 @@ export class OpenId4VcIssuerService {
credential: { ...signOptions.payload } as unknown as CredentialIssuanceInput,
signCallback: this.getSdJwtVcCredentialSigningCallback(agentContext, signOptions),
}
} else if (signOptions.format === ClaimFormat.MsoMdoc) {
if (credentialRequest.format !== OpenId4VciCredentialFormatProfile.MsoMdoc) {
throw new CredoError(
`Invalid credential format. Expected '${OpenId4VciCredentialFormatProfile.MsoMdoc}', received '${credentialRequest.format}'.`
)
}

if (credentialRequest.doctype !== signOptions.docType) {
throw new CredoError(
`The types of the offered credentials do not match the types of the requested credential. Offered '${signOptions.docType}' Requested '${credentialRequest.doctype}'.`
)
}

return {
format: credentialRequest.format,
// NOTE: we don't use the credential value here as we pass the credential directly to the singer
credential: { ...signOptions.namespaces, docType: signOptions.docType } as unknown as CredentialIssuanceInput,
signCallback: this.getMsoMdocCredentialSigningCallback(agentContext, signOptions),
}
} else {
throw new CredoError(`Unsupported credential format ${signOptions.format}`)
}
Expand Down
Loading
Loading