Hold and share credentials

How the Auth Hub wallet stores and shares IXO credentials with the person's consent, how a person blocks a sender, and how a person or team runs their own wallet with the kit and the person's own keys, without the Hub.

This page explains the wallet side of IXO Credentials: how the Auth Hub's /credentials pages collect, store and share credentials for a person, how a person blocks a sender, and how a person or team runs their own wallet (a self-sovereign wallet) with @ixo/credentials-kit, the person's own keys and the standard links, with no Hub involved. Read it if you build a wallet, work on an app that holds user keys, or need to know what the Hub does on a person's behalf.

Both kinds of wallet use the same Credentials worker endpoints, the same keys and the same Vault format. Issuers and verifiers cannot tell them apart.

What a verifier always sees

Whatever a wallet discloses, every presentation also carries, in the clear: the person's IXO account ID (sub, the same at every service), the holder key (cnf.jwk, also the same everywhere), the status reference (list URL and index), the issuer, the dates, and every claim the issuer did not make selectively disclosable. This is a property of the credential format, not something the wallet chooses. The Hub's sharing screen does not list it; see What a verifier always sees in the overview.

The Auth Hub wallet

What the person sees

PageContents
HUB_URL/credentialsNeeds you (offers and requests waiting, each with a "Review" button; the first three, then "Show N more"), Your credentials, a row to Sharing, and the latest activity
HUB_URL/credentials/offers/OFFER_IDThe card as it will look, who offers it, what it holds; "Save to my credentials" or "No thanks"
HUB_URL/credentials/requests/REQUEST_IDWho is asking (mark and name); why, in the verifier's own words (the request's purpose); the credential used; the details that will leave, with their values; what is not shared, in one line; "Just this time" or "Always for NAME" with its end date; "Share" or "Don't share"
HUB_URL/credentials/items/CREDENTIAL_IDWhat a credential contains, who issued it, when it expires, whether it is revoked, and who it was shared with
HUB_URL/credentials/sharingServices always allowed (standing consents, each with its end date and "Stop"), the switch Save new credentials automatically, and Blocked (blocked senders, each with the date and "Unblock")
HUB_URL/credentials/activityEvery action the Hub took, manual or automatic, in plain words, blocks and unblocks included

Before declining an offer ("No thanks") or a request ("Don't share") is final, the Hub offers a choice "Also block NAME" (off by default).

Names, logos and card looks come only from the sender's catalog entry: an issuer's name, logo_uri, color and types[].display, a verifier's name, logo_uri and color. They are what the sender says about itself, and the Hub shows them as given, for every sender alike. A sender without an entry gets a neutral mark and card. The Hub never shows a web address from a sender (its url, credential_issuer or any other), not as a link and not as text to copy: a harmful link would be blamed on IXO. It loads logos and card images only through its own media proxy, which takes png, jpeg or webp. A request's purpose is the one piece of text that comes with the request. See What protects a person.

How the Hub keeps up to date

The overview reads only the Hub's own credentials database, kept apart from its account database: a cached copy of the person's pending offers and requests and of their Vault index. The cache is refreshed by the worker's signed webhook, by the page asking with GET /api/credentials/overview?sync=1 (at most once a minute per person; sent when the page opens or the tab becomes visible), and after every action the Hub takes. Webhook work is handed to the person's own Durable Object and runs from its alarm with retries, never in the background of the webhook request.

Automatic saving

Off by default, and never for an issuer the person blocked. The Hub saves an offered credential without asking only when:

  • the person turned on Save new credentials automatically (any issuer, by the owner's decision), or
  • the offer carries an offer consent: a UCAN delegation signed by the person to the issuer that deposited the offer, ability credentials/offer on ixo:credentials, the offer's type in nb.vct, unexpired. The Hub verifies it itself. Self (not live yet) will ask for one when a verification starts.

Automatic saving is capped at 10 credentials per issuer per person per day. Everything else waits for the person. Before storing, the Hub checks the credential with the kit's verifyIssuedCredential (signature, issuer = the DID that deposited the offer, sub = the person, cnf = the person's key, the offered type, validity) and a readable status list that does not say revoked; a credential that fails is not stored and the activity log says so.

Standing consents ("Always for a service")

