# Verifiable credentials

> Issue SD-JWT VC and W3C credentials over OpenID4VCI, revoke them with status lists and verify OpenID4VP presentations, signed by your own keys.

Source: https://bettersupabase.com/docs/concepts/verifiable-credentials

`better-supabase/verifiable-credentials` issues credentials to a wallet and
verifies what a wallet presents back. It speaks SD-JWT VC and the W3C
Verifiable Credentials 2.0 data model (VC-JOSE `vc+jwt`), issues them through
the OpenID4VCI 1.0 pre-authorized code flow, revokes them with Token Status
List and Bitstring Status List, and checks OpenID4VP 1.0 presentation
responses. It has no tables and no runtime dependency: the crypto is WebCrypto
and the keys stay yours. It runs on every WinterTC runtime. The versions are
pinned in `SPEC_PINS.openid4vci`, `SPEC_PINS.openid4vp`, `SPEC_PINS.sdJwt`,
`SPEC_PINS.sdJwtVc`, `SPEC_PINS.tokenStatusList`, `SPEC_PINS.vcDataModel` and
`SPEC_PINS.bitstringStatusList`.

```ts
import {
  createCredentialIssuer,
  memoryIssuanceStore,
  qualificationSource,
  signerFromSigningKeys,
  toResponse,
} from "better-supabase/verifiable-credentials";
```

## Sign with the signing-keys block [#sign-with-the-signing-keys-block]

`signerFromSigningKeys(signer)` adapts the `Signer` of the
[signing-keys block](/docs/blocks/signing-keys) to a `CredentialSigner`. The
credentials are signed in the database-held key's algorithm and carry its `kid`,
so a verifier finds the key in your JWKS. If the key rotates while a signer is
alive, signing fails with `CREDENTIAL_KEY_ROTATED` instead of issuing a token
under a stale `kid`. `signerFromJwk(privateJwk, kid)` signs with a key you hold
yourself.

## Issue over OpenID4VCI [#issue-over-openid4vci]

A source says what a credential contains for a subject. `qualificationSource`,
`courseCompletionSource` and `passSource` read the facts of the
[qualifications](/docs/blocks/qualifications), [courses](/docs/blocks/courses)
and [passes](/docs/blocks/passes) blocks through a `lookup` function you write,
and `defineCredentialSource` builds one for anything else. Claims a lookup leaves
out are left out of the credential, not issued empty.

```ts
const issuer = createCredentialIssuer({
  issuer: "https://issuer.example",
  signer: await signerFromSigningKeys(signingKeys),
  sources: [
    qualificationSource({
      vct: "https://issuer.example/credentials/qualification",
      validFor: Temporal.Duration.from({ hours: 24 * 365 }),
      lookup: async (subject) => loadQualification(subject),
    }),
  ],
  store: memoryIssuanceStore(() => "https://issuer.example/status/1"),
  endpoints: {
    token: "https://issuer.example/token",
    credential: "https://issuer.example/credential",
    nonce: "https://issuer.example/nonce",
  },
});

const { uri } = await issuer.createOffer({
  configurationId: "qualification",
  subject: "qualification:q1",
  txCode: "4821",
});
```

`uri` is the `openid-credential-offer://` link for a QR code. Send the optional
`txCode` out of band. The issuer is transport-free: mount `token`, `nonce` and
`credential` on any route and return `toResponse(result)`. Publish
`issuer.metadata()` at `/.well-known/openid-credential-issuer` and
`issuer.authorizationServerMetadata()` at
`/.well-known/oauth-authorization-server`.

A pre-authorized code works once. The token endpoint burns it before it checks
the transaction code, so a wrong code cannot be guessed against. The credential
endpoint verifies the wallet's `openid4vci-proof+jwt` (audience, `c_nonce`, key
in the header) and binds the credential to that key through `cnf`. When a proof
or the signing fails, the grant is given back so the wallet can retry.

`memoryIssuanceStore` keeps offers, tokens, nonces and status indexes in
process and is for tests and single-instance demos. Production implements the
`IssuanceStore` port over your own table, with an atomic `redeemOffer` for the pre-authorized code.

## Credentials without a flow [#credentials-without-a-flow]

`issueSdJwt` and `issueJwtVc` sign a credential directly. `issueSdJwt` takes the
claims, the names that become selectively disclosable, the holder's public key
for `cnf` and an optional `status` reference. `presentSdJwt` makes the holder side:
it reveals the disclosures a verifier asked for and adds a key binding JWT over
the audience, nonce and the presented token's hash. `verifySdJwt` checks the
issuer signature, every disclosure digest, expiry, `vct` and the key binding.

## Revocation [#revocation]

`StatusList` is a bit array. Set an index and sign the list as a Token Status
List with `signStatusListToken` (a `statuslist+jwt`), or as a Bitstring Status
List credential with `signBitstringStatusListCredential`. A credential points at
its entry through `status.status_list` (SD-JWT VC) or `bitstringEntry` (W3C).
`readStatus` and `readBitstringStatus` verify a published list and read an index.
A Bitstring Status List has at least 131,072 entries, as the specification
requires, so create it with `StatusList.create(131072)` or larger and
`toEncodedList()` refuses a smaller one. Decoding stops at 16 MiB.

## Verify OpenID4VP presentations [#verify-openid4vp-presentations]

```ts
const { request, params, uri } = createPresentationRequest({
  responseUri: "https://verifier.example/present",
  credentials: [
    {
      id: "qualification",
      vct: "https://issuer.example/credentials/qualification",
      claims: ["qualification"],
    },
  ],
});
```

`request` is what you store, keyed by `state`. `uri` is the `openid4vp://` link.
The client identifier is `redirect_uri:<responseUri>` and the response mode is
`direct_post`. When the wallet posts back, call
`verifyPresentationResponse(response, { request, issuers })`, where `issuers`
returns the JWKS of an issuer or `undefined` to reject it. It never throws on a
bad response: the outcome is `{ ok: false, code, message }` for a wrong `state`, a
missing credential, an issuer `issuers` does not trust, a
missing requested claim, a key binding for another audience or nonce, or (when
you pass `fetchStatusList`) a revoked credential.

## Passkeys and contact cards [#passkeys-and-contact-cards]

Two smaller additions ship with it. The [mfa block](/docs/blocks/mfa) registers
passkeys and verifies them for step-up through Supabase Auth's WebAuthn support
(`SPEC_PINS.webauthn`). The [contacts block](/docs/blocks/contacts) imports and
exports vCard 4.0 (`SPEC_PINS.rfc6350`).

## Not included [#not-included]

The block that stores offers, status lists and issued credentials in Postgres is
not part of this release; `IssuanceStore` is the seam for it. Other credential
formats (mdoc, JSON-LD data integrity proofs) and the authorization code flow of
OpenID4VCI are out of scope.