# Credo > Credo is an open source TypeScript framework for verifiable credentials. It is used to build issuers, verifiers, wallets and mediators that exchange SD-JWT VC, mdoc, W3C and AnonCreds credentials over OpenID4VC and DIDComm, on Node.js and React Native. Credo 0.8 (next, unreleased). # Credo documentation > Build issuers, verifiers, wallets and mediators for verifiable credentials with Credo, an open source TypeScript framework. Credo is an open source TypeScript framework for verifiable credentials. You use it to build issuers, verifiers, wallets and mediators that exchange SD-JWT VC, mdoc, W3C and AnonCreds credentials over OpenID4VC and DIDComm, on Node.js and React Native. ## Start here [Choose your setup](https://credo.js.org/guides/next/getting-started.md#choose-your-setup) recommends a platform, protocol, credential format and storage to start with. - [Installation](https://credo.js.org/guides/next/getting-started/installation.md): Set up a Node.js or React Native project and install Credo. - [Your first agent](https://credo.js.org/guides/next/getting-started/first-agent.md): Configure storage and keys, initialize an agent and create a DID. - [Verifiable credentials](https://credo.js.org/guides/next/concepts/verifiable-credentials.md): Issuers, holders and verifiers, and how protocols and formats fit together. - [Example setups](https://credo.js.org/guides/next/getting-started/example-setups.md): Complete agent configurations for a mobile wallet, an issuer and verifier service, a multi-tenant service, a mediator and tests. ## Build by role - [Issue credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md): Run an OpenID4VCI issuer and offer credentials to any compliant wallet. - [Build a wallet](https://credo.js.org/guides/next/protocols/openid4vc/receive.md): Receive, store and present credentials from a mobile or cloud wallet. - [Verify credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md): Request claims with DCQL and verify presentations with OpenID4VP. - [Run a mediator](https://credo.js.org/guides/next/protocols/didcomm/mediation.md): Relay DIDComm messages for mobile wallets that can't receive them directly. ## Explore the docs - [Agent setup](https://credo.js.org/guides/next/agent-setup.md): Storage, key management, caching, multi-tenancy and logging. - [Protocols](https://credo.js.org/guides/next/protocols.md): OpenID4VC and DIDComm, role by role, and how to use each credential format with them. - [Credential formats](https://credo.js.org/guides/next/credential-formats.md): SD-JWT VC, mdoc, W3C Verifiable Credentials, AnonCreds and status lists. - [DIDs and trust](https://credo.js.org/guides/next/dids-and-trust.md): DID methods, X.509 certificates and deciding which issuers to trust. ## Use with AI coding agents Point your coding agent at [llms.txt](https://credo.js.org/guides/next/llms.txt), an index of these docs as Markdown. Every page is also available as Markdown by adding `.md` to its URL. See [AI coding agents](https://credo.js.org/guides/next/getting-started/ai-agents.md). ## Get help Ask questions in the [#credo channel on Discord](https://discord.com/channels/1022962884864643214/1179453305856991263), open an issue on [GitHub](https://github.com/openwallet-foundation/credo-ts/issues), or join the [working group calls](https://github.com/openwallet-foundation/credo-ts/wiki/Meeting-Information). # Getting started > What you need to build with Credo, and the order to read the getting started pages in. Credo is a TypeScript framework for issuing, holding and verifying verifiable credentials. It supports OpenID4VC and DIDComm, and formats such as SD-JWT VC, mdoc, W3C Verifiable Credentials and AnonCreds. This section takes you from an empty project to a running agent. ## Prerequisites - Node.js 22 or later (Credo is tested on Node.js 22, 24 and 26), or React Native. These pages use Expo for React Native. - A TypeScript project that uses ES modules. Credo is published as ES modules only. Browsers aren't supported, and Bun and Deno aren't tested. See [Platforms](https://credo.js.org/guides/next/concepts/platform-and-environment.md). ## Choose your setup Four choices come up before you write any code. If you're not sure, start with the default. You can add a protocol or format later, and an agent can use several at once. | Choice | Default | Pick the other option when | Details | | --- | --- | --- | --- | | Platform | Node.js, for issuers, verifiers, mediators and cloud wallets | You build a mobile wallet: use React Native with Expo | [Platforms](https://credo.js.org/guides/next/concepts/platform-and-environment.md) | | Protocol | OpenID4VC | Use DIDComm if you need a lasting connection with messaging between agents, or AnonCreds, or your ecosystem is built on Aries. A wallet can support both | [Protocols](https://credo.js.org/guides/next/protocols.md) | | Credential format | SD-JWT VC | Your ecosystem asks for another one: mdoc for mobile driving licences, W3C VCs for Open Badges or EBSI, AnonCreds (DIDComm only) for predicates and unlinkable presentations. The EUDI Wallet uses SD-JWT VC and mdoc | [Credential formats](https://credo.js.org/guides/next/credential-formats.md) | | Storage | On a server, Drizzle on PostgreSQL for the records, as SQL tables that scale to many tenants and instances, and Askar for the keys. In a mobile wallet, Askar for both | Use SQLite instead of PostgreSQL for a single server instance or local development. A mobile wallet can also use Drizzle on `expo-sqlite` | [Storage](https://credo.js.org/guides/next/agent-setup/storage.md) | The getting started pages use Drizzle on SQLite with Askar for the keys on Node.js, and Askar on React Native. They work for any protocol and format. ## Read in this order - [1. Installation](https://credo.js.org/guides/next/getting-started/installation.md): Set up a Node.js or Expo project and install Credo and its storage. - [2. Your first agent](https://credo.js.org/guides/next/getting-started/first-agent.md): Create an agent, initialize it, create a DID and shut it down. - [3. Example setups](https://credo.js.org/guides/next/getting-started/example-setups.md): Complete configurations for wallets, issuers, verifiers, mediators and tests. ## Where to go next Once your agent runs, pick the guide for what you're building: - [Issue credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md): Run an OpenID4VCI issuer that wallets receive credentials from. - [Verify credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md): Request and verify presentations with OpenID4VP. - [Build a wallet](https://credo.js.org/guides/next/protocols/openid4vc/receive.md): Receive, store and present credentials in a mobile or server wallet. - [Use DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md): Connect to other agents and exchange messages and credentials over DIDComm. If you're new to verifiable credentials, start with [Concepts](https://credo.js.org/guides/next/concepts.md). If you use an AI coding assistant, see [AI coding agents](https://credo.js.org/guides/next/getting-started/ai-agents.md). # Installation > Set up a Node.js or React Native (Expo) project and install Credo with its storage and key management packages. This page sets up a project and installs Credo, its platform package, and the packages for storage and keys. Every agent needs a storage module. On Node.js the next pages store records with [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md) in SQLite and keys with [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md). On React Native, Askar stores both. Use Node.js for a server and React Native for a mobile wallet. [Choose your setup](https://credo.js.org/guides/next/getting-started.md#choose-your-setup) covers the other choices to make first. ## Set up the project **Node.js** Credo needs Node.js 22 or later. It's published as ES modules only, so create a project with `"type": "module"`: ```bash mkdir my-agent cd my-agent npm init -y npm pkg set type=module ``` Add TypeScript and [tsx](https://tsx.is) to run TypeScript files: ```bash npm install --save-dev typescript tsx ``` ```json { "compilerOptions": { "module": "nodenext", "strict": true, "skipLibCheck": true } } ``` - `"module": "nodenext"` resolves the `exports` of the Credo packages, including subpaths such as `@credo-ts/core/kms`. `"moduleResolution": "bundler"` works too. The older `"node"` resolution doesn't find subpaths. - `skipLibCheck` is needed because some of Credo's dependencies ship type definitions that don't type-check. **React Native** Create an Expo app, or use an existing one: ```bash npx create-expo-app@latest my-wallet ``` Credo uses native modules, so it doesn't run in Expo Go. Build the app with [Continuous Native Generation](https://docs.expo.dev/workflow/continuous-native-generation/), for example with `npx expo run:android` and `npx expo run:ios` or with EAS Build. Expo's TypeScript and Metro defaults work with Credo's ES modules, so you don't need to change them. ## Install Credo Keep all `@credo-ts/*` packages on the same version. They depend on each other with exact versions. **Node.js** ```bash npm install @credo-ts/core@alpha @credo-ts/node@alpha @credo-ts/drizzle-storage@alpha drizzle-orm@^0.45.4 @libsql/client @credo-ts/askar@alpha @openwallet-foundation/askar-nodejs@0.6.1 ``` `@libsql/client` is the SQLite driver for Drizzle. Keep `drizzle-orm` on the version range shown, which `@credo-ts/drizzle-storage` requires. **React Native** Install the peer dependencies of `@credo-ts/react-native` with `npx expo install`, so you get the versions that match your Expo SDK: ```bash npx expo install expo-crypto react-native-get-random-values @dr.pogodin/react-native-fs ``` | Package | Why Credo needs it | | --- | --- | | `react-native-get-random-values` | Secure random numbers. `@credo-ts/react-native` imports it for you, so you don't need a polyfill in your entry file. | | `expo-crypto` | Hashing in the JSON-LD libraries Credo uses. | | `@dr.pogodin/react-native-fs` | File system access. `react-native-fs` works too. Importing `@credo-ts/react-native` throws if neither is installed. | `@credo-ts/react-native` declares `expo-crypto@^15` as its peer range, which only matches Expo SDK 54. With a newer SDK, npm refuses to install Credo with an `ERESOLVE` error. Add an override to your `package.json` first: ```json { "overrides": { "@credo-ts/react-native": { "expo-crypto": "$expo-crypto" } } } ``` pnpm and Yarn print a peer dependency warning instead. Then install Credo: ```bash npm install @credo-ts/core@alpha @credo-ts/react-native@alpha @credo-ts/askar@alpha @openwallet-foundation/askar-react-native@0.6.1 ``` `@openwallet-foundation/askar-react-native` is linked automatically and doesn't need a config plugin. Rebuild the native app after you install it. The Askar platform package depends on `@openwallet-foundation/askar-shared` with its exact version, and `@credo-ts/askar` needs the same copy. Install a single Askar version: with two versions of `askar-shared` in your project, `agent.initialize()` fails with `Native askar has not been registered yet`. :::note[pnpm and Bun] The Askar packages download their native library in an install script. pnpm and Bun don't run install scripts of dependencies unless you allow them. With pnpm, run `pnpm approve-builds`. With Bun, add the package to `trustedDependencies` in your `package.json`. ::: ## Next steps - [Your first agent](https://credo.js.org/guides/next/getting-started/first-agent.md) configures the agent and runs it. - [Storage](https://credo.js.org/guides/next/agent-setup/storage.md) compares Drizzle with Askar. # Your first agent > Create, initialize and shut down a minimal Credo agent that stores records with Drizzle in SQLite and keys in Askar, and creates a did:key. This page builds the smallest complete agent: it stores records in an SQLite file with Drizzle and keys in Askar, creates a `did:key`, resolves it and shuts down. On React Native, Askar stores both. You need the packages from [Installation](https://credo.js.org/guides/next/getting-started/installation.md). ## Create the agent **Node.js** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent, ConsoleLogger, type KeyDidCreateOptions, LogLevel } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { agentDependencies } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/libsql' const agent = new Agent({ config: { logger: new ConsoleLogger(LogLevel.Info), }, dependencies: agentDependencies, modules: { // Records go in the tables of an SQLite file storage: new DrizzleStorageModule({ database: drizzle('file:agent.db'), bundles: [], }), // Drizzle doesn't store keys, so Askar keeps them askar: new AskarModule({ askar: NativeAskar, enableStorage: false, store: { id: 'my-first-agent', // Use a secret from your environment or a secure store in a real application key: 'my-first-agent-key', }, }), }, }) ``` **React Native** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent, ConsoleLogger, type KeyDidCreateOptions, LogLevel } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/react-native' import { NativeAskar } from '@openwallet-foundation/askar-react-native' const agent = new Agent({ config: { logger: new ConsoleLogger(LogLevel.Info), }, dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'my-first-agent', // Use a key from the device's secure storage in a real app key: 'my-first-agent-key', }, }), }, }) ``` The `Agent` constructor takes three options: - `dependencies` are the platform implementations of the file system, `fetch`, WebSocket and events. Import `agentDependencies` from `@credo-ts/node` or `@credo-ts/react-native`. - `modules` adds features to the agent. On Node.js, the `DrizzleStorageModule` stores the records, and the `AskarModule` with `enableStorage: false` is the [key management](https://credo.js.org/guides/next/agent-setup/key-management.md) backend. On React Native, the `AskarModule` does both. An agent needs a storage module: without one, `new Agent()` throws `Missing required dependency: 'StorageService'`. - `config` holds agent-wide settings, such as the logger. See [Agent configuration](https://credo.js.org/guides/next/agent-setup.md). `drizzle('file:agent.db')` opens `agent.db` in the current directory. For PostgreSQL and the record bundles of other modules, see [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md). `store.id` names the Askar database and `store.key` encrypts it. On Node.js the Askar database is created at `~/.afj/data/wallet//sqlite.db`. See [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md) for the options and how to choose the key. ## Modules and their APIs The key you register a module under is the name of its API: `agent.modules.askar` for the module above. `didcomm` and `openid4vc` are also available as `agent.didcomm` and `agent.openid4vc` when you register them under those keys. Default modules, such as DIDs, key management and SD-JWT VC, are always registered and have their APIs at the top level, for example `agent.dids` and `agent.kms`. TypeScript infers these APIs from the `modules` you pass, so using a module you didn't register is a type error. [Agent configuration](https://credo.js.org/guides/next/agent-setup.md) lists the default modules and shows how to type an agent you pass around. ## Initialize ```ts await agent.initialize() ``` `initialize()` opens the storage and the Askar store, creates the store on the first run, and checks that the storage is up to date for this version of Credo. Call it once, before you use the agent. ## Create and resolve a DID ```ts const { didState } = await agent.dids.create({ method: 'key', options: { createKey: { type: { kty: 'OKP', crv: 'Ed25519' } }, }, }) if (didState.state !== 'finished') { throw new Error(`Creating the DID failed: ${didState.state === 'failed' ? didState.reason : didState.state}`) } const didDocument = await agent.dids.resolveDidDocument(didState.did) console.log(JSON.stringify(didDocument.toJSON(), null, 2)) ``` `agent.dids.create()` creates an Ed25519 key in Askar, stores a DID record that links the DID to the key, and returns the `did:key`. `resolveDidDocument()` resolves the DID to its DID document, and throws if that fails. See [DIDs](https://credo.js.org/guides/next/dids-and-trust.md) for other DID methods. ## Shut down ```ts await agent.shutdown() ``` `shutdown()` stops the modules and closes the storage. Call it before your process exits or your app is closed. ## Run it **Node.js** Drizzle doesn't create tables, so apply the migrations of the core bundle first, and again after every Credo upgrade. Without them, `initialize()` fails with `no such table: StorageVersion`. ```bash npx drizzle-storage --bundle core migrate --dialect sqlite --database-url file:agent.db ``` Then run the agent: ```bash npx tsx src/index.ts ``` The agent logs its startup at the info level and prints the DID document: ```text INFO: Creating agent with config { ... } INFO: Agent storage is up to date. { "@context": [ "https://www.w3.org/ns/did/v1", ... ], "id": "did:key:z6Mkg...", "verificationMethod": [ ... ], ... } ``` Run it again and the agent adds a second DID to the same database. **React Native** Metro doesn't support top-level `await`, so the React Native version wraps the steps in `runFirstAgent()`. Call it from your app, for example in a `useEffect` of your root component, and check the Metro logs for the DID document. ## Next steps - [Example setups](https://credo.js.org/guides/next/getting-started/example-setups.md) shows complete configurations for wallets, issuers, verifiers and mediators. - [Agent configuration](https://credo.js.org/guides/next/agent-setup.md) describes all agent options. # Example setups > Complete Credo agent configurations for a mobile wallet, an issuer and verifier service, a multi-tenant service, a DIDComm mediator and tests. Each recipe on this page is a complete, type-checked agent configuration for a common deployment. Start from the one closest to what you build. The linked pages explain the options. ## Mobile wallet Start here if you build a wallet app for phones. This React Native app [receives](https://credo.js.org/guides/next/protocols/openid4vc/receive.md) and [presents](https://credo.js.org/guides/next/protocols/openid4vc/present.md) credentials with OpenID4VC, and connects to other agents over [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md) through a mediator. Askar stores the data and keys, and keys that must stay in the device's secure hardware use the secure environment backend. Install the packages from [Installation](https://credo.js.org/guides/next/getting-started/installation.md), and: ```bash npm install @credo-ts/openid4vc@alpha @credo-ts/didcomm@alpha @animo-id/expo-secure-environment@^0.1.5 ``` ```ts import { AskarKeyManagementService, AskarModule } from '@credo-ts/askar' import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { DidCommHttpOutboundTransport, DidCommMediatorPickupStrategy, DidCommModule, DidCommWsOutboundTransport, } from '@credo-ts/didcomm' import { OpenId4VcModule } from '@credo-ts/openid4vc' import { agentDependencies, SecureEnvironmentKeyManagementService } from '@credo-ts/react-native' import { NativeAskar } from '@openwallet-foundation/askar-react-native' interface WalletAgentOptions { // A raw key from Store.generateRawKey(), kept in the device keychain walletKey: string // Invitation URL of the mediator that receives DIDComm messages for this wallet mediatorInvitationUrl: string } export function createWalletAgent({ walletKey, mediatorInvitationUrl }: WalletAgentOptions) { return new Agent({ config: { logger: new ConsoleLogger(LogLevel.Warn), }, dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'wallet', key: walletKey, keyDerivationMethod: 'raw' }, // The Askar backend is registered in the kms module below, so it can be the default enableKms: false, }), kms: new KeyManagementModule({ backends: [new AskarKeyManagementService(), new SecureEnvironmentKeyManagementService()], defaultBackend: 'askar', }), openid4vc: new OpenId4VcModule(), didcomm: new DidCommModule({ // No inbound transports: messages for the wallet go through the mediator transports: { outbound: [new DidCommWsOutboundTransport(), new DidCommHttpOutboundTransport()], }, mediationRecipient: { mediatorInvitationUrl, mediatorPickupStrategy: DidCommMediatorPickupStrategy.PickUpV2LiveMode, }, mediator: false, }), }, }) } export type WalletAgent = ReturnType ``` Askar is the default [key management](https://credo.js.org/guides/next/agent-setup/key-management.md) backend. A key goes to the secure environment only when you create it with `backend: 'secureEnvironment'`, which supports P-256 keys and ES256 signatures. `walletKey` is a raw key that you generate once and keep in the device keychain, see [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md). If the wallet only uses OpenID4VC, leave out the `didcomm` module and package. [Mediation](https://credo.js.org/guides/next/protocols/didcomm/mediation.md) explains the pickup strategies. ## Issuer and verifier service Start here if your organization issues or verifies credentials from its own backend. This Node.js service issues credentials with [OpenID4VCI](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) and verifies presentations with [OpenID4VP](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md). It stores its records in PostgreSQL with [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md), its keys in Askar and its cache in Redis, so you can run several instances of it. ```bash npm install @credo-ts/core@alpha @credo-ts/node@alpha @credo-ts/drizzle-storage@alpha @credo-ts/askar@alpha @credo-ts/openid4vc@alpha @credo-ts/redis-cache@alpha @openwallet-foundation/askar-nodejs@0.6.1 drizzle-orm@^0.45.4 pg express@^5 ioredis ``` ```bash npm install --save-dev @types/express@^5 ``` ```ts import { AskarModule } from '@credo-ts/askar' import { Agent, CacheModule, ClaimFormat, ConsoleLogger, LogLevel } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapper, OpenId4VcModule, } from '@credo-ts/openid4vc' import { RedisCache } from '@credo-ts/redis-cache' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/node-postgres' import express from 'express' import { Redis } from 'ioredis' function env(name: string) { const value = process.env[name] if (!value) throw new Error(`Environment variable ${name} is not set`) return value } // The URL wallets use to reach this service, for example https://issuer.example.com const publicUrl = env('PUBLIC_URL') // Issues an SD-JWT VC with the claims you set as issuanceMetadata when creating the offer const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = ({ credentialConfiguration, holderBinding, issuanceSession, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.SdJwtDc) { throw new Error(`Unsupported credential format '${credentialConfiguration.format}'`) } return { type: 'credentials', format: ClaimFormat.SdJwtDc, credentials: holderBinding.keys.map((holder) => ({ payload: { vct: credentialConfiguration.vct, ...issuanceSession.issuanceMetadata }, holder, issuer: { method: 'did', didUrl: env('ISSUER_DID_URL') }, })), } } const app = express() const redis = new Redis(env('REDIS_URL')) const agent = new Agent({ config: { logger: new ConsoleLogger(LogLevel.Info), }, dependencies: agentDependencies, modules: { // Records go in PostgreSQL tables. Run the migrations of the core and openid4vc bundles first. storage: new DrizzleStorageModule({ database: drizzle(env('DATABASE_URL')), bundles: [openid4vcBundle], }), // Drizzle doesn't store keys, so Askar keeps them, in a PostgreSQL database of its own askar: new AskarModule({ askar: NativeAskar, enableStorage: false, store: { // Also the name of the PostgreSQL database id: 'issuer-verifier-keys', key: env('STORE_KEY'), database: { type: 'postgres', config: { host: env('POSTGRES_HOST') }, credentials: { account: env('POSTGRES_USER'), password: env('POSTGRES_PASSWORD') }, }, }, }), // Shared by all instances of the service cache: new CacheModule({ cache: new RedisCache(redis) }), openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: `${publicUrl}/oid4vci`, credentialRequestToCredentialMapper }, verifier: { baseUrl: `${publicUrl}/oid4vp` }, }), }, }) // Mounts the OpenID4VC routes on the Express app await agent.initialize() const server = app.listen(4000) process.on('SIGTERM', async () => { server.close() await agent.shutdown() // The agent doesn't close the Redis connection await redis.quit() }) ``` `credentialRequestToCredentialMapper` decides which credential the issuer signs for a request. This one signs SD-JWT VCs with a DID that the agent created before, set in `ISSUER_DID_URL`. Before you can offer credentials, create an issuer record. [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) covers the mapper, issuer records and offers. `agent.initialize()` adds the OpenID4VC routes to the Express app, so start the server after it. Drizzle doesn't create tables. Run the migrations before every deploy: ```bash npx drizzle-storage --bundle core --bundle openid4vc migrate --dialect postgres --database-url "$DATABASE_URL" ``` ## Multi-tenant service Start here if you host issuers or verifiers for many organizations in one deployment. Each organization is a tenant with its own DIDs, keys and records. See [Multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md). Install the packages of the issuer and verifier service, and: ```bash npm install @credo-ts/tenants@alpha ``` ```ts import { AskarModule, AskarMultiWalletDatabaseScheme } from '@credo-ts/askar' import { Agent, CacheModule, ConsoleLogger, LogLevel } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { tenantsBundle } from '@credo-ts/drizzle-storage/tenants' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcModule } from '@credo-ts/openid4vc' import { RedisCache } from '@credo-ts/redis-cache' import { TenantsModule } from '@credo-ts/tenants' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/node-postgres' import express from 'express' import { Redis } from 'ioredis' function env(name: string) { const value = process.env[name] if (!value) throw new Error(`Environment variable ${name} is not set`) return value } const app = express() const redis = new Redis(env('REDIS_URL')) const modules = { // The records of all tenants in one PostgreSQL database. Run the migrations of the core, // openid4vc and tenants bundles first. storage: new DrizzleStorageModule({ database: drizzle(env('DATABASE_URL')), bundles: [openid4vcBundle, tenantsBundle], }), // Drizzle doesn't store keys, so Askar keeps them: one database, with a profile per tenant askar: new AskarModule({ askar: NativeAskar, enableStorage: false, multiWalletDatabaseScheme: AskarMultiWalletDatabaseScheme.ProfilePerWallet, store: { id: 'multi-tenant-keys', key: env('STORE_KEY'), database: { type: 'postgres', config: { host: env('POSTGRES_HOST') }, credentials: { account: env('POSTGRES_USER'), password: env('POSTGRES_PASSWORD') }, }, }, }), // Required: the default cache throws as soon as a second agent context (a tenant) uses it cache: new CacheModule({ cache: new RedisCache(redis) }), openid4vc: new OpenId4VcModule({ app, verifier: { baseUrl: `${env('PUBLIC_URL')}/oid4vp` }, }), } const agent = new Agent({ config: { logger: new ConsoleLogger(LogLevel.Info), }, dependencies: agentDependencies, modules: { ...modules, // Tenant agents share the modules of the root agent. The type argument types their API. tenants: new TenantsModule({ sessionLimit: 500 }), }, }) await agent.initialize() app.listen(4000) ``` Replace the default cache: it works with one agent context only, and throws as soon as a tenant uses it. Use `InMemoryLruCache` for a single instance or `RedisCache` for several, see [Caching](https://credo.js.org/guides/next/agent-setup/caching.md). Drizzle keeps the records of all tenants in one database. `ProfilePerWallet` does the same for the keys, with an Askar profile per tenant. Run the migrations with the tenants bundle: ```bash npx drizzle-storage --bundle core --bundle openid4vc --bundle tenants migrate --dialect postgres --database-url "$DATABASE_URL" ``` Tenant agents use the modules of the root agent, and the OpenID4VC endpoints route each request to the tenant that owns the issuer or verifier. Create a tenant and set it up through a tenant agent: ```ts export async function createVerifierTenant(label: string) { const tenant = await agent.modules.tenants.createTenant({ config: { label } }) const verifier = await agent.modules.tenants.withTenantAgent({ tenantId: tenant.id }, (tenantAgent) => tenantAgent.openid4vc.verifier.createVerifier({ clientMetadata: { client_name: label } }) ) return { tenantId: tenant.id, verifierId: verifier.verifierId } } ``` ## DIDComm mediator Start here if the mobile wallets in your DIDComm ecosystem need a mediator. This public Node.js service receives DIDComm messages for wallets without an endpoint of their own, and holds them until the wallet picks them up. It stores records in PostgreSQL with Drizzle, keeps keys in Askar and caches in Redis. See [Mediation](https://credo.js.org/guides/next/protocols/didcomm/mediation.md). ```bash npm install @credo-ts/core@alpha @credo-ts/node@alpha @credo-ts/askar@alpha @credo-ts/didcomm@alpha @credo-ts/drizzle-storage@alpha @credo-ts/redis-cache@alpha @openwallet-foundation/askar-nodejs@0.6.1 drizzle-orm@^0.45.4 pg ioredis ``` ```ts import { AskarModule } from '@credo-ts/askar' import { Agent, CacheModule, ConsoleLogger, LogLevel } from '@credo-ts/core' import { DidCommHttpOutboundTransport, DidCommModule, DidCommWsOutboundTransport } from '@credo-ts/didcomm' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { didcommBundle } from '@credo-ts/drizzle-storage/didcomm' import { agentDependencies, DidCommHttpInboundTransport, DidCommWsInboundTransport } from '@credo-ts/node' import { RedisCache } from '@credo-ts/redis-cache' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/node-postgres' import { Redis } from 'ioredis' function env(name: string) { const value = process.env[name] if (!value) throw new Error(`Environment variable ${name} is not set`) return value } const redis = new Redis(env('REDIS_URL')) const agent = new Agent({ config: { logger: new ConsoleLogger(LogLevel.Info), autoUpdateStorageOnStartup: true, }, dependencies: agentDependencies, modules: { // Run the Drizzle migrations for the core and didcomm bundles before you start the agent storage: new DrizzleStorageModule({ database: drizzle(env('DATABASE_URL')), bundles: [didcommBundle], }), // Drizzle doesn't store keys, so Askar provides the KMS askar: new AskarModule({ askar: NativeAskar, enableStorage: false, store: { id: 'mediator-keys', key: env('STORE_KEY'), database: { type: 'postgres', config: { host: env('POSTGRES_HOST') }, credentials: { account: env('POSTGRES_USER'), password: env('POSTGRES_PASSWORD') }, }, }, }), cache: new CacheModule({ cache: new RedisCache(redis) }), didcomm: new DidCommModule({ // The public URLs of the HTTP and WebSocket transports, for example behind a reverse proxy endpoints: [env('HTTP_ENDPOINT'), env('WS_ENDPOINT')], transports: { inbound: [new DidCommHttpInboundTransport({ port: 4000 }), new DidCommWsInboundTransport({ port: 4001 })], outbound: [new DidCommHttpOutboundTransport(), new DidCommWsOutboundTransport()], }, connections: { autoAcceptConnections: true }, mediator: { autoAcceptMediationRequests: true }, // Message pickup is enabled by default. A mediator doesn't need the other protocols. mediationRecipient: false, credentials: false, proofs: false, basicMessages: false, }), }, }) await agent.initialize() process.on('SIGTERM', async () => { await agent.shutdown() await redis.quit() }) ``` Run the migrations before you start it: ```bash npx drizzle-storage --bundle core --bundle didcomm migrate --dialect postgres --database-url "$DATABASE_URL" ``` Queued messages are kept in memory by default, so they're lost on restart and not shared between instances. For production, pass a persistent `queueTransportRepository` to the `DidCommModule`. [DIDComm Mediator Credo](https://github.com/openwallet-foundation/didcomm-mediator-credo) is a complete mediator that can keep the queue in PostgreSQL or DynamoDB. `autoUpdateStorageOnStartup` migrates the storage when you upgrade Credo. Back up the database first, see [Updating](https://credo.js.org/guides/next/updating.md). ## Tests Start here for unit and integration tests of your own code. Each agent gets its own in-memory SQLite database and keeps its keys in memory, so tests don't share state and nothing is written to disk. ```ts import { after, before, test } from 'node:test' import { Agent, type KeyDidCreateOptions } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { coreBundle } from '@credo-ts/drizzle-storage/core' import { agentDependencies, NodeInMemoryKeyManagementStorage, NodeKeyManagementService } from '@credo-ts/node' import { createClient } from '@libsql/client' import { drizzle } from 'drizzle-orm/libsql' import { migrate } from 'drizzle-orm/libsql/migrator' export async function createTestAgent() { // A new in-memory database for every agent keeps parallel tests apart const database = drizzle(createClient({ url: ':memory:' })) await migrate(database, { migrationsFolder: coreBundle.migrations.sqlite.migrationsPath, // Give every bundle you add its own migrations table migrationsTable: 'credo_migrations__core', }) return new Agent({ dependencies: agentDependencies, modules: { storage: new DrizzleStorageModule({ database, bundles: [] }), // Keeps the keys in memory kms: new KeyManagementModule({ backends: [new NodeKeyManagementService(new NodeInMemoryKeyManagementStorage())], }), }, }) } ``` The Node.js [key management](https://credo.js.org/guides/next/agent-setup/key-management.md) backend is lighter than Askar and needs no store. DIDComm needs XSalsa20-Poly1305, which only Askar has, so tests that use DIDComm keep the keys in Askar with an in-memory store. A test with Node.js's test runner: ```ts const agent = await createTestAgent() before(() => agent.initialize()) after(() => agent.shutdown()) test('creates a did:key', async () => { const { didState } = await agent.dids.create({ method: 'key', options: { createKey: { type: { kty: 'OKP', crv: 'Ed25519' } } }, }) if (didState.state !== 'finished') throw new Error('Creating the DID failed') }) ``` # AI coding agents > Give Claude, ChatGPT, Cursor and other AI tools the Credo docs as Markdown. AI coding agents often know an older Credo version from their training data. They write better Credo code when they read these docs, so the docs are also published as Markdown. ## Entry points | URL | Contents | | --- | --- | | [https://credo.js.org/guides/next/llms.txt](https://credo.js.org/guides/next/llms.txt) | Index of the Credo 0.8 docs (unreleased) | | [https://credo.js.org/guides/next/llms-full.txt](https://credo.js.org/guides/next/llms-full.txt) | All Credo 0.8 docs in one file | | [https://credo.js.org/llms.txt](https://credo.js.org/llms.txt) | Index of the Credo 0.7 docs, with a link to the Markdown version of each page | | [https://credo.js.org/llms-full.txt](https://credo.js.org/llms-full.txt) | All Credo 0.7 docs in one file | Every page is also available as Markdown: add `.md` to its URL, for example [https://credo.js.org/guides/next/getting-started/installation.md](https://credo.js.org/guides/next/getting-started/installation.md). Code examples in the Markdown are the same type-checked snippets as on the site. The **Copy page** button at the top of each page copies its Markdown. Its menu opens the page in Claude or ChatGPT. ## Point your agent at the docs Add a line like this to the `AGENTS.md` or `CLAUDE.md` of your project: ```md This project uses Credo 0.8 (`@credo-ts/*` packages). Before writing or changing Credo code, read https://credo.js.org/guides/next/llms.txt and the Markdown pages it links to. ``` # Concepts > Background on verifiable credentials, Credo agents and modules, DIDs and DIDComm, and the platforms Credo runs on. These pages explain the ideas behind Credo: how verifiable credentials work, what an agent is, and where DIDs and DIDComm fit in. Read them if you're new to verifiable credentials or to Credo. If you want to get an agent running first, start with [Getting started](https://credo.js.org/guides/next/getting-started.md). - [Verifiable credentials](https://credo.js.org/guides/next/concepts/verifiable-credentials.md): Issuers, holders and verifiers, credentials and presentations, and how exchange protocols, credential formats, key binding, selective disclosure, status and trust fit together in Credo. - [Agents and modules](https://credo.js.org/guides/next/concepts/agents.md): What a Credo agent is, how modules add functionality to it, and how agent contexts and tenants keep data apart. - [DIDs and DIDComm](https://credo.js.org/guides/next/concepts/did-and-didcomm.md): What decentralized identifiers and DID documents are, and how DIDComm uses them for messaging between agents. - [Platforms](https://credo.js.org/guides/next/concepts/platform-and-environment.md): The platforms Credo supports, Node.js and React Native, how its packages are published, and which runtimes aren't supported. # Verifiable credentials > Issuers, holders and verifiers, credentials and presentations, and how exchange protocols, credential formats, key binding, selective disclosure, status and trust fit together in Credo. A verifiable credential is a set of claims about a subject, such as a name or a date of birth, signed by the party that makes the claims. Anyone who receives it can check that the claims came from that party and weren't changed. This page explains the model and the terms the rest of the docs use, and links to the section that covers each part. ## Issuer, holder and verifier Three roles take part: - The issuer signs a credential and gives it to the holder. A government issuing an ID or a university issuing a diploma are issuers. - The holder receives the credential, stores it and decides when to share it. - The verifier asks the holder for credentials and checks them. ```mermaid flowchart LR Issuer -- issues credential --> Holder Holder -- presents credential --> Verifier Verifier -. checks signature and trust .-> Issuer ``` The issuer doesn't take part when the holder presents a credential. The verifier only needs the issuer's public key, which it gets from a DID or an X.509 certificate (see [Trust](#trust)). These docs use *holder* for the role, as the Credo APIs do (`agent.openid4vc.holder`), and *wallet* for the application that takes the holder role for a person, such as a mobile wallet app. A Credo agent can take any of these roles, and one agent can take more than one. Issuers and verifiers usually run on a server with Node.js. A holder usually runs in a mobile wallet built with React Native, but it can also be a cloud wallet on a server. ## Credentials and presentations The issuer creates a credential. When the holder shares it, they create a presentation: the credential, or the parts of it the verifier asked for, plus proof that the holder is the one it was issued to. A presentation is bound to the verifier's request, usually through a nonce, so it can't be replayed to another verifier. ## Exchange protocols and credential formats Credo separates two things: - An exchange protocol defines how the parties talk: how an issuer offers a credential, how the holder requests it, how a verifier asks for a presentation. Credo supports [OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc.md) (OpenID4VCI for issuance, OpenID4VP for presentation), [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md) (Issue Credential and Present Proof) and the [ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md) presentation protocols (in person over BLE or NFC, and online through the Digital Credentials API). - A credential format defines what a credential looks like and how it's signed: [SD-JWT VC](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md), [mdoc](https://credo.js.org/guides/next/credential-formats/mdoc.md), [W3C Verifiable Credentials](https://credo.js.org/guides/next/credential-formats/w3c-vc.md) and [AnonCreds](https://credo.js.org/guides/next/credential-formats/anoncreds.md). Several formats work over more than one protocol, and you can sign, store and verify a credential without any protocol. That's why the docs are split the same way: [Protocols](https://credo.js.org/guides/next/protocols.md) covers the flows, [Credential formats](https://credo.js.org/guides/next/credential-formats.md) covers what you can do with a format on its own, and pages such as [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md) combine the two. | Format | OpenID4VC | DIDComm | ISO mdoc | | --- | --- | --- | --- | | SD-JWT VC | Yes | No | No | | mdoc | Yes | No | Yes | | W3C VC | Yes | Yes | No | | AnonCreds | No | Yes | No | Not every W3C data model and signature type works over both OpenID4VC and DIDComm. See [Supported standards](https://credo.js.org/guides/next/supported-standards.md) for the full matrix. [Getting started](https://credo.js.org/guides/next/getting-started.md#choose-your-setup) recommends a protocol and format to start with. ## Key binding Most credentials are bound to a key that the holder controls. The issuer puts the holder's public key (or a DID that resolves to it) in the credential, and the holder signs each presentation with the private key. Someone who copies the credential can't present it without that key. AnonCreds binds credentials to a link secret instead of a key. In Credo the holder's keys live in a key management service (KMS). See [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md). ## Selective disclosure Selective disclosure lets the holder share some claims of a credential and keep the rest private: - SD-JWT VC and mdoc: the issuer signs a salted hash of each claim, and the holder reveals only the claims the verifier asks for. - AnonCreds: the holder creates a zero-knowledge proof, which can also prove a predicate, such as being over 18, without revealing the date of birth. - W3C VCs: those secured with SD-JWT (data model 2.0) work like SD-JWT VC. Those signed as a plain JWT or with a Linked Data proof are always shared as a whole. ## Revocation and status An issuer may need to revoke a credential, or suspend it for a while. With a status list, the issuer publishes a list with one entry per credential, and each credential points to its entry. The verifier fetches the list and checks the entry. Credo supports [Token Status Lists](https://credo.js.org/guides/next/credential-formats/status-lists.md) for SD-JWT VC and mdoc. AnonCreds uses its own revocation registries, and the holder proves the credential isn't revoked as part of the presentation (see [AnonCreds](https://credo.js.org/guides/next/credential-formats/anoncreds.md)). ## Trust A valid signature only proves who signed a credential. The verifier still has to decide whether it trusts that issuer. Credo identifies issuers in two ways: - [DIDs](https://credo.js.org/guides/next/dids-and-trust.md): the issuer's key is published in a DID document, on a ledger, a web server, or in the DID itself. See [DIDs and DIDComm](https://credo.js.org/guides/next/concepts/did-and-didcomm.md). - [X.509 certificates](https://credo.js.org/guides/next/dids-and-trust/x509.md): the issuer signs with a certificate that chains up to a root certificate the verifier trusts. mdoc always works this way, and SD-JWT VC often does. You tell Credo which issuers to trust per verification, or with a callback on the agent. See [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). # Agents and modules > What a Credo agent is, how modules add functionality to it, and how agent contexts and tenants keep data apart. Everything you do with Credo goes through an agent. This page explains what an agent holds, how modules extend it, and what agent contexts and tenants are. ## The agent An agent is an instance of `Agent` from `@credo-ts/core`. It holds the configuration, the storage, the keys and the modules for one party, such as an issuer, a verifier, a wallet or a mediator. These roles all use the same `Agent` class. What an agent can do depends on the modules you register. You pass three things to the constructor: - `dependencies`: platform-specific dependencies, such as file system access and `fetch`. Import `agentDependencies` from `@credo-ts/node` or `@credo-ts/react-native`. See [Platforms](https://credo.js.org/guides/next/concepts/platform-and-environment.md). - `modules`: the modules to register, by key. A storage module is required. - `config`: optional agent-wide settings, such as the logger. ```ts import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcModule } from '@credo-ts/openid4vc' const agent = new Agent({ dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup openid4vc: new OpenId4VcModule(), }, }) await agent.initialize() ``` `storage` holds the storage and key management modules, such as Drizzle for the records and Askar for the keys on a server. [Storage](https://credo.js.org/guides/next/agent-setup/storage.md) helps you choose, and [Your first agent](https://credo.js.org/guides/next/getting-started/first-agent.md) walks through a complete setup step by step. ## Modules A module adds functionality to the agent: an API, services, storage records, and sometimes HTTP endpoints or message handlers. Credo is split into packages that each provide one or more modules, so you install only what you use. - Default modules are always registered: among others DIDs, W3C credentials, SD-JWT VC, mdoc, X.509 and key management. You use them through properties such as `agent.dids`, `agent.sdJwtVc` and `agent.kms`. Register a module under the same key to configure it, for example `dids: new DidsModule({ ... })`. - Other modules you register yourself, such as `DrizzleStorageModule` or `AskarModule` for storage, `DidCommModule`, `OpenId4VcModule`, `AnonCredsModule` and `TenantsModule`. Their APIs are available under `agent.modules.`. - `DidCommModule` and `OpenId4VcModule` are also available as `agent.didcomm` and `agent.openid4vc`, but only when you register them under the keys `didcomm` and `openid4vc`. The agent's type follows from the modules you pass, so TypeScript knows which APIs exist. [Agent configuration](https://credo.js.org/guides/next/agent-setup.md) covers storage, key management, caching and logging. ## Agent context Credo keeps the data of one agent or tenant apart from others through an agent context. The context links storage, keys and configuration to an identifier, the `contextCorrelationId`. An agent has one root context. Credo passes the context along for you, so you only see it in callbacks, such as `getTrustedIssuersForVerification(agentContext, ...)`, and when you write your own modules. ## Tenants With `@credo-ts/tenants`, one agent can host many tenants. Each tenant gets its own agent context, so its own storage and keys, while all tenants share the modules, configuration and transports of the root agent. This uses fewer resources than running an agent per tenant, which matters for hosted issuers, verifiers and cloud wallets. See [Multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md). # DIDs and DIDComm > What decentralized identifiers and DID documents are, and how DIDComm uses them for messaging between agents. Credo uses DIDs to identify issuers, holders and the keys they sign with, and DIDComm to exchange messages between agents. This page explains the basics of both. ## DIDs A [decentralized identifier](https://www.w3.org/TR/did-1.0/) (DID) is an identifier such as `did:web:issuer.example.com` that you can resolve to a DID document. You create DIDs yourself: no central party issues them. The part after `did:` is the DID method. It defines where the document lives and how you create and update it: - `did:key` and `did:jwk` encode a single public key in the DID itself. Nothing is published. - `did:peer` is shared only with the other party of a DIDComm connection. - `did:web` and `did:webvh` are published on a web server under your domain. - `did:cheqd`, `did:indy` and `did:hedera` are anchored on a ledger. [DIDs](https://credo.js.org/guides/next/dids-and-trust.md) lists the methods Credo supports and shows how to create and resolve DIDs. ## DID documents A DID document lists: - Verification methods: the public keys of the DID. - Verification relationships, which say what each key may be used for, for example `assertionMethod` to sign credentials, `authentication` to prove control of the DID, and `keyAgreement` to encrypt messages. - Services, such as the endpoint where the DID controller receives DIDComm messages. A verifier resolves the issuer's DID to get the key that signed a credential. Another agent resolves a DID to find out where and how to send it a message. ## DIDComm [DIDComm](https://identity.foundation/didcomm-messaging/spec/) is a way for agents identified by DIDs to send each other messages. A message is encrypted for the recipient's `keyAgreement` key and sent to its service endpoint, over any transport, such as HTTP or WebSockets. Credo implements DIDComm Messaging v1, the version the Aries protocols use. On top of this messaging layer, protocols define specific exchanges: setting up a connection with Out of Band and DID Exchange, issuing credentials, requesting proofs, mediation and basic messages. A mobile wallet has no public endpoint, so it uses a mediator: an agent that receives and stores messages until the wallet picks them up. You don't need DIDComm to use DIDs. OpenID4VC uses DIDs or X.509 certificates for keys, and HTTPS and OAuth 2.0 for transport. See [Verifiable credentials](https://credo.js.org/guides/next/concepts/verifiable-credentials.md) for how the protocols compare, and [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md) to set it up. # Platforms > The platforms Credo supports, Node.js and React Native, how its packages are published, and which runtimes aren't supported. Credo runs on Node.js and React Native. This page describes what each platform is used for, what you need to know about how Credo is packaged, and which runtimes don't work. [Installation](https://credo.js.org/guides/next/getting-started/installation.md) has the setup steps. ## Node.js Use `@credo-ts/node` for agents that run on a server, such as issuers, verifiers and mediators. Credo requires Node.js 22 or later and is tested on Node.js 22, 24 and 26. Some parts of Credo only run on Node.js: the DIDComm inbound transports from `@credo-ts/node`, and the OpenID4VC issuer and verifier, which serve their endpoints with Express. ## React Native Use `@credo-ts/react-native` for mobile wallets. It needs `react-native-get-random-values`, `expo-crypto`, and a file system package (`@dr.pogodin/react-native-fs` or `react-native-fs`). Askar, AnonCreds and Indy VDR are native modules. With Expo, that means you need a development build: Expo Go can't load them. ## ESM only All `@credo-ts/*` packages are published as ES modules only. Use `import` in your code. A CommonJS project can still load them with `require()` on Node.js versions that support `require(esm)`, but ESM is recommended. In TypeScript, set `moduleResolution` to `nodenext`, `node16` or `bundler`. Other settings don't read the `exports` field of `package.json`, so subpath imports such as `@credo-ts/anoncreds/didcomm` don't resolve. ## Package versions All `@credo-ts/*` packages are released together and depend on exact versions of each other. Install the same version of every Credo package you use. ## Other runtimes - Browsers aren't supported. Credo has no storage, key management or platform dependencies for the browser, and the OpenID4VC issuer and verifier depend on Express. - Bun, Deno and Electron aren't tested. Credo's CI only runs on Node.js. They may work through their Node.js compatibility, but there's no guarantee. # Agent configuration > The options of the Credo Agent class, the modules every agent has, and how to start, stop and update an agent. This page describes what you pass to `new Agent()`, the modules every agent has, and how to start and stop an agent. The other pages in this section cover the choices you make when you set up an agent. ## Choices to make | Choice | Mobile wallet | Server | Details | | --- | --- | --- | --- | | Storage | Askar on SQLite | Drizzle on PostgreSQL, or on SQLite for a single instance or development | [Storage](https://credo.js.org/guides/next/agent-setup/storage.md) | | Key management | Askar, plus the secure environment for keys that must stay in hardware | Askar, with its storage turned off | [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md) | | Cache | The default | The default for one agent, `InMemoryLruCache` with tenants, Redis with several instances | [Caching](https://credo.js.org/guides/next/agent-setup/caching.md) | | Multi-tenancy | Not needed | Only when one service hosts many isolated issuers, verifiers or wallets | [Multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md) | For tests, use Drizzle on an in-memory SQLite database and keep the keys in memory with the Node.js backend. ## Agent options | Option | Description | | --- | --- | | `dependencies` | Required. The platform implementations of the file system, `fetch`, WebSocket and events: `agentDependencies` from `@credo-ts/node` or `@credo-ts/react-native`. | | `modules` | The modules of the agent, keyed by the name of their API. Include a storage module, [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md) or [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md): without one, `new Agent()` throws `Missing required dependency: 'StorageService'`. | | `config` | Agent-wide settings, described below. | ## Config | Field | Default | Description | | --- | --- | --- | | `logger` | `ConsoleLogger` at `LogLevel.Off` | The logger of the agent and its modules. See [Logging](https://credo.js.org/guides/next/agent-setup/logging.md). | | `autoUpdateStorageOnStartup` | `false` | Update the storage during `initialize()` when it was written by an older version of Credo. See [Storage updates](#storage-updates). | | `allowInsecureHttpUrls` | `false` | Accept `http://` URLs where HTTPS is otherwise required, such as OpenID4VC endpoints. For local development only. The OpenID4VC module applies it to the whole process, so it also affects other agents in it. | | `validitySkewSeconds` | `30` | The clock skew, in seconds, allowed when checking the validity period of credentials, status lists and other signed objects. The OpenID4VC issuer also applies it to wallet attestations and DPoP proofs. Phones often run a little behind. | | `getTrustedIssuersForVerification` | none | A callback that returns the DIDs and X.509 certificates to trust for a verification. See [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). | ## Default modules Credo registers these modules on every agent: | Key | Module | API | | --- | --- | --- | | `dids` | `DidsModule` | `agent.dids` | | `kms` | `KeyManagementModule` | `agent.kms` | | `sdJwtVc` | `SdJwtVcModule` | `agent.sdJwtVc` | | `mdoc` | `MdocModule` | `agent.mdoc` | | `w3cCredentials` | `W3cCredentialsModule` | `agent.w3cCredentials` | | `w3cV2Credentials` | `W3cV2CredentialsModule` | `agent.w3cV2Credentials` | | `w3cDataIntegrity` | `W3cDataIntegrityModule` | `agent.w3cDataIntegrity` | | `x509` | `X509Module` | `agent.x509` | | `tokenStatusList` | `TokenStatusListModule` | `agent.tokenStatusList` | | `genericRecords` | `GenericRecordsModule` | `agent.genericRecords` | | `cache` | `CacheModule` with a `SingleContextStorageLruCache` of 500 entries | none | | `dcql` | `DcqlModule` | none | | `pex` | `DifPresentationExchangeModule` | none | The `kms` module starts without a backend. `AskarModule` adds one, also when Drizzle stores the records. Without Askar, configure a backend yourself (see [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md)). The default cache doesn't work with [multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md), so replace it when you use tenants (see [Caching](https://credo.js.org/guides/next/agent-setup/caching.md)). ## Replace a default module To configure a default module, pass your own instance under the same key: ```ts import { Agent, ConsoleLogger, DidsModule, JwkDidRegistrar, JwkDidResolver, KeyDidRegistrar, KeyDidResolver, LogLevel, WebDidResolver, } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' function getAgentModules() { return { ...storage, // Storage and key management, see Agent setup // Same key as the default module, so it replaces it dids: new DidsModule({ registrars: [new KeyDidRegistrar(), new JwkDidRegistrar()], resolvers: [new KeyDidResolver(), new JwkDidResolver(), new WebDidResolver()], }), } } const agent = new Agent({ config: { logger: new ConsoleLogger(LogLevel.Warn), }, dependencies: agentDependencies, modules: getAgentModules(), }) ``` The API stays where it was, at `agent.dids`. When you pass `resolvers` or `registrars` to the `DidsModule`, your list replaces the defaults, except for `did:key` and `did:peer`, which are always added. ## Module APIs and types Default modules are at the top level, for example `agent.dids`, also when you replace them. Other modules are under `agent.modules`, for example `agent.modules.askar` or `agent.modules.tenants`. The exceptions are `didcomm` and `openid4vc`: registered under exactly those keys, they're also at `agent.didcomm` and `agent.openid4vc`. Modules without an API, such as `cache`, have no property on the agent. The `Agent` class infers these types from the `modules` you pass to the constructor. To type an agent you pass to other functions, derive the type from a function that returns the modules: ```ts export type MyAgent = Agent> export async function createDid(agent: MyAgent) { const { didState } = await agent.dids.create({ method: 'jwk', options: { createKey: { type: { kty: 'EC', crv: 'P-256' } } }, }) return didState.did } ``` `Agent` without a type argument accepts any agent, but loses the types of `agent.modules`. ## Start and stop the agent ```ts await agent.initialize() console.log(`Created ${await createDid(agent)}`) await agent.shutdown() console.log(`Agent initialized: ${agent.isInitialized}`) ``` `initialize()` initializes the modules: it opens the storage (Askar creates the store on the first run), starts DIDComm transports and adds OpenID4VC routes to the Express app. Then it checks the storage version. Calling `initialize()` on an agent that is already initialized throws. `shutdown()` stops the modules and their transports and closes the storage. Afterwards `agent.isInitialized` is `false`, and you can initialize the agent again. ## Storage updates Credo stores the version of its storage format. When you start a newer version of Credo on storage that needs an update, `initialize()` throws `Current agent storage is not up to date`. Set `autoUpdateStorageOnStartup: true` to run the update during `initialize()`, or run it yourself. Credo doesn't back up the storage first, so make a backup before you upgrade. See [Updating](https://credo.js.org/guides/next/updating.md). With Drizzle, the database schema has its own SQL migrations, which you run separately. See [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md#run-migrations). ## In this section - [Storage](https://credo.js.org/guides/next/agent-setup/storage.md): Compare Askar and Drizzle, the two storage modules for a Credo agent, and pick the one that fits your platform and deployment. - [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md): Choose and configure the key management backends of a Credo agent (Askar, Node.js, hardware-backed keys on React Native, or your own), and use agent.kms to create keys and sign. - [Caching](https://credo.js.org/guides/next/agent-setup/caching.md): Choose and configure the cache a Credo agent uses for resolved DID documents, ledger objects and certificate revocation lists. - [Multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md): Run many isolated tenants in one Credo agent with the tenants module, choose how their data is stored, and manage tenant sessions and status. - [Logging](https://credo.js.org/guides/next/agent-setup/logging.md): Log what a Credo agent does with the built-in ConsoleLogger or your own logger, and choose the log level. # Storage > Compare Askar and Drizzle, the two storage modules for a Credo agent, and pick the one that fits your platform and deployment. Every Credo agent needs a storage module to keep its state between restarts: DIDs, credentials, OpenID4VC issuers and sessions, DIDComm connections, tenants and generic records. Credo ships two, Drizzle and Askar, and one of them stores the records. Without one, `new Agent()` throws `Missing required dependency: 'StorageService'`. ## Which should I use? | You're building | Records | Keys | | --- | --- | --- | | A server | Drizzle on PostgreSQL | Askar, with storage turned off | | A server with one instance, or local development | Drizzle on SQLite | Askar, with storage turned off | | A mobile wallet | Askar on SQLite | Askar | | Tests | Drizzle on [in-memory SQLite](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md#in-memory-database-for-tests) | Node.js backend, in memory. Askar in memory for DIDComm, which needs algorithms only Askar has | Drizzle scales better on servers: records are SQL tables with indexes that the database can query, and many tenants and instances share one database. On phones, Askar encrypts the wallet with a key you keep in the device keychain, includes key management, and has no schema migrations to ship with app updates. Drizzle on `expo-sqlite` is an option there too. Drizzle doesn't encrypt records, so rely on encryption of the database or disk. It also adds SQL migrations, also in non-breaking Credo releases, and Credo doesn't work until you apply them. If you can't run migrations on every deploy, use Askar on PostgreSQL for the records as well: it has no schema and encrypts everything with the store key. For many tenants on Askar, use a [profile per tenant](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md#where-tenant-data-is-stored). ## Askar and Drizzle compared | | Askar | Drizzle | | --- | --- | --- | | Package | `@credo-ts/askar` | `@credo-ts/drizzle-storage` | | Encryption at rest | Record values and tags are encrypted with the store key | None. Use encryption of the database or disk | | Key management included | Yes | No, add a backend such as Askar | | Databases | SQLite, PostgreSQL | PostgreSQL (`pg` driver), SQLite (`libsql` or `expo-sqlite` driver) | | Platforms | Node.js, React Native | Node.js, React Native (with `expo-sqlite`) | | Schema and migrations | No schema, nothing to migrate | SQL tables. Run migrations on every Credo upgrade | | Multi-tenancy | A database or a profile per tenant | One database, rows scoped per tenant | | Read records outside Credo | No, the data is encrypted | Yes, a table per record type | | Cursor pagination in queries | No, only `limit` and `offset` | Yes | Keys aren't records: the [key management](https://credo.js.org/guides/next/agent-setup/key-management.md) backend stores them. With Drizzle, Askar usually keeps the keys in a store of its own. ## Move from Askar to Drizzle A server that already stores its records in Askar can copy them to Drizzle. The keys stay in Askar. See [Migrate from Askar](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md#migrate-from-askar). To use a storage backend Credo doesn't ship, implement the `StorageService` interface from `@credo-ts/core`. - [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md): Configure Askar for encrypted storage and key management in a Credo agent, on SQLite or PostgreSQL, in Node.js and React Native. - [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md): Store Credo records in PostgreSQL or SQLite tables with Drizzle ORM, run the migrations, and move an existing Askar agent to Drizzle. # Askar > Configure Askar for encrypted storage and key management in a Credo agent, on SQLite or PostgreSQL, in Node.js and React Native. [Askar](https://github.com/openwallet-foundation/askar) is an encrypted store for records and keys. `AskarModule` registers it as the agent's storage and as a [key management](https://credo.js.org/guides/next/agent-setup/key-management.md) backend. It runs on SQLite or PostgreSQL, in Node.js and React Native. Askar is the default for mobile wallets: it encrypts the wallet, includes key management, and has no migrations to ship with app updates. On a server, [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md) stores the records and Askar keeps the keys, with [storage turned off](#storage-and-key-management). Use SQLite, the default, on phones and for a server with one instance, and PostgreSQL when several instances share the keys. To choose between Askar and Drizzle, see [Storage](https://credo.js.org/guides/next/agent-setup/storage.md). ## Install **Node.js** ```bash npm install @credo-ts/askar@alpha @openwallet-foundation/askar-nodejs@0.6.1 ``` **React Native** ```bash npm install @credo-ts/askar@alpha @openwallet-foundation/askar-react-native@0.6.1 ``` Askar is a native module, so it doesn't run in Expo Go. Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/). The platform package pins `@openwallet-foundation/askar-shared` to its own version, so install a single Askar version across your project. `@credo-ts/askar-to-drizzle-storage-migration` requires exactly `0.6.1`. ## Configure **Node.js** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' const agent = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'my-agent', // Load the key from a secret store in production key: 'my-agent-store-key', }, }), }, }) // Creates the store on the first run, opens it on later runs await agent.initialize() ``` **React Native** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/react-native' import { NativeAskar } from '@openwallet-foundation/askar-react-native' // walletKey: created once with Store.generateRawKey() and kept in the device keychain export async function initializeAgent(walletKey: string) { const agent = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'wallet', key: walletKey, keyDerivationMethod: 'raw', }, }), }, }) // Creates the store on the first run, opens it on later runs await agent.initialize() return agent } ``` Pass the `NativeAskar` class as `askar`. The `askar` instance export of the platform package is deprecated: it's only set once the platform package has run, which depends on import order. `store.id` names the store. It's also the root agent's profile, the SQLite directory and the PostgreSQL database name. `agent.initialize()` opens the store, and creates it if it doesn't exist yet. `agent.shutdown()` closes it. With the wrong key, `initialize()` throws a `CredoError` caused by `AskarStoreInvalidKeyError`. A different `id` or SQLite `path` doesn't fail: it creates a new, empty store. To open an existing wallet, check that the database exists before you initialize. ## Store key Askar encrypts the store with `store.key`. `keyDerivationMethod` decides how it turns the key into the encryption key: | `keyDerivationMethod` | Use it for | | --- | --- | | `raw` | Random keys that you keep somewhere safe, such as the device keychain or a secret manager. Skips key derivation, so opening the store is fast. | | `kdf:argon2i:mod` (default) | Passwords and PINs. Argon2i makes guessing the key slow. | | `kdf:argon2i:int` | The same, faster and less secure. | Use `raw` when you can keep a random key in a keychain or secret manager, and Argon2i when the key is something a person types. Generate a `raw` key once with `Store.generateRawKey()` from the platform package: any other string fails when Askar creates the store. To change the key of an existing store, call `agent.modules.askar.rotateStoreKey({ newKey, newKeyDerivationMethod })` and then update the key in your configuration. ## Databases ### SQLite The store is a file at `/wallet//sqlite.db`. The data path is `~/.afj/data` on Node.js, and `.afj` in the app's document directory on React Native. To use another file, set the full path of the database file: ```ts database: { type: 'sqlite', config: { path: '/var/lib/my-agent/agent.db' } } ``` ### In-memory SQLite For tests that need Askar, such as DIDComm tests, keep the store in memory. The data is gone when the agent shuts down. Other tests can use the lighter [Drizzle and Node.js setup](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md#in-memory-database-for-tests). ```ts import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { NativeAskar, Store } from '@openwallet-foundation/askar-nodejs' const agent = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'test-agent', // A random raw key skips the slow key derivation, which keeps tests fast key: Store.generateRawKey(), keyDerivationMethod: 'raw', database: { type: 'sqlite', config: { inMemory: true } }, }, }), }, }) ``` ### PostgreSQL ```ts import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' const agent = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { // Also the name of the Postgres database id: 'my-agent', key: 'my-agent-store-key', database: { type: 'postgres', config: { host: 'localhost:5432', maxConnections: 10, connectionParameters: { sslmode: 'require' }, }, credentials: { account: 'credo', password: 'credo-password', }, }, }, }), }, }) await agent.initialize() ``` The store `id` is the database name. On the first `initialize()` Askar creates that database, so the account needs permission to create databases, or you pass a separate `adminAccount` and `adminPassword` in `credentials` for that. | Option | Description | | --- | --- | | `config.host` | Host and port, for example `localhost:5432`. | | `config.maxConnections`, `config.minConnections` | Size of the connection pool. Also available for SQLite. | | `config.connectTimeout`, `config.idleTimeout` | Timeouts. | | `config.connectionParameters` | Extra connection string parameters, such as `sslmode`, `sslrootcert` and `application_name`. Parameters that Credo sets itself, such as `host`, `user` and `dbname`, throw. | | `credentials.account`, `credentials.password` | The account to connect with. | | `credentials.adminAccount`, `credentials.adminPassword` | Optional account for creating and deleting databases. | ## Storage and key management `AskarModule` registers storage and a key management backend named `askar`. Turn either off: | `enableStorage` | `enableKms` | Use it when | | --- | --- | --- | | `true` | `true` | Askar stores records and keys. The default. | | `false` | `true` | Another module stores records, such as [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md). Askar still creates its store, for the keys. | | `true` | `false` | You register `AskarKeyManagementService` yourself in the `KeyManagementModule`, for example to make Askar the default next to another backend. See [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md#multiple-backends). | If you add `AskarKeyManagementService` to the `KeyManagementModule` backends and keep `enableKms` on, registering the module throws. ## Multi-tenancy With the [tenants module](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md), `multiWalletDatabaseScheme` decides where tenant data goes: `AskarMultiWalletDatabaseScheme.ProfilePerWallet` keeps all tenants in the root store with a profile each, `DatabasePerWallet` (the default) gives each tenant its own store. This also applies when Askar only keeps the keys. Use `ProfilePerWallet`. See [Where tenant data is stored](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md#where-tenant-data-is-stored) for the trade-offs, and for why you shouldn't set a SQLite `path` with `DatabasePerWallet`. ## Manage the store `agent.modules.askar` (the module's key in `modules`) has methods for store-level tasks: | Method | What it does | | --- | --- | | `rotateStoreKey({ newKey, newKeyDerivationMethod? })` | Changes the store key. Update your configuration afterwards. | | `exportStore({ exportToStore })` | Copies the open store to a new store, for example a backup with its own id, key and path. On SQLite, the target file must not exist yet. | | `importStore({ importFromStore })` | Copies another store, such as a backup, to the configured store location. Call `closeStore()` first. On SQLite, the configured file must not exist yet. | | `deleteStore()` | Deletes the store and all its data. | | `closeStore()`, `openStore()`, `provisionStore()`, `isStoreOpen` | Manual control. `initialize()` and `shutdown()` already do this. | With `ProfilePerWallet` these methods act on the whole store, including all tenants, so they throw when you call them from a tenant agent. # Drizzle > Store Credo records in PostgreSQL or SQLite tables with Drizzle ORM, run the migrations, and move an existing Askar agent to Drizzle. `@credo-ts/drizzle-storage` stores the agent's records in regular SQL tables in PostgreSQL or SQLite, through [Drizzle ORM](https://orm.drizzle.team). You create the database connection, choose the record bundles and run migrations. Drizzle doesn't store keys, so you also need a [key management](https://credo.js.org/guides/next/agent-setup/key-management.md) backend, usually Askar. Drizzle is the storage for servers: records are SQL tables with indexes that the database can query, and many tenants and instances share one database. It doesn't encrypt records, and you run its migrations on every Credo upgrade. To compare it with Askar, see [Storage](https://credo.js.org/guides/next/agent-setup/storage.md). ## Install **PostgreSQL** ```bash npm install @credo-ts/drizzle-storage@alpha drizzle-orm@^0.45.4 pg @credo-ts/askar@alpha @openwallet-foundation/askar-nodejs@0.6.1 ``` **SQLite** ```bash npm install @credo-ts/drizzle-storage@alpha drizzle-orm@^0.45.4 @libsql/client @credo-ts/askar@alpha @openwallet-foundation/askar-nodejs@0.6.1 ``` `@credo-ts/drizzle-storage` requires `drizzle-orm` `^0.45.4`. It includes `drizzle-kit` for the migration CLI, so you don't install that yourself. The Askar packages provide the key management backend. Use PostgreSQL with the `pg` driver on servers, and always when several instances share the data. SQLite works for a single instance or local development, with `libsql` on Node.js or `expo-sqlite` on React Native. For another driver, open an issue in the Credo repository: Credo has to map the driver's error codes. ## Configure **PostgreSQL** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcModule } from '@credo-ts/openid4vc' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/node-postgres' const agent = new Agent({ dependencies: agentDependencies, modules: { storage: new DrizzleStorageModule({ database: drizzle('postgresql://credo:credo-password@localhost:5432/credo'), // The core bundle is always included. Add one bundle per module that stores records. bundles: [openid4vcBundle], }), // Drizzle doesn't store keys: Askar provides the KMS, in its own Postgres database askar: new AskarModule({ askar: NativeAskar, enableStorage: false, store: { id: 'credo-keys', key: 'my-agent-store-key', database: { type: 'postgres', config: { host: 'localhost:5432' }, credentials: { account: 'credo', password: 'credo-password' }, }, }, }), openid4vc: new OpenId4VcModule(), }, }) // Run the migrations first: the module doesn't create tables await agent.initialize() ``` **SQLite** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { agentDependencies } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/libsql' const agent = new Agent({ dependencies: agentDependencies, modules: { storage: new DrizzleStorageModule({ database: drizzle('file:agent.db'), bundles: [], }), // Drizzle doesn't store keys: Askar provides the KMS askar: new AskarModule({ askar: NativeAskar, enableStorage: false, store: { id: 'credo-keys', key: 'my-agent-store-key' }, }), }, }) // Run the migrations first: the module doesn't create tables await agent.initialize() ``` `database` takes any Drizzle PostgreSQL or SQLite database. `drizzle()` accepts a connection string, or a client or pool you created yourself. The module doesn't create tables. Run the [migrations](#run-migrations) before the first `initialize()`. ## Bundles A bundle holds the tables for the records of one Credo module. Pass a bundle for every module you use that stores records. Without it, saving or reading such a record throws `Could not find a registered drizzle adapter for record type`. | Bundle | Import | For | | --- | --- | --- | | `coreBundle` | `@credo-ts/drizzle-storage/core` | `@credo-ts/core`. Always included by the module. | | `didcommBundle` | `@credo-ts/drizzle-storage/didcomm` | `@credo-ts/didcomm` | | `actionMenuBundle` | `@credo-ts/drizzle-storage/action-menu` | `@credo-ts/action-menu` | | `anoncredsBundle` | `@credo-ts/drizzle-storage/anoncreds` | `@credo-ts/anoncreds` | | `openid4vcBundle` | `@credo-ts/drizzle-storage/openid4vc` | `@credo-ts/openid4vc` | | `drpcBundle` | `@credo-ts/drizzle-storage/drpc` | `@credo-ts/drpc` | | `tenantsBundle` | `@credo-ts/drizzle-storage/tenants` | `@credo-ts/tenants` | | `questionAnswerBundle` | `@credo-ts/drizzle-storage/question-answer` | `@credo-ts/question-answer` | Order the bundles as in this table, so a bundle comes after the bundles it depends on (`actionMenuBundle` uses the connection table from `didcommBundle`). Put your own bundles last. ## Key management Without a key management backend the agent still initializes, but every key operation throws. The examples above keep the keys in Askar with `enableStorage: false`, so Askar creates a store for the keys only. Back that store up together with the Drizzle database: records such as DIDs refer to keys in it. The Node.js backend from `@credo-ts/node` keeps keys in memory only, so it's for [tests](#in-memory-database-for-tests). See [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md). ## Run migrations Credo ships the SQL migrations for each bundle. Run them before you start the agent for the first time, and again after every Credo upgrade: Drizzle storage adds migrations in non-breaking releases, and Credo doesn't work until you apply them. For example, [tenant status](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md#tenant-status) needs the `0001_add-tenant-status` migration of the tenants bundle. Running the migrations when there's nothing new does nothing. ### With the CLI The package has a `drizzle-storage` command: ```sh # PostgreSQL npx drizzle-storage --bundle core --bundle openid4vc migrate --dialect postgres --database-url "postgresql://credo:credo-password@localhost:5432/credo" # SQLite npx drizzle-storage --bundle core migrate --dialect sqlite --database-url "file:agent.db" ``` | Option | Description | | --- | --- | | `--bundle`, `-b` | A bundle to migrate. Repeat it for every bundle. Required, and the CLI adds no bundles itself, so always pass `--bundle core`. Takes the bundle names from the table above, or a path or package name for your own bundles. | | `migrate --dialect` | `postgres` or `sqlite`. | | `migrate --database-url` | The database to migrate. You can set `DRIZZLE_DATABASE_URL` instead. | Run the CLI with the same Credo version as your agent. The CLI keeps track of applied migrations in a table per bundle, such as `credo_migrations__core`. `drizzle-storage studio --dialect --database-url ` opens [Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview) on the database. ### In code **Node.js** Each bundle has the absolute path of its migration folders in `bundle.migrations`. Apply them with the Drizzle migrator for your driver, before you initialize the agent: ```ts import { coreBundle } from '@credo-ts/drizzle-storage/core' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { drizzle } from 'drizzle-orm/libsql' import { migrate } from 'drizzle-orm/libsql/migrator' const database = drizzle('file:agent.db') // The core bundle first, then the bundles you pass to the DrizzleStorageModule for (const bundle of [coreBundle, openid4vcBundle]) { await migrate(database, { migrationsFolder: bundle.migrations.sqlite.migrationsPath, // The table names the drizzle-storage CLI uses, so you can switch between the CLI and this code migrationsTable: `credo_migrations__${bundle.name.replace('-', '_')}`, }) } ``` For PostgreSQL, import `migrate` from `drizzle-orm/node-postgres/migrator` and use `bundle.migrations.postgres.migrationsPath`. **React Native** On React Native, use `expo-sqlite` as the driver and the React Native Askar package for the keys: ```bash npm install @credo-ts/drizzle-storage@alpha drizzle-orm@^0.45.4 expo-sqlite @credo-ts/askar@alpha @openwallet-foundation/askar-react-native@0.6.1 ``` A phone has its own database, so the app applies the migrations itself. Import the SQLite migrations of each bundle from `@credo-ts/drizzle-storage//migrations/sqlite` and apply them with `applyReactNativeMigrations`: ```ts import { applyReactNativeMigrations, DrizzleStorageModule } from '@credo-ts/drizzle-storage' import coreMigrations from '@credo-ts/drizzle-storage/core/migrations/sqlite' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import openid4vcMigrations from '@credo-ts/drizzle-storage/openid4vc/migrations/sqlite' import { drizzle } from 'drizzle-orm/expo-sqlite' import { migrate } from 'drizzle-orm/expo-sqlite/migrator' import { openDatabaseSync } from 'expo-sqlite' const database = drizzle(openDatabaseSync('credo.db')) // Run on every app start, before agent.initialize() await applyReactNativeMigrations({ db: database, migrate, migrations: [coreMigrations, openid4vcMigrations], }) export const storage = new DrizzleStorageModule({ database, bundles: [openid4vcBundle] }) ``` The migrations import `.sql` files, so the bundler has to inline them. Install `babel-plugin-inline-import` as a dev dependency, add the `inline-import` plugin to your Babel config with `extensions: ['.sql']`, and add `sql` to `resolver.sourceExts` in your Metro config. ## In-memory database for tests For tests, use an in-memory SQLite database and keep the keys in memory with the Node.js backend, so there's nothing to set up or clean up. A new in-memory database is empty, so apply the migrations each time you create an agent: ```ts import { Agent } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { coreBundle } from '@credo-ts/drizzle-storage/core' import { agentDependencies, NodeInMemoryKeyManagementStorage, NodeKeyManagementService } from '@credo-ts/node' import { drizzle } from 'drizzle-orm/libsql' import { migrate } from 'drizzle-orm/libsql/migrator' export async function createTestAgent() { // Every in-memory database starts empty, so apply the migrations each time const database = drizzle(':memory:') for (const bundle of [coreBundle]) { await migrate(database, { migrationsFolder: bundle.migrations.sqlite.migrationsPath, // A migrations table per bundle, as the drizzle-storage CLI uses migrationsTable: `credo_migrations__${bundle.name.replace('-', '_')}`, }) } return new Agent({ dependencies: agentDependencies, modules: { storage: new DrizzleStorageModule({ database, bundles: [] }), // Keys in memory as well, with node:crypto kms: new KeyManagementModule({ backends: [new NodeKeyManagementService(new NodeInMemoryKeyManagementStorage())], }), }, }) } ``` The Node.js backend can't encrypt DIDComm messages, which need XSalsa20-Poly1305. For DIDComm tests, keep the keys in Askar on [in-memory SQLite](https://credo.js.org/guides/next/agent-setup/storage/askar.md#in-memory-sqlite), with `enableStorage: false`. ## Migrate from Askar `@credo-ts/askar-to-drizzle-storage-migration` copies all records from an Askar store to an empty Drizzle database. Use it when a server that runs on Askar should move to Drizzle, for example to get SQL access to its records. It doesn't move keys: after the migration, Askar stays your key management backend with storage turned off. ```bash npm install @credo-ts/askar-to-drizzle-storage-migration@alpha @openwallet-foundation/askar-shared@0.6.1 ``` 1. Stop the agent and back up the Askar store. The migrator doesn't make a backup. 2. Create the Drizzle database and [run the migrations](#run-migrations) for every bundle you'll use. The database must be empty. 3. Run the migration with your current Askar configuration and your new Drizzle configuration: ```ts import { AskarModule } from '@credo-ts/askar' import { AskarToDrizzleStorageMigrator } from '@credo-ts/askar-to-drizzle-storage-migration' import { ConsoleLogger, LogLevel } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { agentDependencies } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/libsql' // The Askar config your agent uses today, with storage still enabled const askarModule = new AskarModule({ askar: NativeAskar, store: { id: 'my-agent', key: 'my-agent-store-key' }, }) // The Drizzle config your agent will use: an empty, migrated database and a bundle for // every module that stored records in Askar const drizzleModule = new DrizzleStorageModule({ database: drizzle('file:agent.db'), bundles: [], }) const migrator = await AskarToDrizzleStorageMigrator.initialize({ askarModule, drizzleModule, agentDependencies, logger: new ConsoleLogger(LogLevel.Info), }) // Copies all records from Askar to Drizzle. The keys stay in Askar. await migrator.migrate() ``` 4. Start the agent with the `DrizzleStorageModule` and the same Askar store configuration, with `enableStorage: false`, so Credo still finds the keys. 5. When everything works, you can remove the copied records from the Askar store: ```ts // Removes the copied records from the Askar store, keeping the keys await migrator.deleteStorageRecords() ``` From then on, only open the store with `enableStorage: false`. With storage on, `initialize()` fails because the storage version record is gone. If a record in Askar has no bundle in the Drizzle module, the migration throws. `skipMigrationForMissingAdapter: true` skips such records instead, and they're lost. For a multi-tenant agent, pass `tenantsModule` and include `tenantsBundle` to migrate every tenant as well. Tenants that had their own Askar database all end up in the one Drizzle database. # Key management > Choose and configure the key management backends of a Credo agent (Askar, Node.js, hardware-backed keys on React Native, or your own), and use agent.kms to create keys and sign. The key management service (KMS) creates and stores keys and does the cryptography with them: signing, verifying, encrypting and key agreement. Credo modules use it for DIDs, credentials, DIDComm and OpenID4VC, and you can call it directly through `agent.kms`. The KMS hands the work to one or more backends. ## Which backend should I use? | You're building | Use | Why | | --- | --- | --- | | A server | Askar | Keys are stored encrypted and survive restarts. With [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md) storing the records, Askar keeps only the keys. | | A mobile wallet | Askar, plus the secure environment for keys that must stay in hardware | Askar handles everything the secure environment can't, such as verifying, encryption and Ed25519. | | Tests and other short-lived agents | Node.js, with in-memory key storage | Lighter than Askar: it only uses `node:crypto`, and the keys are gone when the process stops. | | Keys in a cloud KMS or an HSM | Your own backend | Credo doesn't ship one, but a backend is a single interface. See [Build your own backend](#build-your-own-backend). | Add the Node.js backend next to Askar to [verify RSA signatures](#verify-rsa-signatures) and other algorithms Askar lacks, for example of an X.509 certificate authority. Verifying with a public key stores nothing. Don't make the Node.js backend a server's only backend for keys it creates: it keeps them in memory, unless you give it a key storage of your own. ### Hardware-bound keys in a wallet Keys in the [secure environment](#secure-environment-react-native) are created in the phone's secure hardware and never leave it, so a copy of the wallet's data can't sign with them. Use it for the keys that credentials are bound to when issuers ask for it. Under HAIP, an issuer can require a [key attestation](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md#wallet-and-key-attestations) that says how the keys are protected, and the EUDI Wallet requires hardware-protected keys for credentials such as the PID. When no issuer asks for it, keep the keys in Askar, because hardware keys come with trade-offs: - Every signature shows a biometric prompt, so a presentation or a batch issuance asks the user to authenticate for each key it signs with. - Only P-256 keys and ES256 signatures. DIDs and credentials that need Ed25519 stay in Askar. - The keys can't be backed up or moved, so on a new phone the user needs new credentials. Changing the enrolled biometrics also invalidates the keys. ## Backends `KeyManagementModule` is a default module under the key `kms`, registered without backends. `AskarModule` adds the `askar` backend, unless you set `enableKms: false`. For other backends, register `new KeyManagementModule({ backends, defaultBackend })` yourself under the key `kms`, as in the examples below. Import `KeyManagementModule` and the other KMS classes from `@credo-ts/core/kms`. Credo doesn't check for a backend at startup. Without one, the agent initializes, and every key operation throws `No key management service backend found that supports ...`. ### Askar `AskarKeyManagementService` (backend `askar`) keeps keys in the Askar store, encrypted with the store key, on Node.js and React Native. See [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md). ### Node.js `NodeKeyManagementService` from `@credo-ts/node` (backend `node`) uses `node:crypto`, so it needs no native library. It supports more algorithms than Askar, such as RSA, P-521 and HMAC. It has two common uses: the only backend of a test or other short-lived agent, and a second backend that verifies what Askar can't (see [Verify RSA signatures](#verify-rsa-signatures)). It needs a key storage. For a test agent, keep the keys in memory: ```ts import { Agent } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { agentDependencies, NodeInMemoryKeyManagementStorage, NodeKeyManagementService } from '@credo-ts/node' const agent = new Agent({ dependencies: agentDependencies, modules: { kms: new KeyManagementModule({ // Keys only live in memory: they are lost when the process stops backends: [new NodeKeyManagementService(new NodeInMemoryKeyManagementStorage())], }), storage, // Storage, see Agent setup }, }) ``` :::warning[Keys are kept in memory] `NodeInMemoryKeyManagementStorage` is the only key storage that Credo ships. Keys are lost when the process stops, while DIDs and credentials in storage still refer to them. To keep keys, implement the `NodeKeyManagementStorage` interface (`get`, `has`, `set`, `delete`). It receives private keys as JWKs, so encrypt them at rest. ::: ### Secure environment (React Native) `SecureEnvironmentKeyManagementService` from `@credo-ts/react-native` (backend `secureEnvironment`) creates keys in the Secure Enclave on iOS and the Android Keystore, protected with biometrics. It needs `@animo-id/expo-secure-environment` 0.1 (version 0.2 doesn't match the peer dependency range). The package's README lists its platform requirements, such as Android API 30 or higher. ```bash npm install @animo-id/expo-secure-environment@^0.1.5 ``` It only creates P-256 keys, signs with ES256 and deletes keys. It can't verify, encrypt, import keys or generate random bytes, so you always use it next to Askar. See [Askar and the secure environment](#askar-and-the-secure-environment-on-react-native). ### Capabilities | | Askar | Node.js | Secure environment | | --- | --- | --- | --- | | Platforms | Node.js, React Native | Node.js | React Native | | Keys are stored | In the Askar store | In memory, or your own storage | In secure hardware | | EC curves | P-256, P-384, secp256k1 | P-256, P-384, P-521, secp256k1 | P-256 | | OKP curves | Ed25519, X25519 | Ed25519, X25519 | No | | RSA | No | Yes | No | | Symmetric keys | AES-128, AES-256, ChaCha20-Poly1305 | AES, HMAC | No | | Sign and verify | EdDSA, ES256, ES384, ES256K | EdDSA, ES256, ES384, ES512, ES256K, RS\*, PS\*, HS\* | ES256, sign only | | Encrypt and decrypt | AES-GCM, AES-CBC-HMAC, (X)ChaCha20, XSalsa20, AES key wrap, ECDH-ES, HPKE | AES-GCM, AES-CBC, AES-CBC-HMAC, ChaCha20, ECDH-ES, HPKE | No | | Import keys | EC and OKP keys | EC, OKP, RSA and symmetric keys | No | ## Multiple backends Every backend has a name (`askar`, `node`, `secureEnvironment`). Credo decides which one handles an operation like this: 1. If you pass `backend`, that backend handles it, or the call throws if it can't. 2. For an operation on an existing key, Credo uses the backend that holds the key: `sign`, `verify`, `encrypt`, `decrypt` and `getPublicKey` look the key up by its key id. 3. Otherwise, the default backend handles it if it supports the operation, and else the first other backend that does. This applies to `createKey`, `importKey`, `randomBytes` and `deleteKey`, and to `verify` with a `publicJwk`. `deleteKey` doesn't look up the key, so pass `backend` to delete a key that isn't in the default backend. The default backend is `defaultBackend`, or else the first backend in the list. The list starts with the backends you pass to `KeyManagementModule`, and `AskarModule` adds `askar` after them. `defaultBackend` must name a backend in your own `backends` list. So to make Askar the default, add `AskarKeyManagementService` to `backends` yourself and set `enableKms: false` on `AskarModule`: ```ts import { AskarKeyManagementService, AskarModule } from '@credo-ts/askar' import { Agent, TypedArrayEncoder } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { agentDependencies, NodeInMemoryKeyManagementStorage, NodeKeyManagementService } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' const agent = new Agent({ dependencies: agentDependencies, modules: { storage, // Storage, see Agent setup kms: new KeyManagementModule({ backends: [new AskarKeyManagementService(), new NodeKeyManagementService(new NodeInMemoryKeyManagementStorage())], defaultBackend: 'askar', }), askar: new AskarModule({ askar: NativeAskar, // Askar only keeps the keys, and its backend is already in the backends list above enableStorage: false, enableKms: false, store: { id: 'kms-backends-example', key: 'kms-backends-example-store-key' }, }), }, }) ``` Then each key goes to a backend that supports it: ```ts // Askar supports Ed25519, so the default backend creates this key const ed25519Key = await agent.kms.createKey({ type: { kty: 'OKP', crv: 'Ed25519' } }) // Askar doesn't support P-521, so this falls back to the node backend const p521Key = await agent.kms.createKey({ type: { kty: 'EC', crv: 'P-521' } }) // Pass a backend to choose it explicitly const rsaKey = await agent.kms.createKey({ backend: 'node', type: { kty: 'RSA', modulusLength: 2048 } }) // sign finds the backend that holds the key const data = TypedArrayEncoder.fromUtf8String('Hello, Credo') await agent.kms.sign({ keyId: p521Key.keyId, algorithm: 'ES512', data }) ``` ### Askar and the secure environment on React Native On a phone, make Askar the default and use the secure environment only for the keys that need hardware protection: ```ts import { AskarKeyManagementService, AskarModule } from '@credo-ts/askar' import { Agent, TypedArrayEncoder } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { agentDependencies, SecureEnvironmentKeyManagementService } from '@credo-ts/react-native' import { NativeAskar } from '@openwallet-foundation/askar-react-native' export async function initializeAgent(walletKey: string) { const agent = new Agent({ dependencies: agentDependencies, modules: { kms: new KeyManagementModule({ backends: [new AskarKeyManagementService(), new SecureEnvironmentKeyManagementService()], defaultBackend: 'askar', }), askar: new AskarModule({ askar: NativeAskar, // The Askar backend is already in the backends list above enableKms: false, store: { id: 'wallet', key: walletKey, keyDerivationMethod: 'raw' }, }), }, }) await agent.initialize() return agent } ``` Don't only list the secure environment in `backends` and let `AskarModule` add its backend: the secure environment then comes first and becomes the default, and every P-256 key Credo creates ends up in hardware behind biometrics. Create a hardware key by passing `backend`. Signing finds the key by its key id: ```ts export async function signWithHardwareKey(agent: Agent, message: string) { // Only P-256 keys and ES256 signatures are supported in the secure environment const { keyId, publicJwk } = await agent.kms.createKey({ backend: 'secureEnvironment', type: { kty: 'EC', crv: 'P-256' }, }) const { signature } = await agent.kms.sign({ keyId, algorithm: 'ES256', data: TypedArrayEncoder.fromUtf8String(message), }) return { publicJwk, signature } } ``` To verify a signature from a hardware key, pass the `publicJwk` instead of the key id, so another backend does it. ### Verify RSA signatures Neither Askar nor the secure environment supports RSA. You still meet RSA signatures from others, for example X.509 certificate authorities that sign with RSA, or JWTs signed by an external RSA key. Credo can't check those without a backend that verifies RSA, and the error doesn't always say so: an mdoc issued under an RSA certificate authority fails with an error about the certificate chain. On Node.js, add the Node.js backend next to Askar. A verification with a public key needs no stored key, so the in-memory key storage is enough: ```ts import { AskarKeyManagementService, AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { agentDependencies, NodeInMemoryKeyManagementStorage, NodeKeyManagementService } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' const agent = new Agent({ dependencies: agentDependencies, modules: { storage, // Storage, see Agent setup kms: new KeyManagementModule({ backends: [ new AskarKeyManagementService(), // Only verifies with public keys passed in the call, so nothing is stored in it new NodeKeyManagementService(new NodeInMemoryKeyManagementStorage()), ], defaultBackend: 'askar', }), askar: new AskarModule({ askar: NativeAskar, enableStorage: false, enableKms: false, store: { id: 'kms-node-verify-example', key: 'kms-node-verify-example-store-key' }, }), }, }) ``` Credo then sends RSA verifications to it: ```ts // Askar can't verify RS256, so Credo falls back to the node backend const { verified } = await agent.kms.verify({ key: { publicJwk: { kty: 'RSA', n, e } }, algorithm: 'RS256', data, signature, }) ``` On React Native, the Node.js backend isn't available. Add your own backend that only verifies RSA signatures, as described in [Build your own backend](#build-your-own-backend). Paradym Wallet has one, built on `react-native-quick-crypto`: [`RsaVerificationKeyManagementService`](https://github.com/animo/paradym-wallet/blob/226083df04e183b06e7a379b2b4938bbafe84f3c/packages/sdk/src/kms/RsaVerificationKeyManagementService.ts). Register it next to the other backends: ```ts new KeyManagementModule({ backends: [ new AskarKeyManagementService(), new SecureEnvironmentKeyManagementService(), new RsaVerificationKeyManagementService(), ], defaultBackend: 'askar', }) ``` ## Build your own backend A backend is a class that implements `KeyManagementService` from `@credo-ts/core/kms`. Write one to keep keys in a cloud KMS such as Google Cloud KMS or AWS KMS, in an HSM, or on a platform Credo has no backend for. Implement only what your key store can do, and leave the rest to Askar or the Node.js backend in the same agent. Credo uses these parts of the interface to route operations: | Member | What Credo expects | | --- | --- | | `backend` | A unique name. Users pass it as `backend` to choose this backend. | | `isOperationSupported(agentContext, operation)` | `true` for each operation and algorithm the backend handles, such as `createKey` for P-256 keys or `sign` with ES256. Credo never sends this backend an operation it returns `false` for. A call that names this backend for such an operation throws. | | `getPublicKey(agentContext, keyId)` | The public JWK, with `kid`, or `null` when the backend doesn't hold the key. Credo asks each backend in turn to find the one that holds a key. | | `createKey`, `importKey`, `deleteKey` | Manage keys. Return the key id that Credo stores in DIDs and credential records, so keep it stable. Respect `options.keyId` when it's set. | | `sign`, `verify`, `encrypt`, `decrypt`, `randomBytes` | The cryptography. Throw `KeyManagementAlgorithmNotSupportedError` or `KeyManagementError` for what you don't support. | This backend creates P-256 keys in a cloud KMS and signs with them. The `CloudKmsClient` interface stands in for your KMS SDK: ```ts import { createHash, createPublicKey } from 'node:crypto' import { type AgentContext, utils } from '@credo-ts/core' import { derEcSignatureToRaw, KeyManagementAlgorithmNotSupportedError, KeyManagementError, type KeyManagementService, type KmsCreateKeyOptions, type KmsCreateKeyReturn, type KmsCreateKeyType, type KmsJwkPublic, type KmsOperation, type KmsSignOptions, type KmsSignReturn, PublicJwk, } from '@credo-ts/core/kms' // The calls the backend needs from your cloud KMS SDK. With Google Cloud KMS these are // `createCryptoKey`, `getPublicKey` and `asymmetricSign` of its `KeyManagementServiceClient`. export interface CloudKmsClient { createP256SigningKey(keyId: string): Promise // PEM encoded public key, or null when the key doesn't exist getPublicKeyPem(keyId: string): Promise // Signs a SHA-256 digest and returns a DER encoded ECDSA signature signDigest(keyId: string, digest: Uint8Array): Promise } export class CloudKeyManagementService implements KeyManagementService { public readonly backend = 'cloud' public constructor(private client: CloudKmsClient) {} // Credo only routes the operations this returns true for to this backend public isOperationSupported(_agentContext: AgentContext, operation: KmsOperation): boolean { if (operation.operation === 'createKey') { return operation.type.kty === 'EC' && operation.type.crv === 'P-256' } return operation.operation === 'sign' && operation.algorithm === 'ES256' } // Credo asks each backend for a key id, so return null for keys this backend doesn't hold public async getPublicKey(_agentContext: AgentContext, keyId: string): Promise { const pem = await this.client.getPublicKeyPem(keyId) if (!pem) return null const publicJwk = PublicJwk.fromUnknown(createPublicKey(pem).export({ format: 'jwk' })) publicJwk.keyId = keyId return publicJwk.toJson() } public async createKey( agentContext: AgentContext, options: KmsCreateKeyOptions ): Promise> { if (options.type.kty !== 'EC' || options.type.crv !== 'P-256') { throw new KeyManagementAlgorithmNotSupportedError(`kty '${options.type.kty}'`, this.backend) } // Credo stores the key id in DIDs and credential records, so keep it stable const keyId = options.keyId ?? utils.uuid() await this.client.createP256SigningKey(keyId) const publicJwk = await this.getPublicKey(agentContext, keyId) if (!publicJwk) throw new KeyManagementError(`Key '${keyId}' was not found after creating it`) // The generic return type follows from the requested key type, which is checked above return { keyId, publicJwk } as KmsCreateKeyReturn } public async sign(_agentContext: AgentContext, options: KmsSignOptions): Promise { if (options.algorithm !== 'ES256') { throw new KeyManagementAlgorithmNotSupportedError(`algorithm '${options.algorithm}'`, this.backend) } const digest = createHash('sha256').update(options.data).digest() const derSignature = await this.client.signDigest(options.keyId, digest) // JWA signatures are the raw r || s values, cloud KMSs return them DER encoded return { signature: derEcSignatureToRaw(derSignature, 'P-256') } } // isOperationSupported returns false for these, so Credo uses another backend for them public async verify(): Promise { throw new KeyManagementError(`Verifying is not supported by backend '${this.backend}'`) } public async importKey(): Promise { throw new KeyManagementError(`Importing keys is not supported by backend '${this.backend}'`) } public async deleteKey(): Promise { throw new KeyManagementError(`Deleting keys is not supported by backend '${this.backend}'`) } public async encrypt(): Promise { throw new KeyManagementError(`Encrypting is not supported by backend '${this.backend}'`) } public async decrypt(): Promise { throw new KeyManagementError(`Decrypting is not supported by backend '${this.backend}'`) } public randomBytes(): never { throw new KeyManagementError(`Generating random bytes is not supported by backend '${this.backend}'`) } } ``` Signing has two traps with most cloud KMSs. They sign a digest, so hash the data yourself with the hash of the JWA algorithm (SHA-256 for ES256). And they return ECDSA signatures DER encoded, while JWA uses the raw `r || s` form: convert them with `derEcSignatureToRaw`. Register the backend in `KeyManagementModule`. Here it's the default, so Credo creates P-256 keys in the cloud KMS, and Askar creates the other keys, verifies and encrypts: ```ts import { AskarKeyManagementService, AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { KeyManagementModule } from '@credo-ts/core/kms' import { agentDependencies } from '@credo-ts/node' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' export function createAgent(cloudKmsClient: CloudKmsClient) { return new Agent({ dependencies: agentDependencies, modules: { storage, // Storage, see Agent setup kms: new KeyManagementModule({ backends: [new CloudKeyManagementService(cloudKmsClient), new AskarKeyManagementService()], // P-256 keys are created in the cloud KMS. Askar creates the other keys, such as Ed25519, // and verifies and encrypts. defaultBackend: 'cloud', }), askar: new AskarModule({ askar: NativeAskar, enableStorage: false, enableKms: false, store: { id: 'cloud-kms-example', key: 'cloud-kms-example-store-key' }, }), }, }) } ``` Import from `@credo-ts/core` before `@credo-ts/core/kms` in the file your app loads first, as the `kms` entry point doesn't load the `reflect-metadata` polyfill that Credo needs. ## Use `agent.kms` ```ts const { keyId, publicJwk } = await agent.kms.createKey({ type: { kty: 'OKP', crv: 'Ed25519' }, }) const data = TypedArrayEncoder.fromUtf8String('Hello, Credo') const { signature } = await agent.kms.sign({ keyId, algorithm: 'EdDSA', data }) const result = await agent.kms.verify({ key: { publicJwk }, algorithm: 'EdDSA', data, signature, }) console.log(result.verified) // true ``` | Method | What it does | | --- | --- | | `createKey({ type, keyId?, backend? })` | Creates a key and returns `{ keyId, publicJwk }`. `type` is for example `{ kty: 'EC', crv: 'P-256' }` or `{ kty: 'OKP', crv: 'Ed25519' }`. | | `createKeyForSignatureAlgorithm({ algorithm })` | Creates a key that fits a JWA signature algorithm, such as `ES256`. | | `importKey({ privateJwk })` | Imports a private JWK. Set `privateJwk.kid` to choose the key id. | | `getPublicKey({ keyId })` | Returns the public JWK of a key. | | `sign({ keyId, algorithm, data })` | Signs `data` (a `Uint8Array`). | | `verify({ key, algorithm, data, signature })` | Verifies with `key: { keyId }` or `key: { publicJwk }`. Returns `{ verified }`. | | `encrypt(...)`, `decrypt(...)` | Encrypts and decrypts with a key, a JWK or key agreement. | | `deleteKey({ keyId })` | Deletes a key. | | `randomBytes({ length })` | Returns random bytes. | | `supportedBackendsForOperation(operation)` | The backends that support an operation. | | `supportedJwaSignatureAlgorithms()` | The signature algorithms at least one backend supports. | To use a key in a DID, pass its `keyId` when you create the DID. See [DIDs](https://credo.js.org/guides/next/dids-and-trust.md). # Caching > Choose and configure the cache a Credo agent uses for resolved DID documents, ledger objects and certificate revocation lists. Credo caches data that is slow to fetch: resolved DID documents, AnonCreds objects from ledgers and revocation lists of X.509 certificates. A cache is always registered, and you only replace it in two cases: when you use [multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md), and when several instances of the agent run side by side. ## Which should I use? Keep the default for a single agent without tenants, such as a mobile wallet: nothing to configure, and entries survive a restart. Use `InMemoryLruCache` when the agent has tenants, because the default throws as soon as a tenant uses it. Use `RedisCache` when several instances of the agent run behind a load balancer, so they share one cache. With the default, instances overwrite each other's cache record in the shared storage, and with `InMemoryLruCache` each instance fetches and caches everything on its own. | Cache | Package | Tenants | Several processes share it | Survives a restart | | --- | --- | --- | --- | --- | | `SingleContextStorageLruCache` (default) | `@credo-ts/core` | No | No | Yes | | `InMemoryLruCache` | `@credo-ts/core` | Yes | No | No | | `RedisCache` | `@credo-ts/redis-cache` | Yes | Yes | Yes | The default keeps up to 500 entries in memory and saves them as a record in the agent's storage. When a tenant uses it, it throws `SingleContextStorageLruCache can not be used with multiple agent context instances`. ## Configure the cache Register a `CacheModule` under the key `cache` to replace the default: ```ts import { Agent, CacheModule, InMemoryLruCache } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' const agent = new Agent({ dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup cache: new CacheModule({ cache: new InMemoryLruCache({ limit: 500 }), }), }, }) ``` `InMemoryLruCache` removes the least recently used entries when it reaches `limit`. The limit applies to all agent contexts together. | `CacheModule` option | Description | | --- | --- | | `cache` | The cache to use. Required. | | `defaultExpiryInSeconds` | Expiry for entries set without one. `RedisCache` uses it. Default `60`. | | `useCachedStorageService` | Also caches DIDComm connection, mediation and mediator routing records, to save storage reads on busy DIDComm agents such as mediators. Default `false`. With several instances, only turn it on with a cache they share, such as Redis. | ## Redis ```bash npm install @credo-ts/redis-cache@alpha ioredis ``` ```ts import { Agent, CacheModule } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { RedisCache } from '@credo-ts/redis-cache' import { Redis } from 'ioredis' const redis = new Redis('redis://localhost:6379') const agent = new Agent({ dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup cache: new CacheModule({ // Also accepts ioredis options, e.g. new RedisCache({ host: 'localhost', port: 6379 }) cache: new RedisCache(redis), defaultExpiryInSeconds: 300, }), }, }) ``` `RedisCache` takes an `ioredis` client or `ioredis` options. Keys are prefixed with the agent context, or with `global:` for shared entries, so tenants and instances can share one Redis database. When you create the client yourself, close it after the agent shuts down: ```ts await agent.shutdown() // The agent doesn't close the Redis connection for you await redis.quit() ``` `cache.destroy(agentContext)` removes all entries of one agent context, for example after you delete a tenant. ## What is cached | Data | Cached for | Scope | | --- | --- | --- | | Resolved DID documents, for methods whose resolver allows caching (`did:web`, `did:webvh`, `did:cheqd`, `did:indy`, `did:sov`, `did:hedera`). `did:key`, `did:jwk` and `did:peer` aren't cached. | 5 minutes, change it with `cacheDurationInSeconds` when resolving | Global for methods in `DidsModule({ publicDidMethods })`, otherwise per agent context | | AnonCreds schemas, credential definitions, revocation registry definitions and status lists | 5 minutes | Global | | Summaries of X.509 certificate revocation lists | Until the list's next update | Global | | Indy VDR: which ledger a DID is on | Until evicted | Global | | DIDComm connection, mediation and mediator routing records, with `useCachedStorageService: true` | Until evicted | Per agent context | Global entries are public data that all tenants share. `publicDidMethods` defaults to `web`, `indy`, `sov`, `cheqd`, `hedera` and `webvh`. ## Custom cache Implement the `Cache` interface: `get`, `set` and `remove`. Each method receives the agent context and an optional `scope`. Keep entries of different agent contexts apart, and only share entries when `scope` is `'global'`: ```ts import type { AgentContext, Cache, CacheOptions } from '@credo-ts/core' export class MapCache implements Cache { private entries = new Map() // Isolate entries per agent context unless the caller asks for the global scope private key(agentContext: AgentContext, key: string, options?: CacheOptions) { return options?.scope === 'global' ? `global:${key}` : `${agentContext.contextCorrelationId}:${key}` } public async get(agentContext: AgentContext, key: string, options?: CacheOptions) { const namespacedKey = this.key(agentContext, key, options) const entry = this.entries.get(namespacedKey) if (!entry) return null if (entry.expiresAt && entry.expiresAt < Date.now()) { this.entries.delete(namespacedKey) return null } return entry.value as CacheValue } public async set( agentContext: AgentContext, key: string, value: CacheValue, expiresInSeconds?: number, options?: CacheOptions ) { this.entries.set(this.key(agentContext, key, options), { value, expiresAt: expiresInSeconds ? Date.now() + expiresInSeconds * 1000 : undefined, }) } public async remove(agentContext: AgentContext, key: string, options?: CacheOptions) { this.entries.delete(this.key(agentContext, key, options)) } } ``` # Multi-tenancy > Run many isolated tenants in one Credo agent with the tenants module, choose how their data is stored, and manage tenant sessions and status. With `@credo-ts/tenants`, one agent hosts many tenants. Each tenant has its own storage, keys, DIDs and credentials, while all tenants share the agent's modules, configuration and endpoints. ## When to use tenants Use tenants for a service that issues, verifies or holds credentials on behalf of many organizations or users, such as an issuance platform or a cloud wallet. The alternative, an agent per customer, needs a process, a database and endpoints for each of them. You don't need tenants for a mobile wallet, which has one user. You don't need them either to run several issuers or verifiers for one organization: one agent can host many [OpenID4VC issuers](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) and verifiers. Use tenants when each of them needs keys, DIDs and data of its own. ## Install ```bash npm install @credo-ts/tenants@alpha ``` ## Configure ```ts import { AskarModule, AskarMultiWalletDatabaseScheme } from '@credo-ts/askar' import { Agent, CacheModule, InMemoryLruCache } from '@credo-ts/core' import { DrizzleStorageModule } from '@credo-ts/drizzle-storage' import { tenantsBundle } from '@credo-ts/drizzle-storage/tenants' import { agentDependencies } from '@credo-ts/node' import { TenantInactiveError, TenantStatus, TenantsModule } from '@credo-ts/tenants' import { NativeAskar } from '@openwallet-foundation/askar-nodejs' import { drizzle } from 'drizzle-orm/libsql' // Run the migrations of the core and tenants bundles first const database = drizzle('file:agent.db') const agent = new Agent({ dependencies: agentDependencies, modules: { // The records of all tenants in one database storage: new DrizzleStorageModule({ database, bundles: [tenantsBundle] }), askar: new AskarModule({ askar: NativeAskar, enableStorage: false, store: { id: 'credo-keys', key: 'my-agent-store-key' }, // The keys of all tenants in one Askar store, with a profile per tenant multiWalletDatabaseScheme: AskarMultiWalletDatabaseScheme.ProfilePerWallet, }), tenants: new TenantsModule({ sessionLimit: 100, sessionAcquireTimeout: 5000 }), // The default cache throws as soon as a second agent context (a tenant) uses it cache: new CacheModule({ cache: new InMemoryLruCache({ limit: 5000 }) }), }, }) ``` The example stores the records of all tenants in one [Drizzle](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md) database and their keys in one Askar store. Add `tenantsBundle` to `bundles`, pass `--bundle tenants` when you run the migrations, and use PostgreSQL in production. Replace the default cache: it throws as soon as a tenant uses it. Use `InMemoryLruCache`, or `RedisCache` when several instances run the agent. See [Caching](https://credo.js.org/guides/next/agent-setup/caching.md). | `TenantsModule` option | Description | | --- | --- | | `sessionLimit` | Maximum number of tenant sessions open at the same time. Default `100`, which is low on purpose: set it based on your hardware and load. `Infinity` removes the limit. | | `sessionAcquireTimeout` | How long, in milliseconds, opening a session waits for a free slot when the limit is reached, before it throws `Failed to acquire an agent context session`. Default `1000`. | Tenant agents get the modules you register on the root agent. To type them, pass the modules to the generic, for example `new TenantsModule<{ openid4vc: OpenId4VcModule }>()`. ## Where tenant data is stored With Drizzle, all tenants share the one database, and each row is tagged with its tenant. Tenant keys are in Askar, where `multiWalletDatabaseScheme` decides where they go. Set it to `AskarMultiWalletDatabaseScheme.ProfilePerWallet`, as in the example: all tenants in one Askar store, with a profile each. ### With Askar storage When Askar also stores the records, `multiWalletDatabaseScheme` decides where all tenant data goes: | `multiWalletDatabaseScheme` | Tenant records and keys | | --- | --- | | `ProfilePerWallet` | A profile per tenant in the root store | | `DatabasePerWallet` (default) | A store per tenant, a SQLite file or a PostgreSQL database each, with a generated key that Credo saves in the tenant record | Use `ProfilePerWallet`. It keeps all tenants in one database, performs better with many tenants, and doesn't create a PostgreSQL database per tenant. Store-level tasks, such as rotating the store key or exporting the store, then cover all tenants at once. `DatabasePerWallet` is the default for backwards compatibility: use it only to back up or delete tenants one by one. :::warning[No SQLite `path` with `DatabasePerWallet`] Tenant stores reuse the root store's `database` config. With an explicit SQLite `path`, all tenants resolve to the root store's file, and `createTenant` fails. Leave `path` unset so each tenant gets its own file in the data directory. ::: ## Create a tenant ```ts const tenantRecord = await agent.modules.tenants.createTenant({ config: { label: 'Tenant 1' }, }) ``` `createTenant` saves a `TenantRecord` in the root agent's storage and creates the tenant's storage. The label is for your own use. Use the tenant record's `id` to open the tenant later. ## Use a tenant Work in a tenant through a tenant agent, which has the same API as the root agent, scoped to the tenant. `withTenantAgent` opens a session, runs your callback and ends the session, also when the callback throws: ```ts // Opens a session, runs the callback and always ends the session const did = await agent.modules.tenants.withTenantAgent({ tenantId: tenantRecord.id }, async (tenantAgent) => { const { didState } = await tenantAgent.dids.create({ method: 'key', options: { createKey: { type: { kty: 'OKP', crv: 'Ed25519' } } }, }) return didState.did }) ``` `getTenantAgent` returns the tenant agent without a callback. End the session yourself with `endSession()`: ```ts const tenantAgent = await agent.modules.tenants.getTenantAgent({ tenantId: tenantRecord.id }) try { const createdDids = await tenantAgent.dids.getCreatedDids() console.log(`Tenant has ${createdDids.length} DID(s)`) } finally { // Every session counts against the session limit until you end it await tenantAgent.endSession() } ``` Each open session takes a slot of `sessionLimit` until it ends, so a missing `endSession()` eventually blocks new sessions. Inbound DIDComm messages and OpenID4VC requests for a tenant open their own session. ## Manage tenants `agent.modules.tenants` has methods to look up and change tenants: | Method | What it does | | --- | --- | | `getTenantById(tenantId)`, `findTenantsByLabel(label)`, `findTenantsByQuery(query)`, `getAllTenants()` | Find tenant records. | | `updateTenant(tenantRecord)` | Saves changes to a tenant record. | | `updateTenantStatus(tenantId, status)` | Activates or deactivates a tenant. See [Tenant status](#tenant-status). | | `deleteTenantById(tenantId)` | Deletes the tenant record and the tenant's storage. | | `getTenantsWithOutdatedStorage()`, `updateTenantStorage({ tenantId })` | Update tenant storage after a Credo upgrade. With `autoUpdateStorageOnStartup`, Credo updates a tenant when it opens a session. | ```ts await agent.modules.tenants.deleteTenantById(tenantRecord.id) ``` ## Tenant status A tenant is active or inactive. New sessions can't be opened for an inactive tenant: `withTenantAgent` and `getTenantAgent` throw a `TenantInactiveError`, and inbound DIDComm messages and OpenID4VC requests for the tenant fail. Sessions that are already open keep working. Pass `allowInactive: true` to open a session anyway, for example for administration: ```ts await agent.modules.tenants.updateTenantStatus(tenantRecord.id, TenantStatus.Inactive) try { await agent.modules.tenants.withTenantAgent({ tenantId: tenantRecord.id }, async () => {}) } catch (error) { if (!(error instanceof TenantInactiveError)) throw error console.log(`Tenant ${error.tenantId} is inactive`) } // Admin tasks can still open a session for an inactive tenant await agent.modules.tenants.withTenantAgent({ tenantId: tenantRecord.id, allowInactive: true }, async (tenantAgent) => { console.log(`Inactive tenant still has ${(await tenantAgent.dids.getCreatedDids()).length} DID(s)`) }) ``` New tenants, and tenants created before the status existed, are active. With Drizzle, the status is a column that the `0001_add-tenant-status` migration of the tenants bundle adds. ## Route messages and requests to tenants Tenants share the root agent's endpoints, and Credo finds the tenant for each incoming message or request: - DIDComm: when a tenant creates routing keys, for example for a connection, Credo saves which tenant they belong to, and it routes each encrypted message by its recipient key. See [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md). - OpenID4VC: create issuers and verifiers with a tenant agent. Credo routes requests to their URLs to that tenant. See [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) and [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md). # Logging > Log what a Credo agent does with the built-in ConsoleLogger or your own logger, and choose the log level. A Credo agent logs nothing until you give it a logger. The agent and all its modules, including tenant agents, use that one logger. ## Console logger Pass a logger as `config.logger`: ```ts import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' const agent = new Agent({ config: { // Logs Info, Warn, Error and Fatal messages logger: new ConsoleLogger(LogLevel.Info), }, dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup }, }) ``` `ConsoleLogger` prints each message with its level as a prefix, such as `INFO: Creating agent with config`, using the console method that matches the level. Data passed with a message is printed as JSON. Errors in the data keep their message, stack and `cause` chain. ## Log levels A logger prints messages at its own level and above: | Level | What Credo logs at this level | | --- | --- | | `LogLevel.Test` | Nothing. Set it to print every message. | | `LogLevel.Trace` | The most detailed steps, such as key imports, and requests to ledgers and their results. | | `LogLevel.Debug` | Processing steps of protocols, including the content of DIDComm messages the agent sends. | | `LogLevel.Info` | Lifecycle events, such as the agent's config when it's created, storage updates, received DIDComm messages and created tenants. | | `LogLevel.Warn` | Problems the agent recovers from, such as a credential that breaks a recommendation of its specification. | | `LogLevel.Error` | Failed operations, such as a DIDComm message that can't be delivered. | | `LogLevel.Fatal` | A storage update that failed. | | `LogLevel.Off` | Nothing. The default. | Use `Info` or higher in production. At `Debug` and `Trace`, logs can contain message contents, credential data and other personal data. ## Your own logger To send logs to your own logging library, implement the `Logger` interface from `@credo-ts/core`: a `logLevel` property and the methods `test`, `trace`, `debug`, `info`, `warn`, `error` and `fatal`, each taking a `message` and optional `data`. Extending `BaseLogger` saves some work: it stores the level and has `isEnabled(level)`. This logger writes one JSON object per line: ```ts import { BaseLogger, LogLevel, replaceError } from '@credo-ts/core' type LogData = Record export class JsonLogger extends BaseLogger { private log(level: LogLevel, message: string, data?: LogData) { if (!this.isEnabled(level)) return // replaceError keeps the message and stack of errors, which JSON.stringify would drop console.log(JSON.stringify({ level: LogLevel[level], time: new Date().toISOString(), message, data }, replaceError)) } public test(message: string, data?: LogData) { this.log(LogLevel.Test, message, data) } public trace(message: string, data?: LogData) { this.log(LogLevel.Trace, message, data) } public debug(message: string, data?: LogData) { this.log(LogLevel.Debug, message, data) } public info(message: string, data?: LogData) { this.log(LogLevel.Info, message, data) } public warn(message: string, data?: LogData) { this.log(LogLevel.Warn, message, data) } public error(message: string, data?: LogData) { this.log(LogLevel.Error, message, data) } public fatal(message: string, data?: LogData) { this.log(LogLevel.Fatal, message, data) } } ``` Pass it as `config.logger`, for example `new JsonLogger(LogLevel.Info)`. # Protocols > How OpenID4VC, DIDComm and the ISO mdoc protocols differ, which credential formats each one carries, and which one to use. An exchange protocol defines how issuers, holders and verifiers talk to each other. Credo supports OpenID4VC, DIDComm and the ISO mdoc protocols. ## Which protocol should I use? | Use | When | | --- | --- | | [OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc.md) | By default, for new issuers, wallets and verifiers. It only needs HTTPS, carries SD-JWT VC and mdoc, and is what the EU Digital Identity Wallet and the HAIP profile use. | | [ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md) | A wallet presents mdocs, such as a mobile driving licence, in person to a reader over BLE or NFC. A wallet usually receives those mdocs with OpenID4VCI. | | [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md) | You join an ecosystem built on DIDComm and AnonCreds, or the parties need a lasting connection to exchange messages beyond a single issuance or presentation. | ## OpenID4VC [OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc.md) builds on OAuth 2.0 and plain HTTPS. OpenID4VCI covers issuance and OpenID4VP covers presentation. An issuer or verifier starts each exchange with a QR code or link, and the wallet completes it with a few HTTPS requests. Nothing persists between exchanges. This is the protocol family of the EU Digital Identity Wallet and its HAIP profile, and of mobile driving licences (mDL) presented online. ## DIDComm [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md) is a messaging protocol between agents that identify each other with DIDs. Two agents first set up a connection, usually from an out-of-band invitation. Over that connection either side can then offer credentials, request proofs or send other messages, for as long as the connection lasts. Mobile wallets can't accept incoming connections, so they receive messages through a mediator. DIDComm is used in ecosystems built on Hyperledger Aries and AnonCreds. ## ISO mdoc [ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md) covers the presentation protocols of ISO/IEC 18013-5 and 18013-7, which carry only mdocs, such as the mobile driving licence. A wallet presents in person to a reader over BLE or NFC (proximity), or to a website through the browser (ISO 18013-7 Annex C). Like OpenID4VC, each presentation stands alone. These protocols don't cover issuance. ## Digital Credentials API The W3C [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md) lets a website request credentials through the browser, which passes the request to a wallet. It's a channel for other protocols: Credo supports it with OpenID4VP and with ISO 18013-7 Annex C. ## Comparison | | OpenID4VC | DIDComm | ISO mdoc | | --- | --- | --- | --- | | Transport | HTTPS requests from the wallet to the issuer or verifier | Encrypted messages over HTTP or WebSocket, through a mediator for mobile wallets | BLE or NFC in person, or the browser with the Digital Credentials API | | Relationship | None: each exchange stands alone | A connection that both agents keep | None: each presentation stands alone | | Starts with | A credential offer or authorization request | An out-of-band invitation | A QR code or NFC tap, or a website calling the Digital Credentials API | | Issuer and verifier | Node.js | Node.js | No issuer. Verifier on Node.js | | Holder | Node.js and React Native | Node.js and React Native | Node.js and React Native | | Typical ecosystems | EU Digital Identity Wallet, HAIP, mDL | Aries and AnonCreds ecosystems | mDL and EU PID, in person and online | ## Credential formats | Format | OpenID4VC | DIDComm | ISO mdoc | | --- | --- | --- | --- | | SD-JWT VC | Yes | No | No | | mdoc | Yes | No | Yes | | W3C VC | Yes | Yes | No | | AnonCreds | No | Yes | No | Which W3C data models and proof types work depends on the protocol and on whether you issue or present. [Supported standards](https://credo.js.org/guides/next/supported-standards.md) has the full matrix. ## How these pages are organised - Protocol pages cover each role. For OpenID4VC these are [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md), [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md), [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md) and [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md). For DIDComm they're [Connections](https://credo.js.org/guides/next/protocols/didcomm/connections.md), [Issuing credentials](https://credo.js.org/guides/next/protocols/didcomm/issue-credential.md) and [Requesting proofs](https://credo.js.org/guides/next/protocols/didcomm/present-proof.md). The ISO mdoc pages cover [proximity presentation (ISO 18013-5)](https://credo.js.org/guides/next/protocols/mdoc/proximity.md). - Pages named "… with OpenID4VC" or "… with DIDComm", such as [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md) and [AnonCreds with DIDComm](https://credo.js.org/guides/next/protocols/didcomm/anoncreds.md), cover what's specific to one format on one protocol. - What you can do with a format on its own, such as signing, storing and verifying credentials or checking their status, is under [Credential formats](https://credo.js.org/guides/next/credential-formats.md). - [OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc.md): Issue, receive and verify credentials over HTTPS with OpenID4VCI and OpenID4VP, using the @credo-ts/openid4vc module. - [ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md): How the ISO/IEC 18013-5 and 18013-7 presentation protocols for mdocs work, how they differ from OpenID4VP, and what Credo does for proximity and the Digital Credentials API. - [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md): How the W3C Digital Credentials API passes credential requests from a website to a wallet, the two protocols Credo supports over it (OpenID4VP and ISO 18013-7 Annex C), platform support, and which protocols and options to choose. - [DIDComm](https://credo.js.org/guides/next/protocols/didcomm.md): Set up DIDComm messaging in Credo with the DidCommModule, choose protocols and transports, and listen for state changes. # OpenID4VC > Issue, receive and verify credentials over HTTPS with OpenID4VCI and OpenID4VP, using the @credo-ts/openid4vc module. OpenID for Verifiable Credentials (OpenID4VC) is a family of protocols, built on OAuth 2.0, for issuing and presenting credentials over HTTPS. This page explains the protocols and roles, and how to set up the `@credo-ts/openid4vc` module. The pages below it cover each role. ## Protocols | Specification | What it covers | | --- | --- | | OpenID4VCI | Issuance. A holder gets credentials from an issuer. The issuer authorizes it with a pre-authorized code in the offer, or with the OAuth authorization code flow. | | OpenID4VP | Presentation. A verifier sends an authorization request with a DCQL query, and the holder responds with presentations of matching credentials. | | HAIP | The High Assurance Interoperability Profile of both protocols, used by ecosystems such as the EU Digital Identity Wallet. It requires SD-JWT VC or mdoc. For issuance: the authorization code flow with PKCE, DPoP, and wallet authentication, usually with wallet attestations. For presentation: DCQL, requests signed with an X.509 certificate (`x509_hash`) and encrypted responses. [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md) explains the issuance part, [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md) the presentation part. | There is no connection between the parties. Each exchange is a separate session, started by a credential offer or an authorization request that the holder receives as a QR code or link. ```mermaid sequenceDiagram participant Issuer participant Holder participant Verifier Issuer->>Holder: credential offer (QR code or link) Holder->>Issuer: OpenID4VCI token and credential requests Issuer-->>Holder: credentials Verifier->>Holder: authorization request (QR code or link) Holder->>Verifier: OpenID4VP authorization response with presentations ``` [Supported standards](https://credo.js.org/guides/next/supported-standards.md) lists the versions and drafts Credo supports, and which credential formats work with each protocol. ## Roles | Role | API | Platform | Guide | | --- | --- | --- | --- | | Issuer | `agent.openid4vc.issuer` | Node.js | [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) | | Holder | `agent.openid4vc.holder` | Node.js and React Native | [Receiving](https://credo.js.org/guides/next/protocols/openid4vc/receive.md) and [presenting](https://credo.js.org/guides/next/protocols/openid4vc/present.md) credentials | | Verifier | `agent.openid4vc.verifier` | Node.js | [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md) | Issuers and verifiers serve HTTP endpoints with Express, so they run on Node.js. On React Native only the holder is available. ## Install ```bash npm install @credo-ts/openid4vc@alpha ``` For an issuer or verifier, also install Express 5: ```bash npm install express@^5 ``` ```bash npm install --save-dev @types/express@^5 ``` ## Configure the module An agent that issues and verifies credentials: ```ts import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcModule } from '@credo-ts/openid4vc' import express from 'express' const app = express() const agent = new Agent({ dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup // The key must be `openid4vc` for the module to be available as agent.openid4vc openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: 'https://example.com/oid4vci', credentialRequestToCredentialMapper, }, verifier: { baseUrl: 'https://example.com/oid4vp', }, }), }, }) await agent.initialize() // Credo adds its routes to the app, you start the server. Make https://example.com // reach this server, for example through a reverse proxy. const server = app.listen(4022) ``` Register the module under the key `openid4vc`, so it's available as `agent.openid4vc`. `issuer` and `verifier` are optional and turn on that role. The holder is always available, so a wallet registers the module without options: ```ts openid4vc: new OpenId4VcModule(), ``` The module stores issuers, verifiers and sessions as records. With [Drizzle storage](https://credo.js.org/guides/next/agent-setup/storage/drizzle.md), add `openid4vcBundle` from `@credo-ts/drizzle-storage/openid4vc` to the bundles. `baseUrl` is the public URL of the endpoints, and Credo serves them under its path (`/oid4vci` and `/oid4vp` here). OpenID4VCI also requires the issuer metadata at the root of the host (`/.well-known/openid-credential-issuer/oid4vci/`), so your server must pass requests for the whole host to the Express app. `app` is your Express app. Without it, Credo creates one, available as `agent.openid4vc.config.app`. Either way, you start the server. Credo adds its routes during `agent.initialize()`, so register your own middleware and routes after that. The issuer and verifier support [multi-tenancy](https://credo.js.org/guides/next/agent-setup/multi-tenancy.md): Credo finds the tenant from the issuer or verifier id in the URL. For local development over `http://localhost`, set `allowInsecureHttpUrls: true` in the agent config of all parties. Never enable it in production. ## The example flow The pages for each role build one example: an issuer that issues an employee badge as an SD-JWT VC with the pre-authorized code flow, a holder that receives and presents it, and a verifier that requests two of its claims. This runs them in one process: ```ts import { holder, presentCredential, receiveCredential } from './openid4vc-holder.js' import { createEmployeeBadgeOffer, issuer, issuerServer } from './openid4vc-issuer.js' import { createEmployeeBadgeRequest, getEmployeeBadgeClaims, verifier, verifierServer } from './openid4vc-verifier.js' // Issuer: create an offer for a known user const { credentialOffer, txCode } = await createEmployeeBadgeOffer('employee-123') // Holder: resolve the offer, enter the code, store the credential await receiveCredential(credentialOffer, txCode) // Verifier: request given_name and department const { authorizationRequest, verificationSessionId } = await createEmployeeBadgeRequest() // Holder: resolve the request and share the credential await presentCredential(authorizationRequest) // Verifier: read the verified claims console.log(await getEmployeeBadgeClaims(verificationSessionId)) ``` # Issuing credentials > Run an OpenID4VCI issuer with Credo, define credential configurations, create credentials for incoming requests and create credential offers. This page covers issuing credentials with OpenID4VCI from a Node.js agent: the issuer metadata, the callback that creates each credential, and credential offers with the pre-authorized code flow. The examples issue an SD-JWT VC. ## How issuance works ```mermaid sequenceDiagram participant App as Your app participant Issuer as Credo issuer participant Holder App->>Issuer: createCredentialOffer() Issuer-->>App: offer URI and tx code App->>Holder: QR code or link (tx code by email) Holder->>Issuer: fetch offer and issuer metadata Holder->>Issuer: token request with pre-authorized code and tx code Holder->>Issuer: credential request with proof of possession Issuer->>App: credentialRequestToCredentialMapper() Issuer-->>Holder: signed credential ``` Your application decides who gets which credential and creates the offer. Credo hosts the OpenID4VCI endpoints, checks the holder's requests and signs the credentials your callback returns. ## Choose a credential format | Format | Use when | | --- | --- | | [SD-JWT VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md) | By default. JSON claims with selective disclosure, required by HAIP and used for the EU PID. | | [mdoc](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md) | The credential follows an ISO document type, such as the mobile driving licence, or holders must also present it in person over BLE or NFC. | | [W3C VC](https://credo.js.org/guides/next/protocols/openid4vc/w3c.md) | Your ecosystem requires the W3C data model. Use `jwt_vc_json` or VCDM 2.0 with SD-JWT. | You can offer the same credential in several formats, each as its own credential configuration. The format pages cover the configuration and signing for each. ## Set up the issuer Register `OpenId4VcModule` with an `issuer` configuration. [OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc.md) explains the module options. ```ts import { Agent, ClaimFormat, Kms, type SdJwtVcSignOptions } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcIssuanceSessionState, type OpenId4VcIssuanceSessionStateChangedEvent, OpenId4VcIssuerEvents, OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapperOptions, type OpenId4VciSignSdJwtCredentials, OpenId4VcModule, } from '@credo-ts/openid4vc' import express from 'express' const issuerPort = 4020 const app = express() export const issuer = new Agent({ config: { // Allows the http://localhost URLs of this example. Never enable this in production. allowInsecureHttpUrls: true, }, dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: `http://localhost:${issuerPort}/oid4vci`, credentialRequestToCredentialMapper, }, }), }, }) await issuer.initialize() export const issuerServer = app.listen(issuerPort) ``` `baseUrl` is the public URL of the issuer endpoints. Each issuer you create gets the credential issuer identifier `/`. `credentialRequestToCredentialMapper` creates the credentials when a holder requests them, see [Create the credentials](#create-the-credentials). ## Create a signing key and certificate The issuer signs credentials with a key from the agent's KMS. For SD-JWT VC and mdoc it includes an X.509 certificate for that key, so holders and verifiers can check who signed. ```ts const issuerKey = await issuer.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) // Self-signed for this example. In production, a CA issues the certificate for your key. export const issuerCertificate = await issuer.x509.createCertificate({ authorityKey: Kms.PublicJwk.fromPublicJwk(issuerKey.publicJwk), issuer: { commonName: 'Example Corp', countryName: 'NL' }, }) ``` The example uses a self-signed certificate. In production a certificate authority issues it, and holders and verifiers trust that CA. See [X.509 certificates](https://credo.js.org/guides/next/dids-and-trust/x509.md). If you load a certificate instead of creating it, set `certificate.keyId` to the KMS key id of its key so Credo can sign with it. To sign with a DID instead, use `issuer: { method: 'did', didUrl }` in the callback. ## Create the issuer An issuer record holds the metadata that holders fetch from `/.well-known/openid-credential-issuer`: the issuer's display name and the credentials it offers. One agent can host several issuers. ```ts const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ display: [{ name: 'Example Corp', locale: 'en' }], credentialConfigurationsSupported: { 'employee-badge': { format: OpenId4VciCredentialFormatProfile.SdJwtDc, vct: 'urn:example:employee-badge:1', cryptographic_binding_methods_supported: ['jwk'], credential_signing_alg_values_supported: [Kms.KnownJwaSignatureAlgorithms.ES256], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [Kms.KnownJwaSignatureAlgorithms.ES256] }, }, credential_metadata: { display: [{ name: 'Employee badge', locale: 'en', background_color: '#12107c', text_color: '#ffffff' }], claims: [ { path: ['given_name'], display: [{ name: 'Given name', locale: 'en' }] }, { path: ['family_name'], display: [{ name: 'Family name', locale: 'en' }] }, { path: ['department'], display: [{ name: 'Department', locale: 'en' }] }, ], }, }, }, }) ``` Each key of `credentialConfigurationsSupported` is a credential configuration id. You use these ids when you create offers. For each configuration: - Set `proof_types_supported`. Credo only issues credentials bound to a holder key, so it always asks for a proof of possession, and a Credo holder rejects configurations without this field. - Put display information under `credential_metadata`, as OpenID4VCI 1.0 defines. Wallets that implement draft 15 read `display` and `claims` at the top level of the configuration, so add them there as well if you need to support those wallets. Create the issuer once, for example when you deploy, and keep its `issuerId`. Credo generates a UUID unless you pass one. Change the metadata later with `updateIssuerMetadata`. For batch issuance, set `batchCredentialIssuance: { batchSize }` on the issuer. A holder can then request up to that many instances of a credential in one request, each bound to a different key, so verifiers can't link its presentations to each other. ## Create the credentials Credo checks each credential request (the access token and the holder's proof of possession of its keys) and then calls your `credentialRequestToCredentialMapper` with, among others: - `issuanceSession`: the session created with the offer, including the `issuanceMetadata` you passed. - `credentialConfigurationId` and `credentialConfiguration`: the configuration the holder requests. - `holderBinding`: the verified keys or DIDs to bind the credentials to. ```ts // Stands in for your own user database const employees = new Map([['employee-123', { given_name: 'Jane', family_name: 'Doe', department: 'Engineering' }]]) async function credentialRequestToCredentialMapper({ credentialConfiguration, holderBinding, issuanceSession, }: OpenId4VciCredentialRequestToCredentialMapperOptions): Promise { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.SdJwtDc) { throw new Error(`Unsupported credential format '${credentialConfiguration.format}'`) } // Set as issuanceMetadata when creating the offer const employeeId = issuanceSession.issuanceMetadata?.employeeId const employee = typeof employeeId === 'string' ? employees.get(employeeId) : undefined if (!employee) throw new Error('Unknown employee') return { type: 'credentials', format: ClaimFormat.SdJwtDc, // One credential per key the wallet wants the credential bound to credentials: holderBinding.keys.map( (holder): SdJwtVcSignOptions => ({ payload: { vct: credentialConfiguration.vct, ...employee }, holder, issuer: { method: 'x5c', x5c: [issuerCertificate] }, disclosureFrame: { _sd: ['given_name', 'family_name', 'department'] }, }) ), } } ``` Return exactly one credential for each entry in `holderBinding.keys`. With batch issuance there are several entries, and Credo rejects the result if the counts differ. The `format` you return decides how Credo signs: `ClaimFormat.SdJwtDc` for SD-JWT VC, `ClaimFormat.MsoMdoc` for mdoc, or one of the W3C formats. To issue the credential later, return a deferral instead (see [Deferred issuance](#deferred-issuance)). ## Create a credential offer An offer tells the holder which credentials it can get and how to get authorization. Pick the flow: - **Pre-authorized code** (this page): your application has already identified the user, for example after a login on your website, and the offer itself carries the authorization. Use it by default. - **Authorization code**: the holder sends the user to an authorization server to log in, or presents a credential such as a PID first. Use it when the user isn't known yet, or when your ecosystem follows HAIP. See [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md). ```ts export async function createEmployeeBadgeOffer(employeeId: string) { const { credentialOffer, issuanceSession } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['employee-badge'], preAuthorizedCodeFlowConfig: { txCode: { input_mode: 'numeric', length: 6, description: 'Enter the code from the email we sent you' }, }, issuanceMetadata: { employeeId }, }) // Show credentialOffer as a QR code or link. Send the code (userPin) to the user over another channel. return { credentialOffer, txCode: issuanceSession.userPin } } ``` `credentialOffer` is a URI such as `openid-credential-offer://?credential_offer_uri=...`. Show it as a QR code when the wallet runs on another device, or as a link on the same device. `baseUri` changes the `openid-credential-offer://` prefix, for example to a custom scheme or an HTTPS link. `txCode` makes the wallet ask the user for a transaction code. Credo generates the code and stores it as `issuanceSession.userPin` (4 digits unless you set `length`). Send it over a different channel than the offer, such as email or SMS, so that someone who intercepts the offer can't redeem it. The holder must finish the whole issuance before the offer expires: 3 minutes by default. Change it per offer with `expirationInSeconds`, or for all offers with `statefulCredentialOfferExpirationInSeconds` in the issuer configuration. ## Track issuance sessions Each offer creates an issuance session. Listen for state changes to update your application: ```ts issuer.events.on( OpenId4VcIssuerEvents.IssuanceSessionStateChanged, ({ payload: { issuanceSession } }) => { if (issuanceSession.state === OpenId4VcIssuanceSessionState.Completed) { console.log(`Issuance session ${issuanceSession.id} completed`) } else if (issuanceSession.state === OpenId4VcIssuanceSessionState.Error) { console.error(`Issuance session ${issuanceSession.id} failed: ${issuanceSession.errorMessage}`) } } ) ``` In the pre-authorized code flow a session goes through `OfferCreated`, `OfferUriRetrieved`, `AccessTokenRequested`, `AccessTokenCreated`, `CredentialRequestReceived` and `Completed`. Other states: | State | When | | --- | --- | | `AuthorizationInitiated`, `AuthorizationGranted` | Authorization code flow with Credo as the authorization server. | | `CredentialsPartiallyIssued` | The offer has several credential configurations and the holder hasn't requested all of them yet. | | `Error` | The session failed or expired. `errorMessage` says why. | To read a session at any time, call `agent.openid4vc.issuer.getIssuanceSessionById(id)`. ## Deferred issuance If a credential isn't ready when the holder asks for it, for example because it needs manual approval, return `{ type: 'deferral', transactionId, interval }` from the mapper. The holder retries at the deferred credential endpoint after `interval` seconds. Credo then calls `deferredCredentialRequestToCredentialMapper` from the issuer configuration, which returns the credentials or another deferral. The session stays valid for `interval` plus `deferralIntervalGracePeriodInSeconds` (7 days by default). ## Next steps - [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md): the holder side of this flow. - [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md): claims, selective disclosure and type metadata. - [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md): the authorization code flow, PKCE, DPoP and wallet attestations. # Receiving credentials > Receive credentials with OpenID4VCI in a Credo wallet on Node.js or React Native, bind them to the wallet's keys and store them. This page covers the holder side of OpenID4VCI: resolving a credential offer, getting an access token, requesting the credentials and storing them. The holder API is the same on Node.js and React Native. The examples continue the SD-JWT VC flow from [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md). To present the credentials afterwards, see [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md). ## Set up the holder Register `OpenId4VcModule` without options. The holder part is always available as `agent.openid4vc.holder`. **Node.js** ```ts import { Agent, Kms, MdocRecord, SdJwtVcRecord, W3cCredentialRecord, X509Module } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { type OpenId4VciCredentialBindingResolver, OpenId4VcModule, preAuthorizedCodeGrantIdentifier, } from '@credo-ts/openid4vc' import { issuerCertificate } from './openid4vc-issuer.js' import { verifierCertificate } from './openid4vc-verifier.js' export const holder = new Agent({ config: { // Allows the http://localhost URLs of this example. Never enable this in production. allowInsecureHttpUrls: true, }, dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup // Certificates (or root CAs) of the issuers and verifiers you trust x509: new X509Module({ trustedCertificates: [issuerCertificate, verifierCertificate] }), openid4vc: new OpenId4VcModule(), }, }) await holder.initialize() ``` **React Native** ```ts import { AskarModule } from '@credo-ts/askar' import { Agent, X509Module } from '@credo-ts/core' import { OpenId4VcModule } from '@credo-ts/openid4vc' import { agentDependencies } from '@credo-ts/react-native' import { NativeAskar } from '@openwallet-foundation/askar-react-native' // PEM certificates (or root CAs) of the issuers and verifiers the wallet trusts, bundled with the app const trustedCertificates = ['-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'] export const holder = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, // In a real wallet, derive the key from a secret in the device's secure storage store: { id: 'holder', key: 'holder-key' }, }), x509: new X509Module({ trustedCertificates }), openid4vc: new OpenId4VcModule(), }, }) await holder.initialize() ``` The holder verifies the credentials it receives, and later the requests of verifiers. To verify X.509 signatures, it needs the certificates, or root CAs, of the issuers and verifiers you trust. The Node.js example trusts the certificates from the [issuer](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) and [verifier](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md) examples. See [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). For storage and keys, see [Storage](https://credo.js.org/guides/next/agent-setup/storage.md) and [Key management](https://credo.js.org/guides/next/agent-setup/key-management.md). The React Native example uses [Askar](https://credo.js.org/guides/next/agent-setup/storage/askar.md), the default for mobile wallets. ## Resolve the offer The wallet gets a credential offer URI from a QR code or a link. `resolveCredentialOffer` fetches the offer and the issuer metadata: ```ts const resolvedCredentialOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) // What to show the user: the issuer, the offered credentials and whether a code is needed const issuerName = resolvedCredentialOffer.metadata.credentialIssuer.display?.[0]?.name const offeredCredentials = Object.keys(resolvedCredentialOffer.offeredCredentialConfigurations) const txCodeRequest = resolvedCredentialOffer.credentialOfferPayload.grants?.[preAuthorizedCodeGrantIdentifier]?.tx_code ``` Use the issuer metadata and `offeredCredentialConfigurations` to show the user who offers what. Display names, colors and claim labels are under `credential_metadata` of each configuration, or at its top level for issuers that implement draft 15. If `txCodeRequest` is defined, the issuer sent the user a code (by email, for example), and `description`, `length` and `input_mode` tell you what to ask for. ## Request an access token In the pre-authorized code flow, exchange the code from the offer for an access token, with the transaction code the user entered: ```ts // txCode is the code the user received from the issuer, if txCodeRequest is defined const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer, txCode }) ``` If the issuer supports DPoP, Credo creates the DPoP proof and returns its options in `token.dpop`. Pass them to the next calls. PKCE and DPoP need nothing else from you. Issuers that follow HAIP also ask for wallet attestations and key attestations, which your wallet gets from its wallet provider's backend. [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md) covers those and offers that use the authorization code flow. ## Request the credentials The issuer binds each credential to a key the holder controls. Credo calls your `credentialBindingResolver` once for each offered credential configuration, with what the issuer supports, and you return the keys to bind to: ```ts const credentialBindingResolver: OpenId4VciCredentialBindingResolver = async ({ supportsJwk, proofTypes }) => { // The algorithms the issuer accepts for this credential that the wallet's KMS supports const algorithm = proofTypes.jwt?.supportedSignatureAlgorithms[0] if (!supportsJwk || !algorithm) throw new Error('The issuer does not support jwk binding with a jwt proof') const key = await holder.kms.createKeyForSignatureAlgorithm({ algorithm }) return { method: 'jwk', keys: [Kms.PublicJwk.fromPublicJwk(key.publicJwk)] } } ``` The resolver returns one of: | Binding | Use for | | --- | --- | | `{ method: 'jwk', keys }` | SD-JWT VC and mdoc. Keys from the agent's KMS. This is the default choice, and what HAIP requires. | | `{ method: 'did', didUrls }` | W3C formats, which can't bind to a bare key. Verification methods of DIDs the agent created, when the issuer lists the DID method in `supportedDidMethods` or accepts all (`supportsAllDidMethods`). Not allowed for mdoc. | | `{ method: 'attestation', keyAttestationJwt }` | Issuers that require key attestations. See [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md#wallet-and-key-attestations). | To receive a batch, return up to `issuerMaxBatchSize` keys of the same key type, and you get one credential instance per key. Then request the credentials: ```ts const { credentials, deferredCredentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer, accessToken: token.accessToken, cNonce: token.cNonce, dpop: token.dpop, credentialBindingResolver, }) ``` By default this requests every credential in the offer. Pass `credentialConfigurationIds` to request only some of them. ## Store the credentials `requestCredentials` verifies each credential, including the issuer's signature and trust, but it doesn't store anything. Each result has a `record` that you store with the API for its format: ```ts // requestCredentials verifies the credentials, but doesn't store them for (const { record } of credentials) { if (record instanceof SdJwtVcRecord) await holder.sdJwtVc.store({ record }) else if (record instanceof MdocRecord) await holder.mdoc.store({ record }) else if (record instanceof W3cCredentialRecord) await holder.w3cCredentials.store({ record }) else await holder.w3cV2Credentials.store({ record }) } ``` A batch arrives as one record with several instances in `record.credentialInstances`. If the issuer defers issuance, the credential is in `deferredCredentials` instead: call `requestDeferredCredentials` with its `transactionId` after `interval` seconds. If a credential has a `notificationId`, tell the issuer whether you stored it with `sendNotification` (`credential_accepted`, `credential_deleted` or `credential_failure`). ## Next steps - [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md): present the stored credentials to a verifier. - [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md): offers with the authorization code flow, and wallet and key attestations. - [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md) and [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md): format details for wallets. - [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md): decide which issuers your wallet accepts. # Issuance authorization and wallet security > Choose an OpenID4VCI authorization flow, use Credo's authorization server, a chained or an external one, presentation during issuance, PKCE, DPoP, and wallet and key attestations. This page is about issuance (OpenID4VCI): how an issuer decides who gets a credential beyond the pre-authorized code, and the protections that profiles such as HAIP ask for when a wallet requests credentials: PKCE, DPoP, wallet attestations and key attestations. Each section has an issuer part and a holder part. For the verifier side of security, such as signed requests and encrypted responses, see [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md). [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) and [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md) explain the basics. ## Choose an authorization flow | Flow | Who authenticates the user | Use when | Configure with | | --- | --- | --- | --- | | Pre-authorized code | Your application, before it creates the offer | You already know the user, for example after a login on your website. | `preAuthorizedCodeFlowConfig`, see [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md) | | Presentation during issuance | Credo, which asks the holder to present a credential with OpenID4VP | The user proves who they are with a credential they already have, such as a PID. | `requirePresentationDuringIssuance` and `getVerificationSession` | | Chained authorization server | Your identity provider, with Credo's authorization server in between | Users log in with your existing OpenID Connect provider. | `authorizationServerConfigs` with `type: 'chained'` | | External authorization server | Your OAuth 2.0 authorization server, which holders use directly | You run an authorization server that already supports OpenID4VCI. | `authorizationServerConfigs` with `type: 'direct'` | Start with the pre-authorized code. HAIP requires the authorization code flow, which the other three use. When users have to log in, prefer a chained authorization server over an external one: Credo's authorization server then still handles PAR, PKCE, DPoP and wallet attestations, which an external server has to implement itself. Every credential configuration you offer with the authorization code flow needs a `scope`. Credo's built-in authorization server handles the first three flows, and requires pushed authorization requests (PAR). ## Security features | Feature | Protects against | HAIP | Credo issuer default | | --- | --- | --- | --- | | PKCE | Another app redeeming an intercepted authorization code | Required, with `S256` | Required | | DPoP | Someone else using a stolen access token. DPoP binds the token to a key of the holder. | Required | Off | | Wallet attestation | Wallet apps you don't trust getting credentials. The wallet authenticates with an attestation from its wallet provider. | Issuers must authenticate wallets, usually with wallet attestations | Off | | Key attestation | Credentials bound to keys without the protection you need, for example outside a secure element | Wallets must support them | Off, set per credential configuration | For an issuer: keep PKCE on and turn on DPoP. Require wallet and key attestations only when the wallets you issue to get them from their wallet provider, as in the EU Digital Identity Wallet ecosystem. Wallets without them can't get your credentials. For a wallet: the Credo holder uses PKCE and DPoP automatically when the issuer supports them. Wallet and key attestations need a backend from your wallet provider, see [Wallet and key attestations](#wallet-and-key-attestations). ### Module and session options Each requirement has a module option that applies to all issuance sessions, and an option per session: in `authorization` when you create an offer, or in the result of `getDynamicIssuanceSession` for wallet-initiated issuance. | Module option | Per session | Default | | --- | --- | --- | | `pkceRequired` | `requirePkce` | `true` | | `dpopRequired` | `requireDpop` | `false` | | `walletAttestationsRequired` | `requireWalletAttestation` | `false` | If you leave the per-session option out, the module option applies. `false` turns the requirement off for that session, even when the module option requires it. These options apply to Credo's authorization server. An external authorization server enforces its own rules. Credo only checks at the credential endpoint that the access token is bound with DPoP when the offer sets `requireDpop: true`. ## Presentation during issuance The issuer asks the holder to present a credential, for example a PID, before it issues a new one. Credo creates the presentation request, verifies the response and only then gives the holder an authorization code: ```mermaid sequenceDiagram participant Holder participant Issuer as Credo issuer Holder->>Issuer: authorization challenge request (scope, PKCE, DPoP) Issuer-->>Holder: OpenID4VP request from getVerificationSession Holder->>Issuer: OpenID4VP response Holder->>Issuer: authorization challenge request with the presentation Issuer-->>Holder: authorization code Holder->>Issuer: token request and credential request ``` ### Issuer The issuer agent also needs the verifier part of the module, and a `getVerificationSession` callback: :::issuer ```ts const issuer = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...issuerStorage, // Storage and key management, see Agent setup openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper, // Creates the OpenID4VP request for presentation during issuance getVerificationSession, // Require DPoP for all issuance sessions. PKCE is required by default. dpopRequired: true, }, // Presentation during issuance uses the verifier of the same agent verifier: { baseUrl: `${issuerUrl}/oid4vp` }, }), }, }) ``` ::: The callback creates the OpenID4VP request when a holder starts the authorization. `scopes` are the requested scopes that match the offered credentials. Return the request together with the scopes it grants. The request needs a verifier record, here one with the same id as the issuer: :::issuer ```ts const getVerificationSession: OpenId4VciGetVerificationSession = async ({ agentContext, issuanceSession, scopes }) => { if (!scopes.includes('diploma')) throw new Error(`Unsupported scopes ${scopes.join(', ')}`) // Resolve the verifier from the agent context, so this also works for tenants const verifier = agentContext.resolve(OpenId4VcVerifierApi) const authorizationRequest = await verifier.createAuthorizationRequest({ // A verifier with the same id as the issuer (created next to the issuer) verifierId: issuanceSession.issuerId, requestSigner: { method: 'x5c', x5c: [issuerCertificate] }, dcql: { query: { credentials: [ { id: 'pid', format: 'dc+sd-jwt', meta: { vct_values: ['urn:example:pid:1'] }, claims: [{ path: ['given_name'] }, { path: ['family_name'] }], }, ], }, }, }) // The scopes that are granted once the presentation is verified return { ...authorizationRequest, scopes: ['diploma'] } } ``` ::: Create the issuer with a `scope` per credential configuration, and an offer that requires the presentation. Leave `authorizationServerUrl` out (or set it to the issuer itself), as only Credo's authorization server handles this flow: :::issuer ```ts const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-issuer', // Advertise DPoP support in the authorization server metadata dpopSigningAlgValuesSupported: [KnownJwaSignatureAlgorithms.ES256], credentialConfigurationsSupported: { diploma: { format: OpenId4VciCredentialFormatProfile.SdJwtDc, vct: 'urn:example:diploma:1', // Required for the authorization code flow scope: 'diploma', cryptographic_binding_methods_supported: ['jwk'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256] }, }, }, }, }) await issuer.openid4vc.verifier.createVerifier({ verifierId: issuerRecord.issuerId }) const { credentialOffer } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['diploma'], authorizationCodeFlowConfig: { requirePresentationDuringIssuance: true }, }) ``` ::: The mapper receives the verified presentation in `verification`: :::issuer ```ts const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, verification, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.SdJwtDc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } if (holderBinding.bindingMethod !== 'jwk') throw new Error('Expected jwk holder binding') // The verified presentation from the authorization step const [pid] = verification?.dcql?.presentations.pid ?? [] if (pid?.claimFormat !== ClaimFormat.SdJwtDc) throw new Error('Expected a verified PID presentation') return { type: 'credentials', format: ClaimFormat.SdJwtDc, credentials: holderBinding.keys.map((holder) => ({ holder, issuer: { method: 'x5c', x5c: [issuerCertificate], issuer: issuerUrl }, payload: { vct: credentialConfiguration.vct, name: `${pid.prettyClaims.given_name} ${pid.prettyClaims.family_name}`, degree: 'Bachelor of Science', }, disclosureFrame: { _sd: ['name', 'degree'] }, })), } satisfies OpenId4VciSignSdJwtCredentials } ``` ::: ### Holder :::holder ```ts const clientId = 'example-wallet' const redirectUri = 'https://wallet.example.com/redirect' const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const authorization = await holder.openid4vc.holder.resolveOpenId4VciAuthorizationRequest(resolvedOffer, { clientId, redirectUri, scope: getScopesFromCredentialConfigurationsSupported(resolvedOffer.offeredCredentialConfigurations), }) if (authorization.authorizationFlow !== OpenId4VciAuthorizationFlow.PresentationDuringIssuance) { throw new Error('Expected presentation during issuance') } // Present the PID with OpenID4VP const presentationRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest( authorization.openid4vpRequestUrl ) if (!presentationRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const presentation = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: presentationRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(presentationRequest.dcql.queryResult) }, }) if (!presentation.ok) throw new Error('Presentation was rejected') // Exchange the presentation for an authorization code const { authorizationCode, dpop } = await holder.openid4vc.holder.retrieveAuthorizationCodeUsingPresentation({ resolvedCredentialOffer: resolvedOffer, authSession: authorization.authSession, presentationDuringIssuanceSession: presentation.presentationDuringIssuanceSession, dpop: authorization.dpop, }) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer, code: authorizationCode, clientId, redirectUri, // PKCE: proves this is the client that started the authorization codeVerifier: authorization.codeVerifier, // Keep using the same DPoP key dpop, }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, clientId, credentialBindingResolver: async () => { const key = await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) ``` ::: `resolveOpenId4VciAuthorizationRequest` starts the authorization. `authorizationFlow` tells you whether the issuer wants a presentation or a browser redirect ([below](#external-authorization-servers)). Present the credential like any other OpenID4VP request: the result has `presentationDuringIssuanceSession`, which proves the presentation to the issuer. Pass `codeVerifier` (PKCE) and the `dpop` from the previous call to the token request. ## External authorization servers ### Issuer: chained With a chained authorization server, holders talk to Credo's authorization server, which sends the user to your identity provider to log in. PAR, PKCE, DPoP and wallet attestations work as with Credo alone: :::issuer ```ts // Wallets use Credo's authorization server, which sends the user to your identity provider const chainedIssuer = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'employee-chained', credentialConfigurationsSupported, authorizationServerConfigs: [ { type: 'chained', issuer: 'https://idp.example.com', // Credo's client at the identity provider. Register // https://issuer.example.com/oid4vci/employee-chained/redirect as its redirect URI. clientAuthentication: { type: 'clientSecret', clientId: 'credo-issuer', clientSecret: 'secret' }, // Credential scope -> scopes to request from the identity provider scopesMapping: { employee: ['openid', 'profile'] }, }, ], }) await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: chainedIssuer.issuerId, credentialConfigurationIds: ['employee'], // Selects the chained authorization server authorizationCodeFlowConfig: { authorizationServerUrl: 'https://idp.example.com' }, }) ``` ::: - Credo exchanges the code at your identity provider with `clientAuthentication`. Register `//redirect` as the redirect URI of that client. - `scopesMapping` maps each credential scope to the scopes Credo requests from your identity provider. To decide per session instead, use the `getChainedAuthorizationRequestParameters` callback in the issuer module config. - Set `redirectUris` to the redirect URIs of the wallets you accept. Without it, Credo accepts any redirect URI. In the mapper, the token response of your identity provider, including an `id_token`, is in `issuanceSession.chainedIdentity.externalAccessTokenResponse`. A chained flow can't be combined with presentation during issuance. ### Issuer: direct With a direct authorization server, Credo lists it in the issuer metadata, and holders use it directly: :::issuer ```ts // Wallets use your authorization server directly const directIssuer = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'employee-direct', credentialConfigurationsSupported, authorizationServerConfigs: [ { type: 'direct', issuer: 'https://auth.example.com', // Only needed if Credo has to introspect the access tokens clientAuthentication: { clientId: 'credo-issuer', clientSecret: 'secret' }, }, ], }) await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: directIssuer.issuerId, credentialConfigurationIds: ['employee'], authorizationCodeFlowConfig: { authorizationServerUrl: 'https://auth.example.com' }, }) ``` ::: Your authorization server must link the authorization to the offer through `issuer_state`, and handle PAR, PKCE, DPoP and wallet attestations itself if you need them. Credo verifies its access tokens at the credential endpoint, and only needs `clientAuthentication` for token introspection. For issuance without an offer, where the access token isn't linked to an issuance session, use `getDynamicIssuanceSession`. ### Holder With both kinds, the holder opens the authorization URL in a browser and receives the code on its redirect URI: :::holder ```ts /** * `openAuthorizationUrl` opens the URL in a browser and resolves with the URL the * authorization server redirected to, for example using an in-app browser session. */ export async function receiveWithRedirect( credentialOffer: string, openAuthorizationUrl: (authorizationRequestUrl: string) => Promise ) { const clientId = 'example-wallet' const redirectUri = 'https://wallet.example.com/redirect' const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const authorization = await holder.openid4vc.holder.resolveOpenId4VciAuthorizationRequest(resolvedOffer, { clientId, redirectUri, scope: getScopesFromCredentialConfigurationsSupported(resolvedOffer.offeredCredentialConfigurations), }) if (authorization.authorizationFlow !== OpenId4VciAuthorizationFlow.Oauth2Redirect) { throw new Error('Expected a redirect to the authorization server') } const authorizationResponseRedirectUrl = await openAuthorizationUrl(authorization.authorizationRequestUrl) const { authorizationCode } = holder.openid4vc.holder.parseAuthorizationCodeFromAuthorizationResponse({ authorizationResponseRedirectUrl, resolvedCredentialOffer: resolvedOffer, }) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer, code: authorizationCode, clientId, redirectUri, codeVerifier: authorization.codeVerifier, // Bound to the DPoP key used for the pushed authorization request, if any dpop: authorization.dpop, }) return holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, clientId, credentialBindingResolver: async () => { const key = await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) } ``` ::: The authorization request goes through PAR when the authorization server supports it. When the server supports PKCE, Credo adds it and returns the `codeVerifier` for the token request. ## PKCE and DPoP ### Issuer PKCE ties the authorization code to the holder that started the authorization. Credo's authorization server requires it by default (`pkceRequired`), and only supports the `S256` method. When a holder sends a code challenge, Credo always checks the code verifier, also for sessions that don't require PKCE. DPoP binds the access token to a key of the holder. Enable it on the issuer record with `dpopSigningAlgValuesSupported`, as in the [presentation during issuance](#presentation-during-issuance) example: Credo only advertises DPoP when it's set. Require it with `dpopRequired` or `requireDpop`. When a holder uses DPoP in its first request, Credo requires it for the rest of that session. ### Holder Credo creates the PKCE code challenge when the authorization server supports PKCE, and returns `codeVerifier`. Pass it to `requestToken`. Credo uses DPoP when the authorization server supports it. Each call returns the `dpop` options for the next call, as in the examples above. Spreading the token response into `requestCredentials` passes them on. ## Wallet and key attestations A wallet attestation is a JWT from the wallet provider (the organization behind the wallet app) that says the wallet is a genuine instance of its app. The wallet uses it to authenticate to the authorization server ([OAuth 2.0 Attestation-Based Client Authentication](https://datatracker.ietf.org/doc/draft-ietf-oauth-attestation-based-client-auth/)). A key attestation lists keys of the wallet and how they're protected, such as in a secure element, with levels from ISO/IEC 18045 (`iso_18045_high`, `iso_18045_moderate`, `iso_18045_enhanced-basic`, `iso_18045_basic`). The issuer can require it before it binds credentials to those keys. Both come from the wallet provider's backend. Credo doesn't create them. ### Issuer Trust the wallet provider, for example with `getTrustedIssuersForVerification` in the agent config: :::issuer ```ts // Trust the wallet provider for wallet attestations and key attestations const getTrustedIssuersForVerification: GetTrustedIssuersForVerification< VerificationSigner, OpenId4VcVerificationTypes > = async (_agentContext, { verification }) => { if (verification.type === 'oauth2ClientAttestation' || verification.type === 'openId4VciKeyAttestation') { return { trustedIssuers: [{ method: 'x509', issuance: [walletProviderCertificate.toString('pem')] }] } } // Use the other trust configuration for everything else return undefined } ``` ::: Credo checks attestations signed with an X.509 certificate against your trust configuration, and accepts DID-signed attestations unless you return a list of trusted DIDs. See [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). :::warning Your trust configuration only applies to attestations signed with an `x5c` certificate chain or a DID. Credo accepts an attestation signed with a bare JWK (a `jwk` header) from anyone whose signature verifies, so it proves nothing about the wallet provider. ::: Then require wallet attestations, advertise attestation-based client authentication (`clientAttestationSigningAlgValuesSupported` and `clientAttestationPopSigningAlgValuesSupported`), and set the key attestation requirements: :::issuer ```ts config: { allowInsecureHttpUrls: true, getTrustedIssuersForVerification }, dependencies: agentDependencies, modules: { ...issuerStorage, // Storage and key management, see Agent setup openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper, // Every issuance session requires a wallet attestation walletAttestationsRequired: true, }, }), }, ``` ```ts const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-issuer', // Advertise attestation based client authentication clientAttestationSigningAlgValuesSupported: [KnownJwaSignatureAlgorithms.ES256], clientAttestationPopSigningAlgValuesSupported: [KnownJwaSignatureAlgorithms.ES256], batchCredentialIssuance: { batchSize: 2 }, credentialConfigurationsSupported: { membership: { format: OpenId4VciCredentialFormatProfile.MsoMdoc, doctype: 'org.example.membership.1', cryptographic_binding_methods_supported: ['cose_key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256], // The holder keys must be attested at this level key_attestations_required: { key_storage: [OpenId4VciKeyAttestationLevel.High], user_authentication: [OpenId4VciKeyAttestationLevel.High], }, }, }, }, }, }) ``` ::: `key_attestations_required` on a proof type requires a key attestation with at least these levels. A higher level satisfies a lower one. Add an `attestation` proof type as well if wallets may send the key attestation as the proof itself. By default Credo verifies a wallet attestation whenever a wallet sends one, and then requires it for the rest of the session. `ignoreWalletAttestationsWhenNotRequired` ignores attestations for sessions that don't require them. `clientAttestationPopChallengeRequired` makes wallets fetch a challenge for the proof of possession first. The allowed clock skew for wallet attestations and DPoP proofs is the agent's `validitySkewSeconds`. The mapper gets the attested keys in `holderBinding.keys`, and the verified key attestation in `holderBinding.keyAttestation`: :::issuer ```ts const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.MsoMdoc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } if (holderBinding.bindingMethod !== 'jwk') throw new Error('Expected jwk holder binding') // Credo verified the key attestation, its trust and the required levels console.log('Attested key storage', holderBinding.keyAttestation?.payload.key_storage) return { type: 'credentials', format: ClaimFormat.MsoMdoc, // One credential per attested key credentials: holderBinding.keys.map(({ jwk }) => ({ docType: credentialConfiguration.doctype, holderKey: jwk, issuerCertificate, validityInfo: { validUntil: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) }, namespaces: { 'org.example.membership.1': { member_id: 'M-1234', issuing_country: 'NL' } }, })), } satisfies OpenId4VciSignMdocCredentials } ``` ::: ### Holder :::holder ```ts const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) // Credo signs the attestation proof of possession with the key in the attestation's `cnf` const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer, walletAttestationJwt, }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, clientId, credentialBindingResolver: ({ proofTypes }) => { if (!proofTypes.jwt?.keyAttestationsRequired) throw new Error('Expected key attestations to be required') // Without a nonce, Credo sends a jwt proof signed with the first attested key return { method: 'attestation', keyAttestationJwt } }, }) ``` ::: Pass `walletAttestationJwt` to every call to the authorization server: `resolveOpenId4VciAuthorizationRequest`, `retrieveAuthorizationCodeUsingPresentation`, `requestToken` and `refreshToken`. Credo signs the proof of possession with the key in the attestation's `cnf`, so that key must be in the wallet's KMS. When the issuer requires key attestations, `proofTypes.jwt.keyAttestationsRequired` (or `proofTypes.attestation`) says so, and the binding resolver must return `{ method: 'attestation', keyAttestationJwt }`. Binding to a DID isn't possible then. The attested keys must be in the wallet's KMS, with their KMS key id as `kid`, and you get one credential instance per attested key. Without a `nonce` in the key attestation, Credo sends a `jwt` proof signed with the first attested key. With the issuer's `cNonce` (passed to the resolver) as `nonce`, it sends the key attestation as an `attestation` proof. ## Full example Presentation during issuance with PKCE and DPoP, in one process:
openid4vc-auth.ts ```ts import { Agent, ClaimFormat, SdJwtVcRecord, type X509Certificate } from '@credo-ts/core' import { KnownJwaSignatureAlgorithms, PublicJwk } from '@credo-ts/core/kms' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { getScopesFromCredentialConfigurationsSupported, OpenId4VciAuthorizationFlow, OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapper, type OpenId4VciGetVerificationSession, type OpenId4VciSignSdJwtCredentials, OpenId4VcModule, OpenId4VcVerifierApi, } from '@credo-ts/openid4vc' import express from 'express' import { exampleStorage } from './example-storage.js' const issuerUrl = 'http://localhost:4030' // In-memory storage and keys, so the example leaves nothing behind const issuerStorage = await exampleStorage([openid4vcBundle]) const holderStorage = await exampleStorage([openid4vcBundle]) // Created once the issuer agent is initialized (see below) let issuerCertificate: X509Certificate const getVerificationSession: OpenId4VciGetVerificationSession = async ({ agentContext, issuanceSession, scopes }) => { if (!scopes.includes('diploma')) throw new Error(`Unsupported scopes ${scopes.join(', ')}`) // Resolve the verifier from the agent context, so this also works for tenants const verifier = agentContext.resolve(OpenId4VcVerifierApi) const authorizationRequest = await verifier.createAuthorizationRequest({ // A verifier with the same id as the issuer (created next to the issuer) verifierId: issuanceSession.issuerId, requestSigner: { method: 'x5c', x5c: [issuerCertificate] }, dcql: { query: { credentials: [ { id: 'pid', format: 'dc+sd-jwt', meta: { vct_values: ['urn:example:pid:1'] }, claims: [{ path: ['given_name'] }, { path: ['family_name'] }], }, ], }, }, }) // The scopes that are granted once the presentation is verified return { ...authorizationRequest, scopes: ['diploma'] } } const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, verification, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.SdJwtDc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } if (holderBinding.bindingMethod !== 'jwk') throw new Error('Expected jwk holder binding') // The verified presentation from the authorization step const [pid] = verification?.dcql?.presentations.pid ?? [] if (pid?.claimFormat !== ClaimFormat.SdJwtDc) throw new Error('Expected a verified PID presentation') return { type: 'credentials', format: ClaimFormat.SdJwtDc, credentials: holderBinding.keys.map((holder) => ({ holder, issuer: { method: 'x5c', x5c: [issuerCertificate], issuer: issuerUrl }, payload: { vct: credentialConfiguration.vct, name: `${pid.prettyClaims.given_name} ${pid.prettyClaims.family_name}`, degree: 'Bachelor of Science', }, disclosureFrame: { _sd: ['name', 'degree'] }, })), } satisfies OpenId4VciSignSdJwtCredentials } const app = express() const issuer = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...issuerStorage, // Storage and key management, see Agent setup openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper, // Creates the OpenID4VP request for presentation during issuance getVerificationSession, // Require DPoP for all issuance sessions. PKCE is required by default. dpopRequired: true, }, // Presentation during issuance uses the verifier of the same agent verifier: { baseUrl: `${issuerUrl}/oid4vp` }, }), }, }) const holder = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...holderStorage, openid4vc: new OpenId4VcModule(), }, }) await issuer.initialize() await holder.initialize() const server = app.listen(4030) // Self-signed certificate for the example. The SAN must match the host of `issuerUrl`. issuerCertificate = await issuer.x509.createCertificate({ authorityKey: PublicJwk.fromPublicJwk((await issuer.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk), issuer: { commonName: 'Credo example issuer', countryName: 'NL' }, extensions: { subjectAlternativeName: { name: [{ type: 'dns', value: 'localhost' }] } }, }) issuer.x509.config.addTrustedCertificate(issuerCertificate) holder.x509.config.addTrustedCertificate(issuerCertificate) // The holder already has a PID, which it presents to get the diploma const pidKey = await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) const pid = await issuer.sdJwtVc.sign({ issuer: { method: 'x5c', x5c: [issuerCertificate], issuer: issuerUrl }, holder: { method: 'jwk', jwk: PublicJwk.fromPublicJwk(pidKey.publicJwk) }, payload: { vct: 'urn:example:pid:1', given_name: 'Erika', family_name: 'Mustermann' }, disclosureFrame: { _sd: ['given_name', 'family_name'] }, }) await holder.sdJwtVc.store({ record: new SdJwtVcRecord({ credentialInstances: [{ compactSdJwtVc: pid.compact, kmsKeyId: pidKey.keyId }] }), }) const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-issuer', // Advertise DPoP support in the authorization server metadata dpopSigningAlgValuesSupported: [KnownJwaSignatureAlgorithms.ES256], credentialConfigurationsSupported: { diploma: { format: OpenId4VciCredentialFormatProfile.SdJwtDc, vct: 'urn:example:diploma:1', // Required for the authorization code flow scope: 'diploma', cryptographic_binding_methods_supported: ['jwk'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256] }, }, }, }, }) await issuer.openid4vc.verifier.createVerifier({ verifierId: issuerRecord.issuerId }) const { credentialOffer } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['diploma'], authorizationCodeFlowConfig: { requirePresentationDuringIssuance: true }, }) const clientId = 'example-wallet' const redirectUri = 'https://wallet.example.com/redirect' const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const authorization = await holder.openid4vc.holder.resolveOpenId4VciAuthorizationRequest(resolvedOffer, { clientId, redirectUri, scope: getScopesFromCredentialConfigurationsSupported(resolvedOffer.offeredCredentialConfigurations), }) if (authorization.authorizationFlow !== OpenId4VciAuthorizationFlow.PresentationDuringIssuance) { throw new Error('Expected presentation during issuance') } // Present the PID with OpenID4VP const presentationRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest( authorization.openid4vpRequestUrl ) if (!presentationRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const presentation = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: presentationRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(presentationRequest.dcql.queryResult) }, }) if (!presentation.ok) throw new Error('Presentation was rejected') // Exchange the presentation for an authorization code const { authorizationCode, dpop } = await holder.openid4vc.holder.retrieveAuthorizationCodeUsingPresentation({ resolvedCredentialOffer: resolvedOffer, authSession: authorization.authSession, presentationDuringIssuanceSession: presentation.presentationDuringIssuanceSession, dpop: authorization.dpop, }) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer, code: authorizationCode, clientId, redirectUri, // PKCE: proves this is the client that started the authorization codeVerifier: authorization.codeVerifier, // Keep using the same DPoP key dpop, }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, clientId, credentialBindingResolver: async () => { const key = await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) for (const { record } of credentials) { if (!(record instanceof SdJwtVcRecord)) throw new Error('Expected an SD-JWT VC') await holder.sdJwtVc.store({ record }) console.log('Received diploma', record.firstCredential.prettyClaims.name, 'with DPoP', token.dpop !== undefined) } server.close() await issuer.shutdown() await holder.shutdown() ```
Wallet and key attestations, with a stand-in for the wallet provider:
openid4vc-auth-attestations.ts ```ts import { Agent, ClaimFormat, type GetTrustedIssuersForVerification, JwsService, JwtPayload, MdocRecord, type VerificationSigner, type X509Certificate, } from '@credo-ts/core' import { KnownJwaSignatureAlgorithms, PublicJwk } from '@credo-ts/core/kms' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapper, OpenId4VciKeyAttestationLevel, type OpenId4VciSignMdocCredentials, OpenId4VcModule, type OpenId4VcVerificationTypes, } from '@credo-ts/openid4vc' import express from 'express' import { exampleStorage } from './example-storage.js' const issuerUrl = 'http://localhost:4039' // In-memory storage and keys, so the example leaves nothing behind const holderStorage = await exampleStorage([openid4vcBundle]) const issuerStorage = await exampleStorage([openid4vcBundle]) // Created once the agents are initialized (see below) let issuerCertificate: X509Certificate let walletProviderCertificate: X509Certificate // Trust the wallet provider for wallet attestations and key attestations const getTrustedIssuersForVerification: GetTrustedIssuersForVerification< VerificationSigner, OpenId4VcVerificationTypes > = async (_agentContext, { verification }) => { if (verification.type === 'oauth2ClientAttestation' || verification.type === 'openId4VciKeyAttestation') { return { trustedIssuers: [{ method: 'x509', issuance: [walletProviderCertificate.toString('pem')] }] } } // Use the other trust configuration for everything else return undefined } const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.MsoMdoc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } if (holderBinding.bindingMethod !== 'jwk') throw new Error('Expected jwk holder binding') // Credo verified the key attestation, its trust and the required levels console.log('Attested key storage', holderBinding.keyAttestation?.payload.key_storage) return { type: 'credentials', format: ClaimFormat.MsoMdoc, // One credential per attested key credentials: holderBinding.keys.map(({ jwk }) => ({ docType: credentialConfiguration.doctype, holderKey: jwk, issuerCertificate, validityInfo: { validUntil: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) }, namespaces: { 'org.example.membership.1': { member_id: 'M-1234', issuing_country: 'NL' } }, })), } satisfies OpenId4VciSignMdocCredentials } const app = express() const issuer = new Agent({ config: { allowInsecureHttpUrls: true, getTrustedIssuersForVerification }, dependencies: agentDependencies, modules: { ...issuerStorage, // Storage and key management, see Agent setup openid4vc: new OpenId4VcModule({ app, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper, // Every issuance session requires a wallet attestation walletAttestationsRequired: true, }, }), }, }) const holder = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...holderStorage, openid4vc: new OpenId4VcModule(), }, }) await issuer.initialize() await holder.initialize() const server = app.listen(4039) issuerCertificate = await issuer.x509.createCertificate({ authorityKey: PublicJwk.fromPublicJwk((await issuer.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk), issuer: { commonName: 'Credo example issuer', countryName: 'NL' }, }) holder.x509.config.addTrustedCertificate(issuerCertificate) // Stand-in for the wallet provider's backend, which attests genuine wallet instances and their keys. // It is not part of Credo. The key lives in the holder agent to keep the example short. walletProviderCertificate = await holder.x509.createCertificate({ authorityKey: PublicJwk.fromPublicJwk((await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk), issuer: { commonName: 'Example wallet provider', countryName: 'NL' }, }) function signByWalletProvider(typ: string, payload: Record) { return holder.context.resolve(JwsService).createJwsCompact(holder.context, { keyId: walletProviderCertificate.publicJwk.keyId, payload: JwtPayload.fromJson({ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, ...payload, }), protectedHeaderOptions: { alg: KnownJwaSignatureAlgorithms.ES256, typ, x5c: [walletProviderCertificate.toString('base64')], }, }) } const clientId = 'example-wallet' const walletInstanceKey = await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) const walletAttestationJwt = await signByWalletProvider('oauth-client-attestation+jwt', { iss: 'https://wallet-provider.example.com', sub: clientId, cnf: { jwk: PublicJwk.fromPublicJwk(walletInstanceKey.publicJwk).toJson() }, wallet_name: 'Example wallet', }) const attestedKeys = await Promise.all( [1, 2].map(async () => PublicJwk.fromPublicJwk((await holder.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk) ) ) const keyAttestationJwt = await signByWalletProvider('key-attestation+jwt', { attested_keys: attestedKeys.map((key) => key.toJson()), key_storage: [OpenId4VciKeyAttestationLevel.High], user_authentication: [OpenId4VciKeyAttestationLevel.High], }) const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-issuer', // Advertise attestation based client authentication clientAttestationSigningAlgValuesSupported: [KnownJwaSignatureAlgorithms.ES256], clientAttestationPopSigningAlgValuesSupported: [KnownJwaSignatureAlgorithms.ES256], batchCredentialIssuance: { batchSize: 2 }, credentialConfigurationsSupported: { membership: { format: OpenId4VciCredentialFormatProfile.MsoMdoc, doctype: 'org.example.membership.1', cryptographic_binding_methods_supported: ['cose_key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256], // The holder keys must be attested at this level key_attestations_required: { key_storage: [OpenId4VciKeyAttestationLevel.High], user_authentication: [OpenId4VciKeyAttestationLevel.High], }, }, }, }, }, }) const { credentialOffer } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['membership'], preAuthorizedCodeFlowConfig: {}, }) const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) // Credo signs the attestation proof of possession with the key in the attestation's `cnf` const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer, walletAttestationJwt, }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, clientId, credentialBindingResolver: ({ proofTypes }) => { if (!proofTypes.jwt?.keyAttestationsRequired) throw new Error('Expected key attestations to be required') // Without a nonce, Credo sends a jwt proof signed with the first attested key return { method: 'attestation', keyAttestationJwt } }, }) for (const { record } of credentials) { if (!(record instanceof MdocRecord)) throw new Error('Expected an mdoc') await holder.mdoc.store({ record }) console.log('Received', record.credentialInstances.length, 'mdoc instances') } server.close() await issuer.shutdown() await holder.shutdown() ```
# Verifying credentials > Request and verify credential presentations with OpenID4VP and DCQL from a Credo verifier on Node.js. This page covers verifying credentials with OpenID4VP from a Node.js agent: creating an authorization request with a DCQL query, choosing how the wallet responds and how the verifier identifies itself, and reading the verified claims. The examples request the SD-JWT VC issued in [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md). ## Set up the verifier Register `OpenId4VcModule` with a `verifier` configuration. [OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc.md) explains the module options. ```ts import { Agent, ClaimFormat, Kms, X509Module } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcModule, OpenId4VcVerificationSessionState, type OpenId4VcVerificationSessionStateChangedEvent, OpenId4VcVerifierEvents, } from '@credo-ts/openid4vc' import express from 'express' import { issuerCertificate } from './openid4vc-issuer.js' const verifierPort = 4021 const app = express() export const verifier = new Agent({ config: { // Allows the http://localhost URLs of this example. Never enable this in production. allowInsecureHttpUrls: true, }, dependencies: agentDependencies, modules: { ...storage, // Storage and key management, see Agent setup // Certificates (or root CAs) of the credential issuers you accept x509: new X509Module({ trustedCertificates: [issuerCertificate] }), openid4vc: new OpenId4VcModule({ app, verifier: { baseUrl: `http://localhost:${verifierPort}/oid4vp` }, }), }, }) await verifier.initialize() export const verifierServer = app.listen(verifierPort) ``` - `baseUrl` is the public URL of the verifier endpoints. Wallets fetch requests from it and post their responses to it. - The verifier only accepts credentials from issuers it trusts. For X.509-signed credentials it needs the issuer certificates or their root CAs, here `issuerCertificate` from [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md). Credentials signed with a DID are accepted from any DID unless you restrict them. See [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). ## Create a verifier A verifier record gives the verifier an id, used in its URLs, and the client metadata that wallets show to the user, such as `client_name` and `logo_uri`. Create it once and keep its `verifierId`. One agent can host several verifiers. The verifier also needs a way to identify itself to wallets. The example signs its requests with a self-signed certificate: ```ts const verifierRecord = await verifier.openid4vc.verifier.createVerifier({ clientMetadata: { client_name: 'Example Verifier' }, }) const verifierKey = await verifier.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } }) // Self-signed for this example. With x509_san_dns, the host of the verifier's // baseUrl must be a DNS name in the certificate's subject alternative names. export const verifierCertificate = await verifier.x509.createCertificate({ authorityKey: Kms.PublicJwk.fromPublicJwk(verifierKey.publicJwk), issuer: { commonName: 'Example Verifier', countryName: 'NL' }, extensions: { subjectAlternativeName: { name: [{ type: 'dns', value: 'localhost' }] } }, }) ``` In production, use a certificate from a CA that wallets trust. [Client identifiers](#client-identifiers) explains the options. ## Create an authorization request An authorization request describes the credentials and claims you want in a DCQL query: ```ts export async function createEmployeeBadgeRequest() { const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'x5c', x5c: [verifierCertificate] }, dcql: { query: { credentials: [ { id: 'employee-badge', format: 'dc+sd-jwt', meta: { vct_values: ['urn:example:employee-badge:1'] }, claims: [{ path: ['given_name'] }, { path: ['department'] }], }, ], }, }, }) // Show authorizationRequest as a QR code or link, and keep the session id to look up the result return { authorizationRequest, verificationSessionId: verificationSession.id } } ``` Each credential query has an `id`, which you use to find the presentation in the result. `meta.vct_values` limits the query to SD-JWT VCs of that type, and `claims` lists the claims the wallet must disclose. [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md), [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md) and [W3C VCs with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/w3c.md) show the queries for each format. `authorizationRequest` is a URI such as `openid4vp://?client_id=...&request_uri=...`. Show it as a QR code when the wallet runs on another device, or as a link on the same device. For a same-device flow, also set `authorizationResponseRedirectUri`: the wallet opens this URL after it submits the response, so the user returns to your website or app. Include enough randomness in the URL (at least 128 bits) that nobody can guess it. The request expires after 5 minutes. Change it per request with `expirationInSeconds`, or for all requests with `authorizationRequestExpirationInSeconds` in the verifier configuration. ### Version and query language Use the default `version`, `v1` (OpenID4VP 1.0), with DCQL. Set `version` to `v1.draft24` or `v1.draft21` only for wallets that don't support OpenID4VP 1.0 yet. Only those drafts accept a DIF Presentation Exchange definition (`presentationExchange`) instead of DCQL, and Credo throws if you combine draft 21 with DCQL, transaction data or `clientIdPrefix: 'x509_hash'`. ### Response modes `responseMode` sets how the wallet returns the response: | Response mode | How | Use | | --- | --- | --- | | `direct_post.jwt` (default) | The wallet encrypts the response to a key Credo generates for each request and posts it to the verifier. | By default. HAIP requires it. | | `direct_post` | The wallet posts the response without encryption. | Only for wallets that can't encrypt. With drafts 21 and 24, mdoc requests need `direct_post.jwt`. | | `dc_api.jwt`, `dc_api` | The request and response go through the browser's [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api/verifier.md). | For requests from a website. | With `direct_post` and `direct_post.jwt`, Credo receives and verifies the response at its endpoint. You don't have to call anything. ### Client identifiers The `requestSigner` decides how the verifier identifies itself, and which client identifier prefix the request uses: | `requestSigner` | Client identifier | What you need | | --- | --- | --- | | `{ method: 'x5c', x5c }` | `x509_san_dns:` (default for `x5c`) | A certificate with the host of `baseUrl` as a DNS subject alternative name. | | `{ method: 'x5c', x5c, clientIdPrefix: 'x509_hash' }` | `x509_hash:` | A certificate. Its names don't have to match your host. | | `{ method: 'did', didUrl }` | `decentralized_identifier:` | A DID created by the agent, with `didUrl` pointing to the signing key. | | `{ method: 'none' }` | `redirect_uri:` | Nothing. The request isn't signed. | In production, sign requests with an X.509 certificate from a CA that wallets trust, so wallets can show the user who is asking. Use `x509_hash` for wallets that follow HAIP, which requires it, and `x509_san_dns` for wallets that only support that prefix. Use `did` only in ecosystems where wallets trust verifier DIDs. Unsigned requests (`none`) let anyone pretend to be your verifier, and HAIP doesn't allow them. Use them only for development. For `x5c`, `baseUrl` must use HTTPS. Put the certificate of the signing key first, followed by any intermediate certificates. The first certificate needs the KMS key id of its key: `agent.x509.createCertificate` sets it, and for a certificate you load yourself, set `certificate.keyId`. Credo doesn't support the `verifier_attestation`, `openid_federation`, `pre-registered` and `https` prefixes. ## Track verification sessions Each request creates a verification session: ```ts verifier.events.on( OpenId4VcVerifierEvents.VerificationSessionStateChanged, ({ payload: { verificationSession } }) => { if (verificationSession.state === OpenId4VcVerificationSessionState.ResponseVerified) { console.log(`Verification session ${verificationSession.id} verified`) } else if (verificationSession.state === OpenId4VcVerificationSessionState.Error) { console.error(`Verification session ${verificationSession.id} failed: ${verificationSession.errorMessage}`) } } ) ``` | State | When | | --- | --- | | `RequestCreated` | You created the request. | | `RequestUriRetrieved` | The wallet fetched the request. | | `ResponseVerified` | The wallet responded and all presentations passed verification. | | `Error` | Verification failed, or the user declined and the wallet sent an error response. `errorMessage` says why. | A verifier website usually shows the QR code and polls its backend, which checks the session with `getVerificationSessionById`. ## Read the verified result When the session reaches `ResponseVerified`, Credo has already checked the signatures, the holder binding, the nonce and audience, and the issuer's trust. Get the presentations: ```ts export async function getEmployeeBadgeClaims(verificationSessionId: string) { // Throws unless the session is in state ResponseVerified const { dcql } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSessionId) const [presentation] = dcql?.presentations['employee-badge'] ?? [] if (presentation?.claimFormat !== ClaimFormat.SdJwtDc) throw new Error('Expected an SD-JWT VC presentation') // Only the claims the holder disclosed, plus the always-visible ones like vct and iat return presentation.prettyClaims } ``` `dcql.presentations` has an entry per credential query id, with one presentation per credential the wallet shared (more than one only if the query sets `multiple: true`). For SD-JWT VC, `prettyClaims` holds the disclosed claims; the format pages show how to read the other formats. `dcql.presentationResult` is the DCQL result for the whole query, for example to see which `credential_sets` were satisfied. ## Next steps - [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md): the holder side of this flow. - [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api/verifier.md): request credentials through the browser. - [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md): decide which issuers you accept. # Presenting credentials > Present stored credentials to a verifier with OpenID4VP from a Credo wallet on Node.js or React Native, with DCQL or Presentation Exchange. This page covers the holder side of OpenID4VP: resolving a verifier's authorization request, showing the user what it asks for, and sending the response. It uses the holder agent from [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md#set-up-the-holder), which also trusts the verifier's certificate, and the SD-JWT VC it received there. The verifier side is in [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md). ## Resolve the authorization request A verifier sends an authorization request as a QR code or link (`openid4vp://...`). `resolveOpenId4VpAuthorizationRequest` fetches the request, verifies its signature and finds the stored credentials that match its DCQL query: ```ts const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) // Who is asking, e.g. prefix 'x509_san_dns' and client id 'x509_san_dns:localhost' const { clientIdPrefix, effectiveClientId } = resolvedRequest.verifier const queryResult = resolvedRequest.dcql?.queryResult if (!queryResult?.can_be_satisfied) throw new Error('Your stored credentials do not match the request') // For each credential query: the matching credentials and the claims they would disclose for (const [queryId, match] of Object.entries(queryResult.credential_matches)) { if (!match.success) continue for (const { record, claims } of match.valid_credentials) { console.log(queryId, record.type, claims.valid_claim_sets[0].output) } } ``` `resolvedRequest.verifier` tells you who is asking. With `x509_san_dns` or `x509_hash` the request was signed with a certificate that the holder trusts, and resolving throws if it doesn't. With `decentralized_identifier` it was signed with a DID. With `redirect_uri` the request isn't signed, so anyone could have created it: show the user that the verifier is unverified, or reject such requests. `queryResult.can_be_satisfied` is `false` when the holder doesn't have the credentials the verifier asks for. Otherwise `credential_matches` lists, for each credential query, the matching credentials and the claims each would disclose. Use it to show the user what they're about to share. ## Submit the response After the user agrees, select the credentials and send the response: ```ts // Picks the first matching credential for each query. Ask the user for consent before you submit. const credentials = holder.openid4vc.holder.selectCredentialsForDcqlRequest(queryResult) const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials }, }) if (!result.ok) throw new Error(`The verifier rejected the response: ${JSON.stringify(result.serverResponse.body)}`) ``` `selectCredentialsForDcqlRequest` picks the first matching credential for each query and discloses only the requested claims. For credentials received in a batch it uses an unused instance while there is one (`useMode`, default `NewOrFirst`). Credo posts the response to the verifier's response endpoint. If the verifier rejects it, the call doesn't throw: `result.ok` is `false` and `result.serverResponse` holds the verifier's answer. `result.redirectUri` is set when the verifier wants the user to continue on its website or app. Open it in the browser. Verifiers that use an OpenID4VP draft may send a DIF Presentation Exchange definition instead of a DCQL query. Then `resolvedRequest.presentationExchange` is set instead of `dcql`: select with `selectCredentialsForPresentationExchangeRequest` and pass `presentationExchange: { credentials }` when accepting. For requests that include transaction data, see [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md#sign-transaction-data). For requests from a website through the browser, see [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api/wallet.md). ## Next steps - [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md) and [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md): format details, such as transaction data. - [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api/wallet.md): answer requests that a website sends through the browser. - [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md): decide which verifiers your wallet accepts. # SD-JWT VC with OpenID4VC > Issue, receive and verify SD-JWT VCs over OpenID4VCI and OpenID4VP with Credo, including selective disclosure with DCQL and transaction data. This page covers what is specific to SD-JWT VC in OpenID4VC, in the order of the flow. Issuance covers the issuer's credential configuration and signing, and what the holder stores. Presentation covers the verifier's DCQL query, transaction data and the result. [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md), [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md), [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md) and [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md) explain the flow itself, and [SD-JWT VC](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md) explains the format. Use SD-JWT VC by default. HAIP requires SD-JWT VC or mdoc, and the EU PID uses SD-JWT VC. ## Issuance ### Configure the credential Add a `dc+sd-jwt` entry to the `credentialConfigurationsSupported` of the issuer: :::issuer ```ts const credentialConfigurationsSupported = { 'identity-sd-jwt': { format: OpenId4VciCredentialFormatProfile.SdJwtDc, vct: 'urn:example:identity:1', scope: 'identity', cryptographic_binding_methods_supported: ['jwk'], credential_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256] }, }, credential_metadata: { display: [{ name: 'Identity card', locale: 'en', background_color: '#1d3557', text_color: '#ffffff' }], claims: [ { path: ['given_name'], display: [{ name: 'Given name', locale: 'en' }] }, { path: ['family_name'], display: [{ name: 'Family name', locale: 'en' }] }, { path: ['birthdate'], display: [{ name: 'Date of birth', locale: 'en' }] }, { path: ['address', 'country'], display: [{ name: 'Country', locale: 'en' }] }, ], }, }, } satisfies Record ``` ::: - `vct` is the credential type. Verifiers request credentials by it, and Credo checks that every credential the mapper returns has this `vct`. If it's an HTTPS URL, a Credo holder fetches the type metadata from it when it receives the credential and keeps it on the record (`record.typeMetadata`). - `cryptographic_binding_methods_supported` decides what the holder binds the credential to: `jwk` for a key (the default choice, and what HAIP requires), or DID methods such as `did:key`. `credential_metadata` holds what wallets show: the card in `display` and a label per claim in `claims`, where `path` points into the credential. Add a `scope` if you offer the credential through the [authorization code flow](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md). Wallets that implement older drafts use the format `vc+sd-jwt`. Credo accepts it as SD-JWT VC when the configuration has a `vct`, and signs the credential with `vc+sd-jwt` as `typ`. Without a `vct`, `vc+sd-jwt` means a W3C VCDM 2.0 credential, see [W3C VCs with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/w3c.md). ### Sign the credential The mapper returns the SD-JWT VC sign options, one entry for each holder key: :::issuer ```ts const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, issuanceSession, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.SdJwtDc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } if (holderBinding.bindingMethod !== 'jwk') throw new Error('Only jwk holder binding is supported') // Look up the data for this user, e.g. based on the issuance metadata of the offer const { userId } = issuanceSession.issuanceMetadata ?? {} if (userId !== 'user-123') throw new Error('Unknown user') return { type: 'credentials', format: ClaimFormat.SdJwtDc, // One credential per holder key (more than one with batch issuance) credentials: holderBinding.keys.map((holder) => ({ holder, issuer: { method: 'x5c', x5c: [issuerCertificate], issuer: issuerUrl }, payload: { vct: credentialConfiguration.vct, given_name: 'Erika', family_name: 'Mustermann', birthdate: '1984-01-26', address: { street_address: 'Heidestraße 17', locality: 'Köln', country: 'DE' }, }, disclosureFrame: { _sd: ['given_name', 'family_name', 'birthdate'], address: { _sd: ['street_address', 'locality', 'country'] }, }, })), } satisfies OpenId4VciSignSdJwtCredentials } ``` ::: - `payload.vct` must be the `vct` of the configuration. Credo adds `iat`, and sets `iss` and `cnf` from `issuer` and `holder`. - `issuer` signs with an X.509 certificate chain (`x5c`, leaf first) or a DID (`{ method: 'did', didUrl }`, for a DID created in this agent). The optional `issuer` URL becomes the `iss` claim and must match a DNS or URI subject alternative name of the leaf certificate. - `disclosureFrame` lists in `_sd` the claims the holder can disclose one by one. Claims that aren't listed are always visible. [SD-JWT VC](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md) explains nested frames and decoys. For revocation, add a `status` claim to the payload. See [Status lists](https://credo.js.org/guides/next/credential-formats/status-lists.md). ### Trust the issuer The holder verifies each credential when it receives it, and the verifier verifies it again in the presentation. For an X.509-signed credential, both need the issuer certificate or its root CA: ```ts // Both the holder and the verifier only accept credentials from issuers they trust holder.x509.config.addTrustedCertificate(issuerCertificate) verifier.x509.config.addTrustedCertificate(issuerCertificate) ``` DID-signed credentials are accepted from any DID unless you configure trusted issuers. [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md) covers both, and how to decide per verification. ### Receive and store The holder side is the same as for other formats. Only the record type differs: :::holder ```ts const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, credentialBindingResolver: async ({ proofTypes }) => { const algorithm = proofTypes.jwt?.supportedSignatureAlgorithms[0] if (!algorithm) throw new Error('No supported proof of possession algorithm') const key = await holder.kms.createKeyForSignatureAlgorithm({ algorithm }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) for (const { record } of credentials) { if (!(record instanceof SdJwtVcRecord)) throw new Error('Expected an SD-JWT VC') await holder.sdJwtVc.store({ record }) console.log('Stored SD-JWT VC with claims', record.firstCredential.prettyClaims) } ``` ::: `requestCredentials` doesn't store anything. The `SdJwtVcRecord` has one instance per credential the issuer returned, so a batch ends up in one record. `record.firstCredential.prettyClaims` has all claims, decoded. ## Presentation The verifier needs the same trust configuration as the holder, see [Trust the issuer](#trust-the-issuer). ### Request claims with DCQL The verifier asks for SD-JWT VCs by `vct` and lists the claims it needs: :::verifier ```ts const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'none' }, dcql: { query: { credentials: [ { id: 'identity', format: 'dc+sd-jwt', meta: { vct_values: ['urn:example:identity:1'] }, // Only these claims are disclosed, the others stay hidden claims: [{ path: ['given_name'] }, { path: ['address', 'country'] }], }, ], }, }, transactionData: [ { type: 'urn:example:transaction:age-check', credential_ids: ['identity'], transaction_data_hashes_alg: ['sha-256'], }, ], }) ``` ::: - `format` is `dc+sd-jwt`. `meta.vct_values` also matches credentials whose type metadata extends one of the values. - Each entry in `claims` has a `path` into the credential. `null` in a path selects all items of an array, and a number selects one item. Add `values` to only accept certain values, and `claim_sets` to accept alternative combinations of claims. The wallet discloses only the requested claims. Claims the issuer didn't make selectively disclosable are always in the presentation. ### Add transaction data A verifier can ask the holder to sign over extra data, such as the details of a payment, as part of the presentation. The request above has one `transactionData` entry. `type` is defined by your application, and `credential_ids` lists the DCQL credential query ids that may sign it. `transaction_data_hashes_alg` defaults to `sha-256`. Credo supports transaction data for SD-JWT VC only, and not with OpenID4VP draft 21. ### Sign transaction data `resolvedRequest.transactionData` lists each entry with the credentials that can sign it. Show the user what they're signing, and pass one credential id per entry when you accept: :::holder ```ts const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) if (!resolvedRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(resolvedRequest.dcql.queryResult) }, // Sign each transaction data entry with one of the credentials it lists transactionData: resolvedRequest.transactionData?.map(({ matchedCredentialIds }) => ({ credentialId: matchedCredentialIds[0], })), }) if (!result.ok) throw new Error(`Verifier rejected the presentation: ${result.serverResponse.status}`) ``` ::: The holder includes a hash of each entry in the key binding JWT. It rejects transaction data that refers to a credential query id that isn't in the DCQL query. ### Read the disclosed claims After the session reaches `ResponseVerified`, the presentation is an `SdJwtVc`: :::verifier ```ts const { dcql, transactionData } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse( verificationSession.id ) const [presentation] = dcql?.presentations.identity ?? [] if (presentation?.claimFormat !== ClaimFormat.SdJwtDc) throw new Error('Expected an SD-JWT VC presentation') // Only the disclosed claims (plus the always visible ones, like vct and iss) console.log('Disclosed claims', presentation.prettyClaims) console.log( 'Signed transaction data', transactionData?.map(({ decoded }) => decoded.type) ) ``` ::: `prettyClaims` has the disclosed claims and the claims that are always visible, such as `vct`, `iss` and `cnf`. `payload` has the raw JWT payload. `transactionData` has an entry per transaction data entry, with the decoded entry, the credential query id that signed it, and the hashes Credo checked. ## Full example The issuer, holder and verifier from this page in one process:
openid4vc-sd-jwt-vc.ts ```ts import { Agent, ClaimFormat, SdJwtVcRecord, type X509Certificate } from '@credo-ts/core' import { KnownJwaSignatureAlgorithms, PublicJwk } from '@credo-ts/core/kms' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { type OpenId4VciCredentialConfigurationSupportedWithFormats, OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapper, type OpenId4VciSignSdJwtCredentials, OpenId4VcModule, } from '@credo-ts/openid4vc' import express from 'express' import { exampleStorage } from './example-storage.js' const issuerUrl = 'http://localhost:4031' const verifierUrl = 'http://localhost:4032' // In-memory storage and keys, so the example leaves nothing behind const issuerStorage = await exampleStorage([openid4vcBundle]) const verifierStorage = await exampleStorage([openid4vcBundle]) const holderStorage = await exampleStorage([openid4vcBundle]) const credentialConfigurationsSupported = { 'identity-sd-jwt': { format: OpenId4VciCredentialFormatProfile.SdJwtDc, vct: 'urn:example:identity:1', scope: 'identity', cryptographic_binding_methods_supported: ['jwk'], credential_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256] }, }, credential_metadata: { display: [{ name: 'Identity card', locale: 'en', background_color: '#1d3557', text_color: '#ffffff' }], claims: [ { path: ['given_name'], display: [{ name: 'Given name', locale: 'en' }] }, { path: ['family_name'], display: [{ name: 'Family name', locale: 'en' }] }, { path: ['birthdate'], display: [{ name: 'Date of birth', locale: 'en' }] }, { path: ['address', 'country'], display: [{ name: 'Country', locale: 'en' }] }, ], }, }, } satisfies Record // The issuer certificate is created once the issuer agent is initialized (see below) let issuerCertificate: X509Certificate const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, issuanceSession, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.SdJwtDc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } if (holderBinding.bindingMethod !== 'jwk') throw new Error('Only jwk holder binding is supported') // Look up the data for this user, e.g. based on the issuance metadata of the offer const { userId } = issuanceSession.issuanceMetadata ?? {} if (userId !== 'user-123') throw new Error('Unknown user') return { type: 'credentials', format: ClaimFormat.SdJwtDc, // One credential per holder key (more than one with batch issuance) credentials: holderBinding.keys.map((holder) => ({ holder, issuer: { method: 'x5c', x5c: [issuerCertificate], issuer: issuerUrl }, payload: { vct: credentialConfiguration.vct, given_name: 'Erika', family_name: 'Mustermann', birthdate: '1984-01-26', address: { street_address: 'Heidestraße 17', locality: 'Köln', country: 'DE' }, }, disclosureFrame: { _sd: ['given_name', 'family_name', 'birthdate'], address: { _sd: ['street_address', 'locality', 'country'] }, }, })), } satisfies OpenId4VciSignSdJwtCredentials } const issuerApp = express() const issuer = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...issuerStorage, openid4vc: new OpenId4VcModule({ app: issuerApp, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper }, }), }, }) const verifierApp = express() const verifier = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...verifierStorage, openid4vc: new OpenId4VcModule({ app: verifierApp, verifier: { baseUrl: `${verifierUrl}/oid4vp` } }), }, }) const holder = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...holderStorage, openid4vc: new OpenId4VcModule(), }, }) await issuer.initialize() await verifier.initialize() await holder.initialize() const issuerServer = issuerApp.listen(4031) const verifierServer = verifierApp.listen(4032) // Self-signed certificate for the example. The SAN must match the host of `issuerUrl`. issuerCertificate = await issuer.x509.createCertificate({ authorityKey: PublicJwk.fromPublicJwk((await issuer.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk), issuer: { commonName: 'Credo example issuer', countryName: 'NL' }, extensions: { subjectAlternativeName: { name: [{ type: 'dns', value: 'localhost' }] } }, }) // Both the holder and the verifier only accept credentials from issuers they trust holder.x509.config.addTrustedCertificate(issuerCertificate) verifier.x509.config.addTrustedCertificate(issuerCertificate) const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-issuer', display: [{ name: 'Credo example issuer', locale: 'en' }], credentialConfigurationsSupported, }) const { credentialOffer } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['identity-sd-jwt'], preAuthorizedCodeFlowConfig: {}, issuanceMetadata: { userId: 'user-123' }, }) const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, credentialBindingResolver: async ({ proofTypes }) => { const algorithm = proofTypes.jwt?.supportedSignatureAlgorithms[0] if (!algorithm) throw new Error('No supported proof of possession algorithm') const key = await holder.kms.createKeyForSignatureAlgorithm({ algorithm }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) for (const { record } of credentials) { if (!(record instanceof SdJwtVcRecord)) throw new Error('Expected an SD-JWT VC') await holder.sdJwtVc.store({ record }) console.log('Stored SD-JWT VC with claims', record.firstCredential.prettyClaims) } const verifierRecord = await verifier.openid4vc.verifier.createVerifier({ verifierId: 'example-verifier', clientMetadata: { client_name: 'Credo example verifier' }, }) const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'none' }, dcql: { query: { credentials: [ { id: 'identity', format: 'dc+sd-jwt', meta: { vct_values: ['urn:example:identity:1'] }, // Only these claims are disclosed, the others stay hidden claims: [{ path: ['given_name'] }, { path: ['address', 'country'] }], }, ], }, }, transactionData: [ { type: 'urn:example:transaction:age-check', credential_ids: ['identity'], transaction_data_hashes_alg: ['sha-256'], }, ], }) const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) if (!resolvedRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(resolvedRequest.dcql.queryResult) }, // Sign each transaction data entry with one of the credentials it lists transactionData: resolvedRequest.transactionData?.map(({ matchedCredentialIds }) => ({ credentialId: matchedCredentialIds[0], })), }) if (!result.ok) throw new Error(`Verifier rejected the presentation: ${result.serverResponse.status}`) const { dcql, transactionData } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse( verificationSession.id ) const [presentation] = dcql?.presentations.identity ?? [] if (presentation?.claimFormat !== ClaimFormat.SdJwtDc) throw new Error('Expected an SD-JWT VC presentation') // Only the disclosed claims (plus the always visible ones, like vct and iss) console.log('Disclosed claims', presentation.prettyClaims) console.log( 'Signed transaction data', transactionData?.map(({ decoded }) => decoded.type) ) issuerServer.close() verifierServer.close() await issuer.shutdown() await verifier.shutdown() await holder.shutdown() ```
## Next steps - [SD-JWT VC](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md): signing, presenting and verifying outside OpenID4VC. - [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md): request SD-JWT VCs from a website. - [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md): DPoP, wallet attestations and key attestations. # mdoc with OpenID4VC > Issue, receive and verify ISO 18013-5 mdocs, such as mobile driving licences, over OpenID4VCI and OpenID4VP with Credo. This page covers what is specific to mdoc (ISO/IEC 18013-5, `mso_mdoc`) in OpenID4VC, in the order of the flow. Issuance covers the issuer's credential configuration, certificate and signing, and the holder's trust and storage. Presentation covers the verifier's DCQL query and the result. [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md), [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md), [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md) and [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md) explain the flow itself, and [mdoc](https://credo.js.org/guides/next/credential-formats/mdoc.md) explains the format. Use mdoc for credentials that follow an ISO document type, such as the mobile driving licence, or that holders also present in person ([ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md)). To request mdocs from a website, see [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md). ## Issuance ### Configure the credential Add an `mso_mdoc` entry to the `credentialConfigurationsSupported` of the issuer: :::issuer ```ts const mdlNamespace = 'org.iso.18013.5.1' const credentialConfigurationsSupported = { 'mdl-mdoc': { format: OpenId4VciCredentialFormatProfile.MsoMdoc, doctype: 'org.iso.18013.5.1.mDL', scope: 'mdl', cryptographic_binding_methods_supported: ['cose_key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256] }, }, credential_metadata: { display: [{ name: 'Mobile driving licence', locale: 'en' }], claims: [ { path: [mdlNamespace, 'given_name'], display: [{ name: 'Given name', locale: 'en' }] }, { path: [mdlNamespace, 'family_name'], display: [{ name: 'Family name', locale: 'en' }] }, { path: [mdlNamespace, 'birth_date'], display: [{ name: 'Date of birth', locale: 'en' }] }, ], }, }, } satisfies Record ``` ::: - `doctype` is the document type, such as `org.iso.18013.5.1.mDL` for a mobile driving licence. Credo checks that every mdoc the mapper returns has this doctype. - An mdoc is always bound to a key of the holder, never to a DID. `cose_key` (or `jwk`) in `cryptographic_binding_methods_supported` tells the wallet so. In `credential_metadata.claims`, the `path` of a data element is its namespace and element identifier. Add a `scope` if you offer the credential through the [authorization code flow](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md). ### Create the issuer certificate mdocs are signed with an X.509 certificate, the document signer certificate: :::issuer ```ts // Self-signed for the example. In production this is a document signer certificate issued by your IACA. issuerCertificate = await issuer.x509.createCertificate({ authorityKey: PublicJwk.fromPublicJwk((await issuer.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk), // mdoc verification requires a country (C) in the issuer certificate issuer: { commonName: 'Credo example mDL issuer', countryName: 'NL' }, }) ``` ::: The subject must have a country (`countryName`): verification fails without it, and checks that `issuing_country` in the mdoc matches it (and `issuing_jurisdiction` the state, if present). In production, your issuing authority CA (IACA) issues the document signer certificate, and verifiers trust the IACA certificate. Pass the chain as an array, leaf first, to include it in the mdoc. [X.509 certificates](https://credo.js.org/guides/next/dids-and-trust/x509.md) explains creating certificates and chains. ### Sign the credential The mapper returns the mdoc sign options, one entry for each holder key: :::issuer ```ts const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.MsoMdoc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } // An mdoc is always bound to a key, never to a DID if (holderBinding.bindingMethod !== 'jwk') throw new Error('Expected jwk holder binding') const oneYearFromNow = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) return { type: 'credentials', format: ClaimFormat.MsoMdoc, credentials: holderBinding.keys.map(({ jwk }) => ({ docType: credentialConfiguration.doctype, holderKey: jwk, issuerCertificate, // `signed` and `validFrom` default to now validityInfo: { validUntil: oneYearFromNow }, namespaces: { [mdlNamespace]: { given_name: 'Erika', family_name: 'Mustermann', birth_date: new DateOnly('1984-01-26'), issue_date: new DateOnly(), expiry_date: new DateOnly(oneYearFromNow.toISOString().slice(0, 10)), // Must match the country (C) of the issuer certificate issuing_country: 'NL', document_number: 'NL-123456', }, }, })), } satisfies OpenId4VciSignMdocCredentials } ``` ::: - `docType` must be the `doctype` of the configuration, and `holderKey` is the key from `holderBinding`. - `validityInfo` needs `validUntil`. Leave `signed` and `validFrom` out so they default to now: Credo rejects a `validFrom` that is even a few milliseconds before `signed`, which happens if you pass `new Date()`. - `namespaces` maps each namespace to its data elements. Use `DateOnly` for elements of type `full-date`, such as `birth_date`. Every data element can be disclosed on its own. For revocation, add `statusInfo` with a [status list](https://credo.js.org/guides/next/credential-formats/status-lists.md). `keyAuthorizations` lists data elements the wallet may sign itself (device signed). ### Trust the issuer The holder verifies the mdoc when it receives it, and the verifier verifies it again in the presentation. Both need the issuer certificate or its IACA root: ```ts // The holder verifies the mdoc when receiving it, and the verifier when it is presented holder.x509.config.addTrustedCertificate(issuerCertificate) verifier.x509.config.addTrustedCertificate(issuerCertificate) ``` Without a trusted certificate, verification fails. To decide per verification, for example from a list of IACAs, use `getTrustedIssuersForVerification` in the agent config. See [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). ### Receive and store The binding resolver must return keys (`method: 'jwk'`): :::holder ```ts const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, // mdoc only supports binding to a key (jwk) credentialBindingResolver: async ({ proofTypes }) => { const algorithm = proofTypes.jwt?.supportedSignatureAlgorithms[0] if (!algorithm) throw new Error('No supported proof of possession algorithm') const key = await holder.kms.createKeyForSignatureAlgorithm({ algorithm }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) for (const { record } of credentials) { if (!(record instanceof MdocRecord)) throw new Error('Expected an mdoc') await holder.mdoc.store({ record }) console.log(record.firstCredential.docType, record.firstCredential.issuerSignedNamespaces) } ``` ::: `requestCredentials` doesn't store anything. The `MdocRecord` has one instance per mdoc the issuer returned. `record.firstCredential.issuerSignedNamespaces` has the data elements per namespace. ## Presentation The verifier needs the same trust configuration as the holder, see [Trust the issuer](#trust-the-issuer). ### Request data elements with DCQL The verifier asks for an mdoc by doctype and lists the data elements it needs: :::verifier ```ts const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'none' }, // Encrypted response (the default) responseMode: 'direct_post.jwt', dcql: { query: { credentials: [ { id: 'mdl', format: 'mso_mdoc', meta: { doctype_value: 'org.iso.18013.5.1.mDL' }, claims: [ { path: ['org.iso.18013.5.1', 'given_name'], intent_to_retain: false }, { path: ['org.iso.18013.5.1', 'birth_date'], intent_to_retain: false }, ], }, ], }, }, }) ``` ::: `format` is `mso_mdoc` and `meta.doctype_value` the doctype. Each claim `path` is a namespace and a data element identifier, and `intent_to_retain` tells the wallet whether you store the element. The wallet discloses only the requested data elements. Keep the default response mode `direct_post.jwt`: with OpenID4VP drafts 21 and 24, mdoc requests require it. ### Present Presenting works as for other formats. Credo creates an ISO 18013-5 device response, signed with the key the mdoc is bound to: :::holder ```ts const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) if (!resolvedRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching mdoc') const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(resolvedRequest.dcql.queryResult) }, }) if (!result.ok) throw new Error(`Verifier rejected the presentation: ${result.serverResponse.status}`) ``` ::: ### Read the disclosed data elements After the session reaches `ResponseVerified`, the presentation is an `MdocDeviceResponse`: :::verifier ```ts const { dcql } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSession.id) const [deviceResponse] = dcql?.presentations.mdl ?? [] if (deviceResponse?.claimFormat !== ClaimFormat.MsoMdoc) throw new Error('Expected an mdoc presentation') // { 'org.iso.18013.5.1.mDL': { 'org.iso.18013.5.1': { given_name: 'Erika', birth_date: DateOnly } } } console.log(deviceResponse.issuerClaims) ``` ::: `issuerClaims` has the issuer-signed data elements per doctype and namespace. `deviceClaims` has the data elements the wallet signed itself, if any. ## Full example The issuer, holder and verifier from this page in one process:
openid4vc-mdoc.ts ```ts import { Agent, ClaimFormat, DateOnly, MdocRecord, type X509Certificate } from '@credo-ts/core' import { KnownJwaSignatureAlgorithms, PublicJwk } from '@credo-ts/core/kms' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { type OpenId4VciCredentialConfigurationSupportedWithFormats, OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapper, type OpenId4VciSignMdocCredentials, OpenId4VcModule, } from '@credo-ts/openid4vc' import express from 'express' import { exampleStorage } from './example-storage.js' const issuerUrl = 'http://localhost:4033' const verifierUrl = 'http://localhost:4034' // In-memory storage and keys, so the example leaves nothing behind const issuerStorage = await exampleStorage([openid4vcBundle]) const holderStorage = await exampleStorage([openid4vcBundle]) const verifierStorage = await exampleStorage([openid4vcBundle]) const mdlNamespace = 'org.iso.18013.5.1' const credentialConfigurationsSupported = { 'mdl-mdoc': { format: OpenId4VciCredentialFormatProfile.MsoMdoc, doctype: 'org.iso.18013.5.1.mDL', scope: 'mdl', cryptographic_binding_methods_supported: ['cose_key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.ES256] }, }, credential_metadata: { display: [{ name: 'Mobile driving licence', locale: 'en' }], claims: [ { path: [mdlNamespace, 'given_name'], display: [{ name: 'Given name', locale: 'en' }] }, { path: [mdlNamespace, 'family_name'], display: [{ name: 'Family name', locale: 'en' }] }, { path: [mdlNamespace, 'birth_date'], display: [{ name: 'Date of birth', locale: 'en' }] }, ], }, }, } satisfies Record // The issuer certificate is created once the issuer agent is initialized (see below) let issuerCertificate: X509Certificate const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, }) => { if (credentialConfiguration.format !== OpenId4VciCredentialFormatProfile.MsoMdoc) { throw new Error(`Unsupported credential format ${credentialConfiguration.format}`) } // An mdoc is always bound to a key, never to a DID if (holderBinding.bindingMethod !== 'jwk') throw new Error('Expected jwk holder binding') const oneYearFromNow = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) return { type: 'credentials', format: ClaimFormat.MsoMdoc, credentials: holderBinding.keys.map(({ jwk }) => ({ docType: credentialConfiguration.doctype, holderKey: jwk, issuerCertificate, // `signed` and `validFrom` default to now validityInfo: { validUntil: oneYearFromNow }, namespaces: { [mdlNamespace]: { given_name: 'Erika', family_name: 'Mustermann', birth_date: new DateOnly('1984-01-26'), issue_date: new DateOnly(), expiry_date: new DateOnly(oneYearFromNow.toISOString().slice(0, 10)), // Must match the country (C) of the issuer certificate issuing_country: 'NL', document_number: 'NL-123456', }, }, })), } satisfies OpenId4VciSignMdocCredentials } const issuerApp = express() const issuer = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...issuerStorage, openid4vc: new OpenId4VcModule({ app: issuerApp, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper }, }), }, }) const verifierApp = express() const verifier = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...verifierStorage, openid4vc: new OpenId4VcModule({ app: verifierApp, verifier: { baseUrl: `${verifierUrl}/oid4vp` } }), }, }) const holder = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...holderStorage, openid4vc: new OpenId4VcModule(), }, }) await issuer.initialize() await verifier.initialize() await holder.initialize() const issuerServer = issuerApp.listen(4033) const verifierServer = verifierApp.listen(4034) // Self-signed for the example. In production this is a document signer certificate issued by your IACA. issuerCertificate = await issuer.x509.createCertificate({ authorityKey: PublicJwk.fromPublicJwk((await issuer.kms.createKey({ type: { kty: 'EC', crv: 'P-256' } })).publicJwk), // mdoc verification requires a country (C) in the issuer certificate issuer: { commonName: 'Credo example mDL issuer', countryName: 'NL' }, }) // The holder verifies the mdoc when receiving it, and the verifier when it is presented holder.x509.config.addTrustedCertificate(issuerCertificate) verifier.x509.config.addTrustedCertificate(issuerCertificate) const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-mdl-issuer', credentialConfigurationsSupported, }) const { credentialOffer } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['mdl-mdoc'], preAuthorizedCodeFlowConfig: {}, }) const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, // mdoc only supports binding to a key (jwk) credentialBindingResolver: async ({ proofTypes }) => { const algorithm = proofTypes.jwt?.supportedSignatureAlgorithms[0] if (!algorithm) throw new Error('No supported proof of possession algorithm') const key = await holder.kms.createKeyForSignatureAlgorithm({ algorithm }) return { method: 'jwk', keys: [PublicJwk.fromPublicJwk(key.publicJwk)] } }, }) for (const { record } of credentials) { if (!(record instanceof MdocRecord)) throw new Error('Expected an mdoc') await holder.mdoc.store({ record }) console.log(record.firstCredential.docType, record.firstCredential.issuerSignedNamespaces) } const verifierRecord = await verifier.openid4vc.verifier.createVerifier({ verifierId: 'example-verifier' }) const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'none' }, // Encrypted response (the default) responseMode: 'direct_post.jwt', dcql: { query: { credentials: [ { id: 'mdl', format: 'mso_mdoc', meta: { doctype_value: 'org.iso.18013.5.1.mDL' }, claims: [ { path: ['org.iso.18013.5.1', 'given_name'], intent_to_retain: false }, { path: ['org.iso.18013.5.1', 'birth_date'], intent_to_retain: false }, ], }, ], }, }, }) const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) if (!resolvedRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching mdoc') const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(resolvedRequest.dcql.queryResult) }, }) if (!result.ok) throw new Error(`Verifier rejected the presentation: ${result.serverResponse.status}`) const { dcql } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSession.id) const [deviceResponse] = dcql?.presentations.mdl ?? [] if (deviceResponse?.claimFormat !== ClaimFormat.MsoMdoc) throw new Error('Expected an mdoc presentation') // { 'org.iso.18013.5.1.mDL': { 'org.iso.18013.5.1': { given_name: 'Erika', birth_date: DateOnly } } } console.log(deviceResponse.issuerClaims) issuerServer.close() verifierServer.close() await issuer.shutdown() await verifier.shutdown() await holder.shutdown() ```
## Next steps - [mdoc](https://credo.js.org/guides/next/credential-formats/mdoc.md): signing, presenting and verifying outside OpenID4VC. - [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md): request mdocs from a website, with OpenID4VP or ISO 18013-7 Annex C. - [ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md): present mdocs in person. - [Authorization and wallet security](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md): DPoP, wallet attestations and key attestations. # W3C VCs with OpenID4VC > Issue, receive and verify W3C Verifiable Credentials over OpenID4VCI and OpenID4VP with Credo, as VCDM 1.1 JWTs or VCDM 2.0 SD-JWTs. This page covers W3C Verifiable Credentials over OpenID4VC: which forms work, then issuance (the issuer's credential configurations and signing, and DID binding on the holder) and presentation (the verifier's DCQL query and the result). [Issuing credentials](https://credo.js.org/guides/next/protocols/openid4vc/issuer.md), [Receiving credentials](https://credo.js.org/guides/next/protocols/openid4vc/receive.md), [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md) and [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md) explain the flow itself, and [W3C Verifiable Credentials](https://credo.js.org/guides/next/credential-formats/w3c-vc.md) explains the formats. Use W3C VCs only when your ecosystem requires the W3C data model. Otherwise [SD-JWT VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md) is simpler: it binds to a key instead of a DID and is what HAIP requires. ## Supported forms | Data model | Secured as | OpenID4VCI `format` | Issue and receive | Present with DCQL | | --- | --- | --- | --- | --- | | VCDM 1.1 | JWT | `jwt_vc_json` | Yes | Yes, `jwt_vc_json` | | VCDM 2.0 | SD-JWT | `vc+sd-jwt` with `credential_definition` | Yes | Yes, `vc+sd-jwt` with `meta.type_values` | | VCDM 1.1 | JSON-LD (Linked Data Proofs) | `ldp_vc` | Not recommended | No | | VCDM 2.0 | JWT or Data Integrity | none | No | No | Use `jwt_vc_json` for VCDM 1.1, or `vc+sd-jwt` for VCDM 2.0 with selective disclosure. The rest of this page uses those two. [Limitations](#limitations) explains the other rows. ## Issuance ### Configure the credentials :::issuer ```ts const credentialConfigurationsSupported = { // W3C VCDM 1.1, signed as a JWT 'degree-jwt': { format: OpenId4VciCredentialFormatProfile.JwtVcJson, credential_definition: { type: ['VerifiableCredential', 'UniversityDegreeCredential'] }, scope: 'degree-jwt', cryptographic_binding_methods_supported: ['did:key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.EdDSA] }, }, }, // W3C VCDM 2.0, secured with SD-JWT. No `vct`, otherwise it is an SD-JWT VC. 'degree-sd-jwt': { format: OpenId4VciCredentialFormatProfile.SdJwtVc, credential_definition: { type: ['VerifiableCredential', 'UniversityDegreeCredential'] }, scope: 'degree-sd-jwt', cryptographic_binding_methods_supported: ['did:key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.EdDSA] }, }, }, } satisfies Record ``` ::: - `credential_definition.type` lists the credential types. `vc+sd-jwt` with a `credential_definition` and no `vct` is a W3C VCDM 2.0 credential. With a `vct` it's an [SD-JWT VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md). - W3C credentials are bound to a DID of the holder, so list DID methods in `cryptographic_binding_methods_supported` (or `did` for any method). Credo doesn't bind W3C credentials to a bare key. Add a `scope` if you offer the credentials through the [authorization code flow](https://credo.js.org/guides/next/protocols/openid4vc/authorization.md). ### Sign the credentials The issuer signs with a DID that it created in its own agent, and the mapper puts the holder's DID in the credential: :::issuer ```ts const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, }) => { // W3C credentials are bound to a DID of the holder if (holderBinding.bindingMethod !== 'did') throw new Error('Expected did holder binding') if (credentialConfiguration.format === OpenId4VciCredentialFormatProfile.JwtVcJson) { return { type: 'credentials', format: ClaimFormat.JwtVc, credentials: holderBinding.keys.map(({ didUrl }) => ({ verificationMethod: issuerDid.verificationMethodId, credential: new W3cCredential({ type: credentialConfiguration.credential_definition.type, issuer: new W3cIssuer({ id: issuerDid.did }), issuanceDate: w3cDate(Date.now()), credentialSubject: new W3cCredentialSubject({ id: parseDid(didUrl).did, claims: { degree: 'Bachelor of Science' }, }), }), })), } satisfies OpenId4VciSignW3cCredentials } // `vc+sd-jwt` with a `credential_definition` (and no `vct`) is a W3C VCDM 2.0 credential if ( credentialConfiguration.format === OpenId4VciCredentialFormatProfile.SdJwtVc && credentialConfiguration.credential_definition ) { return { type: 'credentials', format: ClaimFormat.SdJwtW3cVc, credentials: holderBinding.keys.map(({ didUrl }) => ({ alg: KnownJwaSignatureAlgorithms.EdDSA, verificationMethod: issuerDid.verificationMethodId, holder: { method: 'did', didUrl }, credential: new W3cV2Credential({ type: credentialConfiguration.credential_definition.type, issuer: new W3cV2Issuer({ id: issuerDid.did }), validFrom: w3cDate(Date.now()), credentialSubject: new W3cV2CredentialSubject({ id: parseDid(didUrl).did, degree: 'Bachelor of Science' }), }), disclosureFrame: { credentialSubject: { _sd: ['degree'] } }, })), } satisfies OpenId4VciSignW3cV2Credentials } throw new Error(`Unsupported credential configuration ${credentialConfiguration.format}`) } ``` ::: - `issuerDid` is a DID created with `agent.dids.create`, for example `did:key`, and `verificationMethod` is the id of its signing key. W3C credentials can't be signed with an X.509 certificate. See [DIDs](https://credo.js.org/guides/next/dids-and-trust.md). - `holderBinding.keys` has a `didUrl` for each holder key. Its DID becomes the credential subject. - For VCDM 1.1 (`ClaimFormat.JwtVc`), return a `W3cCredential` and the verification method. Credo picks the JWT algorithm from the key. - For VCDM 2.0 (`ClaimFormat.SdJwtW3cVc`), return a `W3cV2Credential`, the `alg`, the `holder` (written to `cnf`) and a `disclosureFrame`. Don't add a `credentialStatus`: Credo can't check it, and a Credo verifier rejects VCDM 1.1 presentations of credentials that have one. ### Receive and store The holder needs a DID it controls, and the binding resolver returns its verification method: :::holder ```ts const holderDid = await createDidKey(holder.dids) const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, // W3C credentials can only be bound to a DID credentialBindingResolver: () => ({ method: 'did', didUrls: [holderDid.verificationMethodId] }), }) for (const { record } of credentials) { if (record instanceof W3cCredentialRecord) await holder.w3cCredentials.store({ record }) else if (record instanceof W3cV2CredentialRecord) await holder.w3cV2Credentials.store({ record }) else throw new Error('Expected a W3C credential') } ``` ::: VCDM 1.1 credentials arrive as a `W3cCredentialRecord`, which you store with `agent.w3cCredentials`. VCDM 2.0 credentials arrive as a `W3cV2CredentialRecord`, stored with `agent.w3cV2Credentials`. Credo verifies both before returning them, and accepts any DID issuer unless you configure [trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md). ## Presentation ### Request with DCQL :::verifier ```ts const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'none' }, dcql: { query: { credentials: [ { id: 'degree-jwt', format: 'jwt_vc_json', meta: { type_values: [['UniversityDegreeCredential']] }, }, { id: 'degree-sd-jwt', format: 'vc+sd-jwt', meta: { type_values: [['UniversityDegreeCredential']] }, claims: [{ path: ['credentialSubject', 'degree'] }], }, ], }, }, }) ``` ::: `meta.type_values` lists alternative sets of types. Credo compares them with the `type` values as they appear in the credential, not with expanded JSON-LD IRIs. For `jwt_vc_json`, the wallet presents the whole credential. For `vc+sd-jwt`, the `meta.type_values` make it a W3C VCDM 2.0 query, and `claims` paths point into the credential, for example `['credentialSubject', 'degree']`. The wallet discloses only those claims. ### Present Presenting works as for other formats. The holder signs the presentation with the DID of the credential subject (VCDM 1.1) or the key in `cnf` (VCDM 2.0): :::holder ```ts const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) if (!resolvedRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(resolvedRequest.dcql.queryResult) }, }) if (!result.ok) throw new Error(`Verifier rejected the presentation: ${result.serverResponse.status}`) ``` ::: ### Read the presentations :::verifier ```ts const { dcql } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSession.id) const [jwtPresentation] = dcql?.presentations['degree-jwt'] ?? [] if (jwtPresentation?.claimFormat !== ClaimFormat.JwtVp) throw new Error('Expected a JWT VP') for (const credential of asArray(jwtPresentation.verifiableCredential)) { console.log(credential.type, credential.credentialSubject) } const [sdJwtPresentation] = dcql?.presentations['degree-sd-jwt'] ?? [] if (sdJwtPresentation?.claimFormat !== ClaimFormat.SdJwtW3cVp) throw new Error('Expected an SD-JWT VP') for (const entry of asArray(sdJwtPresentation.resolvedPresentation.verifiableCredential)) { // The SD-JWT credential is enveloped in the presentation, with only the disclosed claims if ('resolvedCredential' in entry) console.log(entry.resolvedCredential.type, entry.resolvedCredential.credentialSubject) } ``` ::: A `jwt_vc_json` query gives a `W3cJwtVerifiablePresentation`, with the credentials in `verifiableCredential`. A `vc+sd-jwt` query gives a `W3cV2SdJwtVerifiablePresentation`: the credential is enveloped in it, and `resolvedCredential` has the disclosed claims. ## Limitations - `ldp_vc` (JSON-LD credentials with Linked Data Proofs) has known problems in this version: issuing between Credo agents needs workarounds, and presenting with DCQL fails when Credo creates the presentation. - `jwt_vc_json-ld` is issued and stored like `jwt_vc_json`, but DCQL has no format for it. - VCDM 2.0 credentials secured as a plain JWT (`vc+jwt`) or with Data Integrity proofs have no OpenID4VCI format. - DIF Presentation Exchange only works with the OpenID4VP drafts, and not for VCDM 2.0 credentials. ## Full example The issuer, holder and verifier from this page in one process:
openid4vc-w3c.ts ```ts import { Agent, asArray, ClaimFormat, type DidsApi, type KeyDidCreateOptions, parseDid, W3cCredential, W3cCredentialRecord, W3cCredentialSubject, W3cIssuer, W3cV2Credential, W3cV2CredentialRecord, W3cV2CredentialSubject, W3cV2Issuer, w3cDate, } from '@credo-ts/core' import { KnownJwaSignatureAlgorithms } from '@credo-ts/core/kms' import { openid4vcBundle } from '@credo-ts/drizzle-storage/openid4vc' import { agentDependencies } from '@credo-ts/node' import { type OpenId4VciCredentialConfigurationSupportedWithFormats, OpenId4VciCredentialFormatProfile, type OpenId4VciCredentialRequestToCredentialMapper, type OpenId4VciSignW3cCredentials, type OpenId4VciSignW3cV2Credentials, OpenId4VcModule, } from '@credo-ts/openid4vc' import express from 'express' import { exampleStorage } from './example-storage.js' const issuerUrl = 'http://localhost:4035' const verifierUrl = 'http://localhost:4036' // In-memory storage and keys, so the example leaves nothing behind const issuerStorage = await exampleStorage([openid4vcBundle]) const verifierStorage = await exampleStorage([openid4vcBundle]) const holderStorage = await exampleStorage([openid4vcBundle]) async function createDidKey(dids: DidsApi) { const { didState } = await dids.create({ method: 'key', options: { createKey: { type: { kty: 'OKP', crv: 'Ed25519' } } }, }) const verificationMethod = didState.didDocument?.verificationMethod?.[0] if (didState.state !== 'finished' || !verificationMethod) throw new Error('Failed to create did:key') return { did: didState.did, verificationMethodId: verificationMethod.id } } const credentialConfigurationsSupported = { // W3C VCDM 1.1, signed as a JWT 'degree-jwt': { format: OpenId4VciCredentialFormatProfile.JwtVcJson, credential_definition: { type: ['VerifiableCredential', 'UniversityDegreeCredential'] }, scope: 'degree-jwt', cryptographic_binding_methods_supported: ['did:key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.EdDSA] }, }, }, // W3C VCDM 2.0, secured with SD-JWT. No `vct`, otherwise it is an SD-JWT VC. 'degree-sd-jwt': { format: OpenId4VciCredentialFormatProfile.SdJwtVc, credential_definition: { type: ['VerifiableCredential', 'UniversityDegreeCredential'] }, scope: 'degree-sd-jwt', cryptographic_binding_methods_supported: ['did:key'], proof_types_supported: { jwt: { proof_signing_alg_values_supported: [KnownJwaSignatureAlgorithms.EdDSA] }, }, }, } satisfies Record // The issuer DID is created once the issuer agent is initialized (see below) let issuerDid: { did: string; verificationMethodId: string } const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({ credentialConfiguration, holderBinding, }) => { // W3C credentials are bound to a DID of the holder if (holderBinding.bindingMethod !== 'did') throw new Error('Expected did holder binding') if (credentialConfiguration.format === OpenId4VciCredentialFormatProfile.JwtVcJson) { return { type: 'credentials', format: ClaimFormat.JwtVc, credentials: holderBinding.keys.map(({ didUrl }) => ({ verificationMethod: issuerDid.verificationMethodId, credential: new W3cCredential({ type: credentialConfiguration.credential_definition.type, issuer: new W3cIssuer({ id: issuerDid.did }), issuanceDate: w3cDate(Date.now()), credentialSubject: new W3cCredentialSubject({ id: parseDid(didUrl).did, claims: { degree: 'Bachelor of Science' }, }), }), })), } satisfies OpenId4VciSignW3cCredentials } // `vc+sd-jwt` with a `credential_definition` (and no `vct`) is a W3C VCDM 2.0 credential if ( credentialConfiguration.format === OpenId4VciCredentialFormatProfile.SdJwtVc && credentialConfiguration.credential_definition ) { return { type: 'credentials', format: ClaimFormat.SdJwtW3cVc, credentials: holderBinding.keys.map(({ didUrl }) => ({ alg: KnownJwaSignatureAlgorithms.EdDSA, verificationMethod: issuerDid.verificationMethodId, holder: { method: 'did', didUrl }, credential: new W3cV2Credential({ type: credentialConfiguration.credential_definition.type, issuer: new W3cV2Issuer({ id: issuerDid.did }), validFrom: w3cDate(Date.now()), credentialSubject: new W3cV2CredentialSubject({ id: parseDid(didUrl).did, degree: 'Bachelor of Science' }), }), disclosureFrame: { credentialSubject: { _sd: ['degree'] } }, })), } satisfies OpenId4VciSignW3cV2Credentials } throw new Error(`Unsupported credential configuration ${credentialConfiguration.format}`) } const issuerApp = express() const issuer = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...issuerStorage, openid4vc: new OpenId4VcModule({ app: issuerApp, issuer: { baseUrl: `${issuerUrl}/oid4vci`, credentialRequestToCredentialMapper }, }), }, }) const verifierApp = express() const verifier = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...verifierStorage, openid4vc: new OpenId4VcModule({ app: verifierApp, verifier: { baseUrl: `${verifierUrl}/oid4vp` } }), }, }) const holder = new Agent({ config: { allowInsecureHttpUrls: true }, dependencies: agentDependencies, modules: { ...holderStorage, openid4vc: new OpenId4VcModule(), }, }) await issuer.initialize() await verifier.initialize() await holder.initialize() const issuerServer = issuerApp.listen(4035) const verifierServer = verifierApp.listen(4036) issuerDid = await createDidKey(issuer.dids) const issuerRecord = await issuer.openid4vc.issuer.createIssuer({ issuerId: 'example-w3c-issuer', credentialConfigurationsSupported, }) const { credentialOffer } = await issuer.openid4vc.issuer.createCredentialOffer({ issuerId: issuerRecord.issuerId, credentialConfigurationIds: ['degree-jwt', 'degree-sd-jwt'], preAuthorizedCodeFlowConfig: {}, }) const holderDid = await createDidKey(holder.dids) const resolvedOffer = await holder.openid4vc.holder.resolveCredentialOffer(credentialOffer) const token = await holder.openid4vc.holder.requestToken({ resolvedCredentialOffer: resolvedOffer }) const { credentials } = await holder.openid4vc.holder.requestCredentials({ resolvedCredentialOffer: resolvedOffer, ...token, // W3C credentials can only be bound to a DID credentialBindingResolver: () => ({ method: 'did', didUrls: [holderDid.verificationMethodId] }), }) for (const { record } of credentials) { if (record instanceof W3cCredentialRecord) await holder.w3cCredentials.store({ record }) else if (record instanceof W3cV2CredentialRecord) await holder.w3cV2Credentials.store({ record }) else throw new Error('Expected a W3C credential') } const verifierRecord = await verifier.openid4vc.verifier.createVerifier({ verifierId: 'example-verifier' }) const { authorizationRequest, verificationSession } = await verifier.openid4vc.verifier.createAuthorizationRequest({ verifierId: verifierRecord.verifierId, requestSigner: { method: 'none' }, dcql: { query: { credentials: [ { id: 'degree-jwt', format: 'jwt_vc_json', meta: { type_values: [['UniversityDegreeCredential']] }, }, { id: 'degree-sd-jwt', format: 'vc+sd-jwt', meta: { type_values: [['UniversityDegreeCredential']] }, claims: [{ path: ['credentialSubject', 'degree'] }], }, ], }, }, }) const resolvedRequest = await holder.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(authorizationRequest) if (!resolvedRequest.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const result = await holder.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolvedRequest.authorizationRequestPayload, dcql: { credentials: holder.openid4vc.holder.selectCredentialsForDcqlRequest(resolvedRequest.dcql.queryResult) }, }) if (!result.ok) throw new Error(`Verifier rejected the presentation: ${result.serverResponse.status}`) const { dcql } = await verifier.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSession.id) const [jwtPresentation] = dcql?.presentations['degree-jwt'] ?? [] if (jwtPresentation?.claimFormat !== ClaimFormat.JwtVp) throw new Error('Expected a JWT VP') for (const credential of asArray(jwtPresentation.verifiableCredential)) { console.log(credential.type, credential.credentialSubject) } const [sdJwtPresentation] = dcql?.presentations['degree-sd-jwt'] ?? [] if (sdJwtPresentation?.claimFormat !== ClaimFormat.SdJwtW3cVp) throw new Error('Expected an SD-JWT VP') for (const entry of asArray(sdJwtPresentation.resolvedPresentation.verifiableCredential)) { // The SD-JWT credential is enveloped in the presentation, with only the disclosed claims if ('resolvedCredential' in entry) console.log(entry.resolvedCredential.type, entry.resolvedCredential.credentialSubject) } issuerServer.close() verifierServer.close() await issuer.shutdown() await verifier.shutdown() await holder.shutdown() ```
## Next steps - [W3C Verifiable Credentials](https://credo.js.org/guides/next/credential-formats/w3c-vc.md): signing, presenting and verifying outside OpenID4VC. - [W3C VCs with DIDComm](https://credo.js.org/guides/next/protocols/didcomm/w3c.md): exchange W3C credentials over DIDComm. - [DIDs](https://credo.js.org/guides/next/dids-and-trust.md): create the DIDs for issuers and holders. # ISO mdoc > How the ISO/IEC 18013-5 and 18013-7 presentation protocols for mdocs work, how they differ from OpenID4VP, and what Credo does for proximity and the Digital Credentials API. ISO/IEC 18013-5 defines the mdoc, the credential format of the mobile driving licence (mDL), together with a protocol to present it in person. ISO/IEC TS 18013-7 adds presentation over the internet, and its Annex C defines `org-iso-mdoc` for the W3C Digital Credentials API. These protocols carry only mdocs. This page explains how an mdoc presentation works, how it differs from OpenID4VP, and what Credo does for each. For the format, see [mdoc](https://credo.js.org/guides/next/credential-formats/mdoc.md). To issue mdocs, or to present them over OpenID4VP, see [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md). ## How a presentation works The wallet holds the mdoc, and the verifier is called the mdoc reader. A presentation has these parts: | Part | What it is | | --- | --- | | Device engagement | Only in proximity. The wallet shows a QR code, or answers an NFC tap, with an ephemeral key and the transports it supports. The reader answers with its own ephemeral key, and both derive session keys that encrypt the rest of the exchange. | | Device request | Per document type, the data elements the reader wants and whether it keeps them (intent to retain). The reader can sign each document request with its certificate (reader authentication), so the wallet can show who is asking. | | Device response | The disclosed data elements as the issuer signed them, and a signature over the session transcript made with the device key the mdoc is bound to. | | Session transcript | Never sent. Both sides derive it from the session: from the engagement in proximity, from the website's origin and the encryption parameters with the Digital Credentials API. The device signature covers it, so a response is only valid in the session it was made for. | ## Proximity and the Digital Credentials API Credo supports two ways to present an mdoc with these protocols: | | [Proximity](https://credo.js.org/guides/next/protocols/mdoc/proximity.md) | [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md) | | --- | --- | --- | | Standard | ISO/IEC 18013-5 | ISO/IEC TS 18013-7 Annex C | | Use | In person, for example at a counter or a gate | A website, through the browser and the operating system | | Started by | The reader scanning a QR code, or an NFC tap | The page calling `navigator.credentials.get()` with protocol `org-iso-mdoc` | | Transport | BLE, NFC or Wi-Fi Aware | The browser and the operating system | | Encryption | Session keys from the engagement | The response is encrypted to the verifier with HPKE | The Digital Credentials API also carries OpenID4VP requests. [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md) covers both protocols, with a guide for the verifier and one for the wallet. Which to use depends on where the presentation happens. In person, use proximity. On a website, use the Digital Credentials API, and pass an OpenID4VP and an `org-iso-mdoc` request in one call, as iOS only accepts `org-iso-mdoc`. Where the browser doesn't support the API, fall back to OpenID4VP with a QR code or a link, see [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md). ## How it differs from OpenID4VP OpenID4VP can present the same mdoc, and the device response inside it has the same structure. ISO/IEC TS 18013-7 also describes mdoc presentation over OpenID4VP. Everything around the device response differs: | | ISO 18013-5 and 18013-7 Annex C | OpenID4VP | | --- | --- | --- | | Encoding | CBOR | JSON and JWT | | Request | A device request with document types and data element identifiers | An authorization request with a DCQL query | | Credential formats | mdoc | SD-JWT VC, mdoc and W3C VCs | | Alternatives | Can't be expressed. Several document requests can mean all of them or any one. | DCQL credential sets and claim sets | | Verifier identity | Reader certificate, optional | Client identifier, usually with a signed request | | Credo API | `agent.mdoc` and `MdocDeviceResponse` in `@credo-ts/core` | `agent.openid4vc` in `@credo-ts/openid4vc` | ## What Credo does | Step | Proximity | Digital Credentials API | | --- | --- | --- | | Engagement and transport | A native module, such as `expo-mdoc-data-transfer` in a React Native wallet | The browser and the operating system. In the wallet, a native module such as `@animo-id/expo-digital-credentials-api` | | Encryption | The native module | Credo | | Create the device request | Not supported | `agent.mdoc.createDcApiVerificationSession` | | Read the request in the wallet | Decode it with `@owf/mdoc` | `agent.mdoc.resolveDcApiRequest`, which also finds matching mdocs | | Reader authentication | Not checked | Created and verified | | Create the device response | `MdocDeviceResponse.createDeviceResponse` | `agent.mdoc.createDcApiResponse` | | Verify the device response | `MdocDeviceResponse.verify` | `agent.mdoc.verifyDcApiResponse`, which also checks the response against the request | Credo signs device responses with the device key (device signature). It doesn't support device MAC authentication, so it can't verify a response authenticated with a MAC either. Neither standard covers issuance. Wallets usually receive mdocs over OpenID4VCI, see [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md). - [Proximity (ISO 18013-5)](https://credo.js.org/guides/next/protocols/mdoc/proximity.md): Present mdocs in person over BLE after a QR code or NFC engagement from a React Native wallet with expo-mdoc-data-transfer and Credo, and verify the device response in a reader. # Proximity (ISO 18013-5) > Present mdocs in person over BLE after a QR code or NFC engagement from a React Native wallet with expo-mdoc-data-transfer and Credo, and verify the device response in a reader. In a proximity presentation the wallet and the verifier's reader are in the same place, for example at a counter, and exchange the request and response over Bluetooth Low Energy (BLE) after the reader scans a QR code or taps the phone. A native module handles the engagement, the transport and the session encryption. Credo creates the device response in the wallet and verifies it in the reader. This page builds a screen for a React Native wallet that uses [`expo-mdoc-data-transfer`](https://github.com/openwallet-foundation-labs/expo-mdoc-data-transfer), then shows how a reader verifies the response. [ISO mdoc](https://credo.js.org/guides/next/protocols/mdoc.md) explains the messages. [Paradym Wallet](https://github.com/animo/paradym-wallet), an open source wallet built on Credo, presents mdocs in person this way. See its [proximity code](https://github.com/animo/paradym-wallet/blob/main/apps/wallet/src/features/proximity/mdocProximity.ts). ## How it works ```mermaid sequenceDiagram participant App as Wallet app participant Module as Native module participant Reader App->>Module: start engagement Module-->>App: QR code Reader->>Module: scan the QR code or tap, connect over BLE Reader->>Module: encrypted device request Module-->>App: device request and session transcript App->>App: ask for consent, create the device response with Credo App->>Module: device response Module->>Reader: encrypted device response Reader->>Reader: verify the device response ``` The module derives the session keys and the session transcript from the engagement, decrypts the request, and encrypts the response. Your app only handles the decrypted device request and the device response. Credo doesn't decode device requests, so the wallet decodes them with `@owf/mdoc`, the mdoc library Credo builds on. Credo also doesn't check reader authentication on a proximity request, and only supports device signatures, not device MAC authentication. ## Wallet ### Set up ```bash npm install expo-mdoc-data-transfer expo-build-properties @owf/mdoc@^0.8.2 react-native-qrcode-svg react-native-svg ``` Add the config plugins to `app.json` and run `npx expo prebuild`: ```json { "expo": { "plugins": [ "expo-mdoc-data-transfer", ["expo-build-properties", { "android": { "minSdkVersion": 26 }, "ios": { "useFrameworks": "dynamic" } }] ] } } ``` - Use the `@owf/mdoc` version that `@credo-ts/core` depends on, so the app has one copy. - The config plugin adds the Bluetooth and NFC permissions and the NFC engagement service to the Android manifest. See the [module README](https://github.com/openwallet-foundation-labs/expo-mdoc-data-transfer#readme). - Dynamic frameworks on iOS change how every pod links. Paradym Wallet keeps some pods static, `askar` and `anoncreds` among them, with the plugin option `ios.buildStatic`. Bluetooth and NFC don't work in emulators, so test on physical devices. Android also asks the user for the Bluetooth permissions at runtime: :::holder ```ts // Android 12 and later need the Bluetooth permissions, earlier versions the location permissions async function requestBluetoothPermissions() { if (Platform.OS !== 'android') return const { PERMISSIONS } = PermissionsAndroid await PermissionsAndroid.requestMultiple( Platform.Version >= 31 ? [PERMISSIONS.BLUETOOTH_ADVERTISE, PERMISSIONS.BLUETOOTH_CONNECT, PERMISSIONS.BLUETOOTH_SCAN] : [PERMISSIONS.ACCESS_FINE_LOCATION, PERMISSIONS.ACCESS_COARSE_LOCATION] ) } ``` ::: The wallet itself is an agent with storage and the stored mdocs, see [Your first agent](https://credo.js.org/guides/next/getting-started/first-agent.md). The screen below gets it as a prop. ### Engage and receive the request When the user taps the button, the screen starts the engagement, shows the QR code and waits for the reader's request: :::holder ```tsx async function start() { await requestBluetoothPermissions() const transfer = mdocDataTransfer.instance() setQrCode(await transfer.startQrEngagement()) // Also lets the reader engage with an NFC tap. Android only. transfer.enableNfc() // Resolves when a reader has scanned the QR code, connected over BLE and sent its request. // The module decrypts the request and derives the session transcript. const { deviceRequest, sessionTranscript } = await transfer.waitForDeviceRequest() setReaderRequest({ documentRequests: decodeDeviceRequest(deviceRequest), sessionTranscript }) } ``` ::: `startQrEngagement()` returns the device engagement to show as a QR code. On iOS, the reader always engages by scanning it. `waitForDeviceRequest()` resolves when a reader has connected and sent its request, and rejects when the transfer fails. `decodeDeviceRequest` reads the requested data elements from the request: :::holder ```ts import { type Agent, CredentialMultiInstanceUseMode, isNonEmptyArray, type Mdoc, MdocDeviceResponse, type MdocDocumentRequest, TypedArrayEncoder, useInstanceFromCredentialRecord, } from '@credo-ts/core' import { DeviceRequest } from '@owf/mdoc' // The requested data elements per document type, with the reader's intent to retain each one export function decodeDeviceRequest(deviceRequest: Uint8Array): MdocDocumentRequest[] { return DeviceRequest.decode(deviceRequest).docRequests.map(({ itemsRequest }) => ({ docType: itemsRequest.docType, nameSpaces: Object.fromEntries( Array.from(itemsRequest.namespaces, ([nameSpace, elements]) => [nameSpace, Object.fromEntries(elements)]) ), })) } ``` ::: `documentRequests` lists, per document type, the requested data elements and the reader's intent to retain each one. The screen shows them to the user before it shares anything. ### Create and send the device response When the user agrees, the screen creates the device response and hands it to the module. `shutdown()` ends the session, and `useMdocDataTransferShutdownOnUnmount()` in the full example also does that when the user leaves the screen: :::holder ```tsx async function share(request: ReaderRequest) { const transfer = mdocDataTransfer.instance() // The module encrypts the response with the session keys and sends it await transfer.sendDeviceResponse(await createDeviceResponse(agent, request)) stop() } function stop() { mdocDataTransfer.instance().shutdown() setQrCode(undefined) setReaderRequest(undefined) } ``` ::: The device response itself is plain Credo: :::holder ```ts export async function createDeviceResponse( agent: Agent, options: { documentRequests: MdocDocumentRequest[]; sessionTranscript: Uint8Array } ) { const mdocs: Mdoc[] = [] const documentRequests: MdocDocumentRequest[] = [] for (const { docType, nameSpaces } of options.documentRequests) { const [record] = await agent.mdoc.findAllByQuery({ docType }) if (!record) continue // A new instance for every presentation, if the mdoc was issued in a batch const { credentialInstance: mdoc } = await useInstanceFromCredentialRecord({ agentContext: agent.context, credentialRecord: record, useMode: CredentialMultiInstanceUseMode.NewOrFirst, }) // Disclose the requested elements the mdoc has. Requesting an element it doesn't have fails. const issuerSigned = mdoc.issuerSignedNamespaces const disclosed = Object.fromEntries( Object.entries(nameSpaces).map(([nameSpace, elements]) => [ nameSpace, Object.fromEntries(Object.entries(elements).filter(([element]) => element in (issuerSigned[nameSpace] ?? {}))), ]) ) mdocs.push(mdoc) documentRequests.push({ docType, nameSpaces: disclosed }) } if (!isNonEmptyArray(mdocs)) throw new Error('No mdoc of the requested document types') const deviceResponse = await MdocDeviceResponse.createDeviceResponse(agent.context, { mdocs, documentRequests, sessionTranscriptOptions: { type: 'sessionTranscriptBytes', sessionTranscriptBytes: options.sessionTranscript }, }) // The encoded DeviceResponse, for the transport to encrypt and send return deviceResponse.deviceResponse.encode() } ``` ::: - `createDeviceResponse` throws when a requested element isn't in the mdoc, so the function only passes the elements the mdoc has. The reader sees the others as missing. - `useInstanceFromCredentialRecord` takes a new instance of an mdoc issued in a batch, so readers can't link presentations. When no new instance is left, it reuses the first one. The session transcript from the module goes in as `sessionTranscriptBytes`, and the device key of the mdoc signs over it. The module encrypts the returned `DeviceResponse` with the session keys. ## Reader Credo doesn't implement the reader side of the transport, and `expo-mdoc-data-transfer` only covers the wallet. When your reader app has the device response and the session transcript from its own transport, Credo verifies the response: :::verifier ```ts export async function verifyDeviceResponse( agent: Agent, options: { deviceResponse: Uint8Array; sessionTranscript: Uint8Array; trustedCertificates: string[] } ) { const deviceResponse = MdocDeviceResponse.fromBase64Url(TypedArrayEncoder.toBase64Url(options.deviceResponse)) // Throws when the response is invalid. Doesn't use the agent's trust configuration. await deviceResponse.verify(agent.context, { trustedCertificates: options.trustedCertificates, sessionTranscriptOptions: { type: 'sessionTranscriptBytes', sessionTranscriptBytes: options.sessionTranscript }, }) return deviceResponse.issuerClaims } ``` ::: - `verify` doesn't use the agent's trust configuration. Pass the certificates of the issuers you trust, usually the IACA root certificates, as `trustedCertificates`, or every response fails. - `verify` throws when the response is invalid. It checks the issuer signature and certificate chain, the element digests, the device signature over the session transcript, the validity period and the status. It doesn't check that the response contains what you asked for, so check `issuerClaims` (per document type and namespace) yourself. ## Full example The wallet screen, and the Credo functions it and the reader use. Render `PresentInPersonScreen` with the wallet's agent. **mdoc-proximity-wallet.tsx** ```tsx // A screen of a React Native wallet that presents mdocs to a reader in person. expo-mdoc-data-transfer // handles the engagement and the transport, Credo the device response (mdoc-proximity.ts). import type { Agent, MdocDocumentRequest } from '@credo-ts/core' import { mdocDataTransfer, useMdocDataTransferShutdownOnUnmount } from 'expo-mdoc-data-transfer' import { useState } from 'react' import { Button, PermissionsAndroid, Platform, Text, View } from 'react-native' import QRCode from 'react-native-qrcode-svg' import { createDeviceResponse, decodeDeviceRequest } from './mdoc-proximity.js' interface ReaderRequest { documentRequests: MdocDocumentRequest[] sessionTranscript: Uint8Array } export function PresentInPersonScreen({ agent }: { agent: Agent }) { const [qrCode, setQrCode] = useState() const [readerRequest, setReaderRequest] = useState() // Ends the session when the user leaves the screen useMdocDataTransferShutdownOnUnmount() async function start() { await requestBluetoothPermissions() const transfer = mdocDataTransfer.instance() setQrCode(await transfer.startQrEngagement()) // Also lets the reader engage with an NFC tap. Android only. transfer.enableNfc() // Resolves when a reader has scanned the QR code, connected over BLE and sent its request. // The module decrypts the request and derives the session transcript. const { deviceRequest, sessionTranscript } = await transfer.waitForDeviceRequest() setReaderRequest({ documentRequests: decodeDeviceRequest(deviceRequest), sessionTranscript }) } async function share(request: ReaderRequest) { const transfer = mdocDataTransfer.instance() // The module encrypts the response with the session keys and sends it await transfer.sendDeviceResponse(await createDeviceResponse(agent, request)) stop() } function stop() { mdocDataTransfer.instance().shutdown() setQrCode(undefined) setReaderRequest(undefined) } if (readerRequest) { return ( The reader asks for: {readerRequest.documentRequests.map(({ docType, nameSpaces }) => ( {docType}: {Object.values(nameSpaces).flatMap(Object.keys).join(', ')} ))} ``` ```ts // Runs in the browser, on the verifier's web page. Talks to the routes in dc-api-verifier.ts. // TypeScript's DOM types don't include the Digital Credentials API yet type DigitalCredentialRequestOptions = CredentialRequestOptions & { digital: { requests: Array<{ protocol: string; data: unknown }> } } async function postJson(path: string, body: unknown = {}) { const response = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!response.ok) throw new Error(`${path} failed with status ${response.status}`) return response.json() } export async function verifyWithWallet() { const { openid4vp, isoMdoc } = await postJson('/dc-api/request') const options: DigitalCredentialRequestOptions = { mediation: 'required', digital: { requests: [ // Use 'openid4vp-v1-signed' for a signed request { protocol: 'openid4vp-v1-unsigned', data: openid4vp.request }, { protocol: 'org-iso-mdoc', data: isoMdoc.request }, ], }, } // The browser and the operating system let the user pick a wallet and a credential. // Throws a NotAllowedError when the user cancels or no wallet has a matching credential. const credential = await navigator.credentials.get(options) if (!credential || !('data' in credential) || !('protocol' in credential)) { throw new Error('No digital credential returned') } return postJson('/dc-api/response', { // The protocol of the request the wallet answered protocol: credential.protocol, data: credential.data, openid4vpSessionId: openid4vp.verificationSessionId, isoMdocSessionId: isoMdoc.verificationSessionId, }) } const button = document.querySelector('button') // The API needs a secure context (https or localhost) and a browser that supports it if (!('DigitalCredential' in window)) { button?.replaceWith('Your browser does not support the Digital Credentials API') } // The browser only opens the wallet in response to a user action, such as a click button?.addEventListener('click', async () => { const claims = await verifyWithWallet() console.log(claims) }) ``` ## Next steps - [Building a wallet](https://credo.js.org/guides/next/protocols/dc-api/wallet.md): the other side of the flow. - [Verifying credentials](https://credo.js.org/guides/next/protocols/openid4vc/verifier.md): OpenID4VP requests, client identifiers and verification sessions. - [mdoc with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/mdoc.md) and [SD-JWT VC with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/sd-jwt-vc.md): request and read each format. - [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md): decide which issuers you trust. # Building a wallet > Answer W3C Digital Credentials API requests from websites in a React Native wallet with @animo-id/expo-digital-credentials-api and Credo. Register credentials with the operating system, share the store with the iOS extension, and answer OpenID4VP and org-iso-mdoc requests. When a website calls the Digital Credentials API, the operating system offers the wallets that hold a matching credential, and opens the one the user picks with the request and the origin of the website. In a React Native wallet, [`@animo-id/expo-digital-credentials-api`](https://github.com/animo/expo-digital-credentials-api) handles the platform side: it registers credentials with Credential Manager on Android and Identity Document Services on iOS, and shows your request UI. Credo resolves the request and creates the response. This page builds a minimal wallet that answers both OpenID4VP and `org-iso-mdoc` requests. [Digital Credentials API](https://credo.js.org/guides/next/protocols/dc-api.md) explains the flow and which protocols to support, and the [module README](https://github.com/animo/expo-digital-credentials-api#readme) has all options and platform details. [Paradym Wallet](https://github.com/animo/paradym-wallet), an open source wallet built on Credo, supports the API this way. See its [request handling](https://github.com/animo/paradym-wallet/blob/main/packages/sdk/src/dcApi/ParadymDcApiSdk.ts), [credential registration](https://github.com/animo/paradym-wallet/blob/main/packages/sdk/src/dcApi/registerCredentials.ts) and [request UI](https://github.com/animo/paradym-wallet/tree/main/apps/wallet/src/features/dc-api) for a complete implementation. ## Set up The wallet is a React Native agent, see [Installation](https://credo.js.org/guides/next/getting-started/installation.md). Add the module, and `expo-secure-store` for the wallet key: ```bash npm install @animo-id/expo-digital-credentials-api expo-secure-store ``` Add the config plugin to `app.json` and run `npx expo prebuild`: ```json { "expo": { "plugins": [ [ "@animo-id/expo-digital-credentials-api", { "entry": "src/dc-api-wallet-request", "ios": { "documentTypes": ["org.iso.18013.5.1.mDL"] } } ] ] } } ``` - The module needs Expo SDK 55 and React Native 0.83 or later. On these SDKs, npm only installs Credo with the `expo-crypto` override from [Installation](https://credo.js.org/guides/next/getting-started/installation.md). It works on Android devices whose Credential Manager has the registry API, and on iOS 26 and later. The app itself can still support older iOS versions. - `entry` is the file with the request UI, without extension. - `ios.documentTypes` are the mdoc document types the wallet can present on iOS, from the set Apple allows: `org.iso.18013.5.1.mDL`, `eu.europa.ec.eudi.pid.1`, `eu.europa.ec.av.1`, `org.iso.23220.photoid.1` and `org.iso.23220.1.jp.mnc`. Without it, the plugin doesn't add the iOS app extension. Android needs no options. ### Share the wallet with the request UI The app and the request UI open the same wallet: ```ts import { type DcApiClaimValue, type DcApiCredential, getRegistrationStatus, getSharedContainerPath, isSupported, registerCredentials, type SdJwtClaims, } from '@animo-id/expo-digital-credentials-api' import { AskarModule } from '@credo-ts/askar' import { Agent, DateOnly } from '@credo-ts/core' import { OpenId4VcModule } from '@credo-ts/openid4vc' import { agentDependencies } from '@credo-ts/react-native' import { NativeAskar, Store } from '@openwallet-foundation/askar-react-native' import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' // Used by both the app and the request UI export async function initializeWalletAgent() { // On iOS, the config plugin shares the keychain with the request UI let walletKey = await SecureStore.getItemAsync('wallet-key') if (!walletKey) { walletKey = Store.generateRawKey() await SecureStore.setItemAsync('wallet-key', walletKey) } const agent = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'wallet', key: walletKey, keyDerivationMethod: 'raw', // On iOS the request UI runs in an app extension, which can only read files in the // shared app group container. On Android it runs in the app's process. database: Platform.OS === 'ios' ? { type: 'sqlite', config: { path: `${getSharedContainerPath()}/wallet.sqlite` } } : undefined, }, }), openid4vc: new OpenId4VcModule(), }, }) await agent.initialize() return agent } export type WalletAgent = Awaited> ``` On iOS, the request UI runs in an app extension, a separate process that can't read the app's files. The config plugin creates an app group, and `getSharedContainerPath()` returns a directory that both can use. A wallet that already has a store elsewhere has to move it there first. The plugin also shares the keychain between the app and the extension, so both read the same wallet key. On Android, the request UI runs in the app's process. ## Register credentials The operating system only offers the wallet for a request that matches a registered credential: ```ts // Call this on start and whenever the wallet stores or deletes a credential. // Each call replaces the credentials registered before. export async function registerWalletCredentials(agent: WalletAgent) { if (!isSupported()) return // iOS asks the user for permission on the first registration. Registering after they denied it throws. if ((await getRegistrationStatus()) === 'notAuthorized') return const mdocs = (await agent.mdoc.getAll()).map( (record): DcApiCredential => ({ // Comes back in the request when the user picks this credential id: record.id, display: { title: record.firstCredential.docType }, credential: { format: 'mso_mdoc', doctype: record.firstCredential.docType, namespaces: Object.fromEntries( Object.entries(record.firstCredential.issuerSignedNamespaces).map(([namespace, elements]) => [ namespace, Object.fromEntries(Object.entries(elements).map(([element, value]) => [element, claimValue(value)])), ]) ), }, }) ) const sdJwtVcs = (await agent.sdJwtVc.getAll()).map( (record): DcApiCredential => ({ id: record.id, display: { title: record.getTags().vct }, credential: { format: 'dc+sd-jwt', vct: record.getTags().vct, claims: sdJwtClaims(record.firstCredential.prettyClaims), }, }) ) // iOS only registers the mdocs whose document type is in the config plugin's `ios.documentTypes` await registerCredentials({ credentials: [...mdocs, ...sdJwtVcs] }) } ``` - On Android, a matcher runs in a sandbox and compares each request with the registered claims. The system picker shows the matching credentials with their values, so register the values: the full example converts them. Each call replaces the registered set, so pass all credentials. - iOS only registers mdocs whose document type is in `ios.documentTypes`, and only keeps their id and document type. It matches on the document type, so a request can reach the wallet for elements the mdoc doesn't have. SD-JWT VCs aren't registered there. - The first registration on iOS asks the user for permission. ## Answer requests When the user picks a credential of the wallet, the operating system starts the request UI. Credo resolves the request for the origin and creates the response: **OpenID4VP** ```ts export async function createOpenId4VpResponse( agent: WalletAgent, { protocol, data }: Openid4vpProtocolRequest, origin: string ): Promise { // The request arrives as a JSON string const resolved = await agent.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(JSON.parse(data), { origin }) if (!resolved.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const { authorizationResponse } = await agent.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolved.authorizationRequestPayload, dcql: { credentials: agent.openid4vc.holder.selectCredentialsForDcqlRequest(resolved.dcql.queryResult) }, origin, }) // Answer with the protocol of the request return { protocol, data: authorizationResponse } } ``` - Pass the `origin` to both `resolveOpenId4VpAuthorizationRequest` and `acceptOpenId4VpAuthorizationRequest`. The holder rejects a DC API request without it, and a signed request whose `expected_origins` doesn't include it. - A signed request only resolves when the wallet trusts the verifier's certificate. See [Presenting credentials](https://credo.js.org/guides/next/protocols/openid4vc/present.md). - `acceptOpenId4VpAuthorizationRequest` doesn't send anything. It returns `authorizationResponse` for you to hand back to the platform, with the protocol of the request. **ISO 18013-7 Annex C** ```ts export async function createIsoMdocResponse( agent: WalletAgent, { data }: IsoMdocProtocolRequest, origin: string ): Promise { const resolved = await agent.mdoc.resolveDcApiRequest({ request: data, origin }) if (!resolved.success || resolved.docRequestsAsAlternatives) throw new Error('No matching mdoc') const { response } = await agent.mdoc.createDcApiResponse({ resolvedRequest: resolved, credentials: resolved.docRequests.map(({ docRequestIndex, validCredentials }) => ({ docRequestIndex, record: validCredentials[0].record, })), }) return { protocol: 'org-iso-mdoc', data: { response } } } ``` - `resolveDcApiRequest` rejects a request without an origin. It verifies reader authentication and matches the stored mdocs against each document request. `success` is `false` when they can't satisfy the request. - A signed request from a reader you don't trust throws. Trust readers through the agent's trust configuration with verification type `mdocReaderAuth`, or pass `trustedReaderCertificates`. `readerAuth.certificateChain` on each document request has the verified chain, leaf first, to show the verifier to the user. - `createDcApiResponse` discloses every requested element. Pass `elements` per credential to disclose fewer, for example to leave out the ones the user declined. It sends nothing: hand the returned `response` back to the platform. Both functions present the first stored credential that matches. On Android, `request.selection.credentialIds` has the ids of the credentials the user picked in the system picker, so a full wallet presents those. ### The request UI The request UI calls these functions when the user shares: ```tsx async function share() { // Always use the origin from the operating system. On Android it's undefined when an app asks // for itself instead of a website. const { origin } = request if (!origin) return request.decline('No website origin') const agent = await initializeWalletAgent() try { if (request.platform === 'ios') { // iOS only hands over the request once the user approved, and only as org-iso-mdoc const [isoMdocRequest] = await request.approve() if (!isoMdocRequest) return request.decline('No org-iso-mdoc request') return await request.respond(await createIsoMdocResponse(agent, isoMdocRequest, origin)) } // On Android, the request that the credentials picked in the system picker were matched against const { requests, selection } = request const protocolRequest = requests[selection?.requestIndex ?? selection?.candidateRequestIndexes[0] ?? 0] if (!protocolRequest) return request.decline('No supported protocol') await request.respond( protocolRequest.protocol === 'org-iso-mdoc' ? await createIsoMdocResponse(agent, protocolRequest, origin) : await createOpenId4VpResponse(agent, protocolRequest, origin) ) } catch (shareError) { // The request stays open, so the user can try again or decline setError(shareError instanceof Error ? shareError.message : String(shareError)) } finally { await agent.shutdown() } } ``` - Always use the origin from the operating system, never one from the request. On Android it's undefined when an app asks for itself instead of a website. - On Android, `requests` has the requests the page passed, and `selection` what the user picked in the system picker and the request it matched. With several requests of the same protocol, the matcher only reports the protocol, so the example takes the first candidate. - iOS has no picker, and hands over the request only after `approve()`, always as `org-iso-mdoc`. Before that, `request.presentmentRequests` has the document types and elements the operating system parsed from it, to show on the consent screen. - `respond()` sends the response through the operating system to the page, `decline()` cancels. Until then the request stays open, so the user can try again after an error. The entry file registers the screen: ```tsx // The entry file registers the screen export default registerDcApiScreen(DcApiScreen) ``` The request UI is bundled separately from the app. Import only what it needs: on iOS the extension gets little memory, and `ios.excludedPackages` or `ios.includedPackages` limit which native modules it links. The module README also lists known issues with some native modules in the request UI, and patches for them. ## Full example Render the app's screens as usual and call `registerWalletCredentials` after the wallet stores or deletes a credential. The request UI is `dc-api-wallet-request.tsx`. **dc-api-wallet-agent.ts** ```ts // The wallet app: a React Native agent, and the credentials it registers with the operating system. // The request UI in dc-api-wallet-request.tsx opens the same wallet. import { type DcApiClaimValue, type DcApiCredential, getRegistrationStatus, getSharedContainerPath, isSupported, registerCredentials, type SdJwtClaims, } from '@animo-id/expo-digital-credentials-api' import { AskarModule } from '@credo-ts/askar' import { Agent, DateOnly } from '@credo-ts/core' import { OpenId4VcModule } from '@credo-ts/openid4vc' import { agentDependencies } from '@credo-ts/react-native' import { NativeAskar, Store } from '@openwallet-foundation/askar-react-native' import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' // Used by both the app and the request UI export async function initializeWalletAgent() { // On iOS, the config plugin shares the keychain with the request UI let walletKey = await SecureStore.getItemAsync('wallet-key') if (!walletKey) { walletKey = Store.generateRawKey() await SecureStore.setItemAsync('wallet-key', walletKey) } const agent = new Agent({ dependencies: agentDependencies, modules: { askar: new AskarModule({ askar: NativeAskar, store: { id: 'wallet', key: walletKey, keyDerivationMethod: 'raw', // On iOS the request UI runs in an app extension, which can only read files in the // shared app group container. On Android it runs in the app's process. database: Platform.OS === 'ios' ? { type: 'sqlite', config: { path: `${getSharedContainerPath()}/wallet.sqlite` } } : undefined, }, }), openid4vc: new OpenId4VcModule(), }, }) await agent.initialize() return agent } export type WalletAgent = Awaited> // Call this on start and whenever the wallet stores or deletes a credential. // Each call replaces the credentials registered before. export async function registerWalletCredentials(agent: WalletAgent) { if (!isSupported()) return // iOS asks the user for permission on the first registration. Registering after they denied it throws. if ((await getRegistrationStatus()) === 'notAuthorized') return const mdocs = (await agent.mdoc.getAll()).map( (record): DcApiCredential => ({ // Comes back in the request when the user picks this credential id: record.id, display: { title: record.firstCredential.docType }, credential: { format: 'mso_mdoc', doctype: record.firstCredential.docType, namespaces: Object.fromEntries( Object.entries(record.firstCredential.issuerSignedNamespaces).map(([namespace, elements]) => [ namespace, Object.fromEntries(Object.entries(elements).map(([element, value]) => [element, claimValue(value)])), ]) ), }, }) ) const sdJwtVcs = (await agent.sdJwtVc.getAll()).map( (record): DcApiCredential => ({ id: record.id, display: { title: record.getTags().vct }, credential: { format: 'dc+sd-jwt', vct: record.getTags().vct, claims: sdJwtClaims(record.firstCredential.prettyClaims), }, }) ) // iOS only registers the mdocs whose document type is in the config plugin's `ios.documentTypes` await registerCredentials({ credentials: [...mdocs, ...sdJwtVcs] }) } // The Android picker shows these values and matches requests against them. // Values it can't compare, such as arrays and images, are registered as null. function claimValue(value: unknown): DcApiClaimValue { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value if (value instanceof Date || value instanceof DateOnly) return value.toISOString() return null } function sdJwtClaims(claims: object): SdJwtClaims { return Object.fromEntries( Object.entries(claims).map(([name, value]) => [ name, typeof value === 'object' && value !== null && !Array.isArray(value) ? sdJwtClaims(value) : claimValue(value), ]) ) } ``` **dc-api-wallet-response.ts** ```ts // What Credo does in the wallet's request UI (dc-api-wallet-request.tsx): resolve the request for // the origin the operating system passed, and create the response to hand back to it. import type { IsoMdocProtocolRequest, IsoMdocResponseOptions, Openid4vpProtocolRequest, Openid4vpResponseOptions, } from '@animo-id/expo-digital-credentials-api/request-handler' import type { WalletAgent } from './dc-api-wallet-agent.js' export async function createOpenId4VpResponse( agent: WalletAgent, { protocol, data }: Openid4vpProtocolRequest, origin: string ): Promise { // The request arrives as a JSON string const resolved = await agent.openid4vc.holder.resolveOpenId4VpAuthorizationRequest(JSON.parse(data), { origin }) if (!resolved.dcql?.queryResult.can_be_satisfied) throw new Error('No matching credentials') const { authorizationResponse } = await agent.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({ authorizationRequestPayload: resolved.authorizationRequestPayload, dcql: { credentials: agent.openid4vc.holder.selectCredentialsForDcqlRequest(resolved.dcql.queryResult) }, origin, }) // Answer with the protocol of the request return { protocol, data: authorizationResponse } } export async function createIsoMdocResponse( agent: WalletAgent, { data }: IsoMdocProtocolRequest, origin: string ): Promise { const resolved = await agent.mdoc.resolveDcApiRequest({ request: data, origin }) if (!resolved.success || resolved.docRequestsAsAlternatives) throw new Error('No matching mdoc') const { response } = await agent.mdoc.createDcApiResponse({ resolvedRequest: resolved, credentials: resolved.docRequests.map(({ docRequestIndex, validCredentials }) => ({ docRequestIndex, record: validCredentials[0].record, })), }) return { protocol: 'org-iso-mdoc', data: { response } } } ``` **dc-api-wallet-request.tsx** ```tsx // The request UI of the wallet: the `entry` file of the config plugin. The operating system shows it // when the user picks a credential of this wallet for a website. It's bundled separately from the app, // and on iOS runs in its own process, so it opens the wallet itself. import { type DcApiRequest, registerDcApiScreen } from '@animo-id/expo-digital-credentials-api/request-handler' import { useState } from 'react' import { Button, Text, View } from 'react-native' import { initializeWalletAgent } from './dc-api-wallet-agent.js' import { createIsoMdocResponse, createOpenId4VpResponse } from './dc-api-wallet-response.js' function DcApiScreen({ request }: { request: DcApiRequest }) { const [error, setError] = useState() async function share() { // Always use the origin from the operating system. On Android it's undefined when an app asks // for itself instead of a website. const { origin } = request if (!origin) return request.decline('No website origin') const agent = await initializeWalletAgent() try { if (request.platform === 'ios') { // iOS only hands over the request once the user approved, and only as org-iso-mdoc const [isoMdocRequest] = await request.approve() if (!isoMdocRequest) return request.decline('No org-iso-mdoc request') return await request.respond(await createIsoMdocResponse(agent, isoMdocRequest, origin)) } // On Android, the request that the credentials picked in the system picker were matched against const { requests, selection } = request const protocolRequest = requests[selection?.requestIndex ?? selection?.candidateRequestIndexes[0] ?? 0] if (!protocolRequest) return request.decline('No supported protocol') await request.respond( protocolRequest.protocol === 'org-iso-mdoc' ? await createIsoMdocResponse(agent, protocolRequest, origin) : await createOpenId4VpResponse(agent, protocolRequest, origin) ) } catch (shareError) { // The request stays open, so the user can try again or decline setError(shareError instanceof Error ? shareError.message : String(shareError)) } finally { await agent.shutdown() } } return ( {request.origin ?? 'An app'} asks for: {/* On Android the system picker already showed the credential. iOS has no picker, so show what the operating system parsed from the request. */} {request.platform === 'ios' && request.presentmentRequests .flatMap(({ documentRequestSets }) => documentRequestSets.flatMap((set) => set.documentRequests)) .map(({ doctype, namespaces }) => ( {doctype}: {Object.values(namespaces).flatMap(Object.keys).join(', ')} ))} {error && {error}}