"Just this time" is selected by default. When the person approves a request with "Always for NAME" chosen and the service accepts the answer, the Hub signs a UCAN delegation from the person to that service's DID: ability credentials/present on ixo:credentials, nb: { vct, claims, issuers }, expiring after CREDENTIALS_CONSENT_DAYS (180 by default). The issuers are pinned: automatic sharing uses only credentials from the issuer(s) recorded when the consent was created. A later request is answered without asking only if it is from the same service, every type and claim is covered, and the consent has not expired or been stopped. Automatic sharing is capped at 20 per consent per day, and each one is logged. The person sees each consent on the Sharing page with its end date and can stop it at any time. Blocking a service stops its standing consents. The service never sees the delegation.

Actions, logs and audit

Saving, sharing, declining, blocking and unblocking are idempotent: repeating a finished action answers with the same result and already: true. Once a presentation may have reached the service it is recorded as sent, with the service's answer. Every action writes a line to the person's activity log and an audit row in the same database transaction. Audit rows record which credential (opaque id, type, issuer) and which claim names were shared, never claim values; opening a stored credential is audited too.

The credentials device

To read and write the Vault without the person's browser, the Hub runs one Durable Object per person: a device of that person on IXO Matrix, named "IXO credentials wallet", visible in the person's device list. Its rules:

  • It never resets the person's encryption. It adopts an existing setup only after the recovery passphrase reproduces the secret-storage check value. On an account with no encryption at all it sets up the complete set (secret storage, all cross-signing keys, key backup) so other apps adopt it.
  • It stops, and writes nothing, if the setup is half finished (vault_needs_attention).
  • It puts each message key in the key backup before sending.
  • It keeps no passwords. It stores only its sign-in token, device id, encryption state and backup key, encrypted with a Hub secret.
  • It reads credentials only in memory, only when needed.

The Hub can act as a wallet only for people whose DID signing key and Matrix mnemonic it holds (accounts it created). Everyone else uses a self-sovereign wallet.

The self-sovereign path

A person, or a team building an app, can run a complete wallet without the Hub: the kit, the person's own keys, the Credentials worker's endpoints and the standard OpenID links. Nothing in the system requires the Hub. The examples/e2e/e2e.mjs script in the ixoworld/ixo-credentials repository (private for now) plays such a wallet end to end against the real worker (everything but the Vault write).

What the wallet needs

NeedDetail
The person's DIDdid:ixo:ixo1…
A DID signing keyAn Ed25519 key listed under authentication on that DID document. It signs UCAN invocations to the worker, issuance proofs and presentations.
A Matrix session with encryptionSigned in as the person, able to read and write the Vault room
A DID resolverThe kit's createIxoResolver (did:ixo on IXO Blocksync: https://devnet-blocksync-graphql.ixo.earth/graphql, https://testnet-blocksync-graphql.ixo.earth/graphql or https://blocksync-graphql.ixo.earth/graphql) and createWebResolver, joined with composeResolvers
The kitnpm install @ixo/credentials-kit@0.1.1 @ixo/ucan (pin an exact version)

For an Auth Hub user, the Hub's sign-in exchange gives an app these keys when it asks for them: the ucan scope returns the DID signing key's mnemonic, the matrix scope the Matrix mnemonic, user id, room id and homeserver.

