Skip to content

Commit

Permalink
feat: openid4vci mdoc-issuanc (openwallet-foundation#2069)
Browse files Browse the repository at this point in the history
Signed-off-by: Martin Auer <[email protected]>
  • Loading branch information
auer-martin authored Oct 28, 2024
1 parent 23a9cb6 commit 5695055
Show file tree
Hide file tree
Showing 16 changed files with 927 additions and 127 deletions.
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

0 comments on commit 5695055

Please sign in to comment.