Steps

  1. List offers and requests. Poll GET /v1/offers?status=pending and GET /v1/requests?status=pending (answers are { items, next_cursor }). Each call carries a fresh UCAN invocation signed with the person's key: ability credentials/hold, resource ixo:credentials, audience the worker's did:web, at most 300 seconds, used once, with the fact { method: 'GET', path: '/v1/offers' } for that exact request.

  2. Collect a credential. Read the offer with GET /v1/offers/OFFER_ID (as the subject you get credential_offer_uri or credential_offer, and consent_token when the issuer attached a valid consent). Then redeem it:

    import { redeemOffer, sendNotification } from '@ixo/credentials-kit/oid4vci';
    
    const out = await redeemOffer({
      credentialOfferUri: offer.credential_offer_uri, // fetched fresh: a new single-use code
      signer: personSigner, // kid = the person's key on their DID document
      resolver,
      expectedIssuer: offer.issuer, // the DID that deposited the offer
      fetch,
    });

    redeemOffer checks the credential with verifyIssuedCredential (issuer signature, iss, sub = the person, cnf = the person's key, type, validity, disclosures) and throws invalid_credential instead of returning one that fails. An offer that needs a transaction code is refused (tx_code_unsupported).

  3. Store it. Build the Vault record with credentialRecordFromSdJwt(credential, { holderDid }) and its id with credentialCid(record). Send an encrypted ixo.credential event, then add an entry to the ixo.credential.index state event at the key stateKeyForVct(vct), unless an entry with the same CID exists. stateKeyForVct maps the exact vct: known types keep their slots (identitylevel1, kycamllevel1), any other type gets vct- plus 32 hex characters of its SHA-256. Refuse a room without encryption.

  4. Close the offer. Send the issuer credential_accepted with sendNotification when it returned a notificationId, then POST /v1/offers/OFFER_ID/status with { "status": "accepted" } (or declined).

  5. Answer a request. Read it with GET /v1/requests/REQUEST_ID, or take an openid4vp://?client_id=…&request_uri=… link a verifier showed and parse it with parseWalletLink, then fetch the request object yourself (no redirects, a timeout, a size cap). Verify it with verifyAuthorizationRequest(jwt, { resolver, expectedClientId: link.clientId, requireExpiry: true }), show the person who is asking (name and logo from the verifier's catalog entry), why (the request's purpose), which details will leave with their values, and which will not, find a credential with matchCredential, build the presentation with createPresentation (audience = the request's client_id, nonce = its nonce) and send it with createAuthorizationResponse. If the person says no, send access_denied with sendAuthorizationError, only after they decided. Then set a broker request to answered or declined.

For local development only, pass allowHttp: true and allowLocal: true to the kit calls that reach localhost; production passes neither.

What a self-sovereign wallet gets

Auth Hub walletSelf-sovereign wallet
Offers and requests for the personYesYes, same endpoints
Requests from any OpenID4VP verifierThrough the brokerThrough the broker or openid4vp:// links
Notice when something arrivesSigned webhook from the workerNone; poll the lists
Automatic saving and answeringBuilt in, off by defaultOnly if the app builds it, under the same rules
Activity logBuilt inThe app must keep one
Block a sender"Also block NAME" when declining; Sharing pagePOST /v1/blocks as the person; the same list

If a person uses both, both may act on the same item. The Vault de-duplicates a credential by its CID, and the inbox item ends up closed either way.

Block a sender

A person can block any issuer or verifier. The block is kept at the Credentials worker, so it holds for every wallet the person uses. One list covers issuers and verifiers.

What a block does:

  • The worker refuses every new offer and request from that sender for this person with 403 blocked and the message "This person is not accepting offers or requests from you." Nothing is stored and no webhook is sent.
  • It closes every offer and request from that sender already waiting for the person, as declined.
  • A block holds for exactly the DID named. Every DID is its own sender: blocking did:web:a.example.com does not block did:web:b.example.com, did:web:example.com or did:web:example.com:user:alice. A sender with many DIDs is many senders, and the person blocks each one.
  • An offer is not refused for the web address it names. Before saving a credential, the Auth Hub checks the credential's issuer (iss, the exact DID) against the person's blocks.
  • Every block, unblock and refused deposit is recorded in the worker's audit trail.

A person can keep at most 500 blocks; one more is refused with 409 too_many_blocks.

In the Auth Hub, the person blocks when declining ("Also block NAME") and unblocks on the Sharing page. After a block the Hub removes that sender's waiting items at once, stops the standing consents for that service, and never saves automatically from that issuer.

A self-sovereign wallet calls the worker as the person, with a credentials/hold invocation bound to the request, as in the steps above:

CallBodyAnswer
POST /v1/blocks{ "sender": "SENDER_DID" }200 { sender, created_at, closed }; closed is how many waiting items it closed
POST /v1/blocks/remove{ "sender": "SENDER_DID" }200 { sender, removed }; removed is false when there was no block
GET /v1/blocks?cursor=&limit=—{ items: [{ sender, created_at }], next_cursor }

Both writes are idempotent. The kit has helpers for them next to its other holder calls: blockSender, unblockSender and listBlocks. Errors are in the API reference.