@ixo/credentials-kit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +469 -2
  2. package/dist/chunk-2SF56NEJ.js +1053 -0
  3. package/dist/chunk-2SF56NEJ.js.map +1 -0
  4. package/dist/chunk-46VRPQPD.js +176 -0
  5. package/dist/chunk-46VRPQPD.js.map +1 -0
  6. package/dist/chunk-AQ3IINHC.js +138 -0
  7. package/dist/chunk-AQ3IINHC.js.map +1 -0
  8. package/dist/chunk-FSKCDHZ7.js +613 -0
  9. package/dist/chunk-FSKCDHZ7.js.map +1 -0
  10. package/dist/chunk-I6GBYFIJ.js +89 -0
  11. package/dist/chunk-I6GBYFIJ.js.map +1 -0
  12. package/dist/chunk-JBUX6V2Q.js +210 -0
  13. package/dist/chunk-JBUX6V2Q.js.map +1 -0
  14. package/dist/chunk-Q3MZNMCK.js +183 -0
  15. package/dist/chunk-Q3MZNMCK.js.map +1 -0
  16. package/dist/chunk-RKKRO4ZK.js +133 -0
  17. package/dist/chunk-RKKRO4ZK.js.map +1 -0
  18. package/dist/chunk-STMMFNVW.js +391 -0
  19. package/dist/chunk-STMMFNVW.js.map +1 -0
  20. package/dist/chunk-VXKONZK4.js +189 -0
  21. package/dist/chunk-VXKONZK4.js.map +1 -0
  22. package/dist/chunk-WIEGZFUO.js +337 -0
  23. package/dist/chunk-WIEGZFUO.js.map +1 -0
  24. package/dist/chunk-WND2LZ4O.js +66 -0
  25. package/dist/chunk-WND2LZ4O.js.map +1 -0
  26. package/dist/chunk-XREZY53L.js +260 -0
  27. package/dist/chunk-XREZY53L.js.map +1 -0
  28. package/dist/chunk-ZZY4WGQ6.js +13 -0
  29. package/dist/chunk-ZZY4WGQ6.js.map +1 -0
  30. package/dist/did.d.ts +182 -0
  31. package/dist/did.js +6 -0
  32. package/dist/did.js.map +1 -0
  33. package/dist/display.d.ts +117 -0
  34. package/dist/display.js +5 -0
  35. package/dist/display.js.map +1 -0
  36. package/dist/errors-BKQmNpXX.d.ts +11 -0
  37. package/dist/holder.d.ts +85 -0
  38. package/dist/holder.js +8 -0
  39. package/dist/holder.js.map +1 -0
  40. package/dist/http-BhuJ_-RZ.d.ts +25 -0
  41. package/dist/index.d.ts +15 -0
  42. package/dist/index.js +16 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/jose.d.ts +134 -0
  45. package/dist/jose.js +4 -0
  46. package/dist/jose.js.map +1 -0
  47. package/dist/oid4vci.d.ts +428 -0
  48. package/dist/oid4vci.js +11 -0
  49. package/dist/oid4vci.js.map +1 -0
  50. package/dist/oid4vp.d.ts +416 -0
  51. package/dist/oid4vp.js +11 -0
  52. package/dist/oid4vp.js.map +1 -0
  53. package/dist/profile.d.ts +218 -0
  54. package/dist/profile.js +4 -0
  55. package/dist/profile.js.map +1 -0
  56. package/dist/sdjwt.d.ts +133 -0
  57. package/dist/sdjwt.js +5 -0
  58. package/dist/sdjwt.js.map +1 -0
  59. package/dist/sender.d.ts +8 -0
  60. package/dist/sender.js +7 -0
  61. package/dist/sender.js.map +1 -0
  62. package/dist/status.d.ts +108 -0
  63. package/dist/status.js +7 -0
  64. package/dist/status.js.map +1 -0
  65. package/dist/util-DOTEZgTt.d.ts +3 -0
  66. package/dist/verify.d.ts +144 -0
  67. package/dist/verify.js +9 -0
  68. package/dist/verify.js.map +1 -0
  69. package/dist/webhook.d.ts +62 -0
  70. package/dist/webhook.js +7 -0
  71. package/dist/webhook.js.map +1 -0
  72. package/package.json +168 -4
package/README.md CHANGED
@@ -1,3 +1,470 @@
1
- # Temporary Holding Version
1
+ # @ixo/credentials-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared library of IXO Credentials: SD-JWT VC (`dc+sd-jwt`, Ed25519: signs with JWS `alg`
4
+ `EdDSA`, accepts `EdDSA` and `Ed25519`), OpenID4VCI 1.0 (pre-authorized code flow), OpenID4VP 1.0
5
+ (signed request objects, DCQL subset, encrypted `direct_post.jwt` responses with `A128GCM` or
6
+ `A256GCM`, error responses, `openid4vp://` wallet links), IETF Token Status List, `did:ixo` /
7
+ `did:web` resolution and the signed webhook between the Credentials worker and the Hub. The
8
+ normative profile is `docs/profile.md` (SPEC §3, §13.3).
9
+
10
+ - ESM only, TypeScript types included, `sideEffects: false`.
11
+ - Runs unchanged in Cloudflare workerd, Node 22+ and browsers: WebCrypto, `CompressionStream`,
12
+ `fetch`; no Node built-ins. Runtime dependencies: `@noble/curves`, `@noble/hashes`.
13
+ - Everything that reaches the network takes `fetch` (and optionally a cache) as an argument,
14
+ never follows redirects and always times out. A Cloudflare KV binding works as a cache as is.
15
+ - Every `now` option is milliseconds since the epoch (`Date.now()`, the clock you read; a value
16
+ that looks like seconds throws `TypeError`); every timestamp the kit returns or puts in an API
17
+ shape (`expiresAt`, `created_at`, JWT claims) is unix seconds.
18
+ - Outbound URLs and did:web hosts must be https on a public host (`isFetchableUrl`, exported
19
+ from `/did`). Loopback, private, link-local, CGNAT, unique-local and multicast IP literals
20
+ (also inside 6to4, NAT64, Teredo, IPv4-mapped and IPv4-translated IPv6), single-label names
21
+ (`intranet`) and `localhost`, `*.localhost`, `*.local`, `*.internal`, `*.lan`, `*.home.arpa`,
22
+ `*.home`, `*.corp`, `*.intranet`, `*.private`, `*.localdomain` are refused unless you pass
23
+ `allowLocal: true` (local development only; `http://localhost` needs `allowHttp: true` as
24
+ well). Names are not resolved: DNS rebinding to a private address still needs the runtime's
25
+ egress policy (Cloudflare Workers cannot reach private networks; a Node or browser wallet
26
+ should add its own).
27
+
28
+ | Subpath | What |
29
+ | ------------------------------ | --------------------------------------------------------------------------- |
30
+ | `@ixo/credentials-kit/jose` | Ed25519 compact JWS, Ed25519 key helpers, `Signer` |
31
+ | `@ixo/credentials-kit/did` | DID documents, `did:ixo` (Blocksync) and `did:web` resolvers, key selection |
32
+ | `@ixo/credentials-kit/sdjwt` | SD-JWT parse / serialize / select, `sd_hash`, the Vault record and its CID |
33
+ | `@ixo/credentials-kit/status` | Token Status List: bits, tokens, verification |
34
+ | `@ixo/credentials-kit/verify` | `verifyPresentation` (verifier), `verifyIssuedCredential` (wallet) |
35
+ | `@ixo/credentials-kit/oid4vci` | issuer metadata, offers, proof JWTs, `redeemOffer` (wallet, checks result) |
36
+ | `@ixo/credentials-kit/oid4vp` | request objects, DCQL subset, presentations, responses, errors, links |
37
+ | `@ixo/credentials-kit/webhook` | `x-ixo-signature` sign / verify |
38
+ | `@ixo/credentials-kit/profile` | constants (UCAN resource, abilities, media types, events) and shared types |
39
+ | `@ixo/credentials-kit/display` | card look: validate an issuer's `display`, WCAG contrast, the neutral card |
40
+ | `@ixo/credentials-kit/holder` | the person's block list at the Credentials worker (`credentials/hold`) |
41
+
42
+ The root export re-exports all of them.
43
+
44
+ ## Keys and resolvers (every role)
45
+
46
+ ```ts
47
+ import {
48
+ ed25519SeedFromMnemonic,
49
+ signerFromEd25519Seed,
50
+ base58btc,
51
+ } from '@ixo/credentials-kit/jose';
52
+ import {
53
+ composeResolvers,
54
+ createIxoResolver,
55
+ createWebResolver,
56
+ } from '@ixo/credentials-kit/did';
57
+
58
+ const seed = ed25519SeedFromMnemonic(env.ISSUER_ED25519_MNEMONIC); // IXO service convention
59
+ const probe = signerFromEd25519Seed(seed, 'tmp');
60
+ const signer = signerFromEd25519Seed(
61
+ seed,
62
+ `${env.ISSUER_DID}#${base58btc.encode(probe.publicKey)}`
63
+ );
64
+
65
+ const resolver = composeResolvers(
66
+ createIxoResolver({
67
+ blocksyncUrl: env.BLOCKSYNC_GRAPHQL_URL,
68
+ fetch,
69
+ cache: env.CACHE_KV,
70
+ }),
71
+ // Local development only: http on localhost AND local hosts. Production passes neither.
72
+ createWebResolver({ fetch, allowHttp: isLocal, allowLocal: isLocal })
73
+ );
74
+ ```
75
+
76
+ A resolver returns `null` for a DID that does not exist and throws `ResolverUnavailable` when its
77
+ source cannot be read: answer 503, never "no".
78
+
79
+ did:web follows the [did:web method spec](https://w3c-ccg.github.io/did-method-web/) (`didWebUrl`,
80
+ used by the web resolver): `did:web:<host>` → `https://<host>/.well-known/did.json`,
81
+ `did:web:<host>:<a>:<b>` → `https://<host>/a/b/did.json`, a port percent-encoded
82
+ (`did:web:example.com%3A3000:user:alice` → `https://example.com:3000/user/alice/did.json`). Any
83
+ host works (a subdomain is fine) and any path; the document's `id` must equal the DID. Plain http
84
+ only for `localhost` and only with `allowHttp` + `allowLocal` (local development).
85
+
86
+ did:web is accepted only in canonical form (`parseDidWeb`), so one DID has one spelling: a
87
+ lower-case host, no trailing dot, no percent-encoding in the host except `%3A` before a port, never
88
+ `%3A443` (the port `https` already implies) or a port with a leading zero, no lower-case `%3a`, an
89
+ IDN in punycode, no IP address (the spec: "MUST NOT include IP addresses"), and path segments made
90
+ of DID characters (letters, digits, `.`, `-`, `_`, percent-encoding; never an encoded `/`, `\`,
91
+ `.` or `..`). In a path segment, percent-encoding uses upper-case hex only and never encodes an
92
+ unreserved character (`%61lice` is `alice`, so it is refused; a `~` cannot be written), and a
93
+ `.well-known` segment is refused (it would read the bare host's document). Any other spelling gets
94
+ `null` from `parseDidWeb` and `didWebUrl` (and so resolves to `null`). `parseDidWeb(did)` returns `{ host, port?, authority, path }`.
95
+
96
+ ## Issuer
97
+
98
+ ```ts
99
+ import {
100
+ createStatusListToken,
101
+ newStatusBits,
102
+ setStatusBit,
103
+ } from '@ixo/credentials-kit/status';
104
+ import {
105
+ buildIssuerMetadata,
106
+ buildAuthorizationServerMetadata,
107
+ buildCredentialOffer,
108
+ verifyProofJwt,
109
+ } from '@ixo/credentials-kit/oid4vci';
110
+
111
+ // Status list, published to the Credentials worker (PUT /status/{issuerDid}/{listId}).
112
+ const bits = newStatusBits(16_384);
113
+ setStatusBit(bits, revokedIndex);
114
+ const token = await createStatusListToken({
115
+ bits,
116
+ uri: `${CREDENTIALS_URL}/status/${ISSUER_DID}/${listId}`,
117
+ iss: ISSUER_DID,
118
+ signer,
119
+ ttl: 300,
120
+ exp: nowS + 86_400,
121
+ });
122
+
123
+ // OpenID4VCI: serve both metadata documents (the token endpoint is in the OAuth server
124
+ // metadata), serve the offer at your credential_offer_uri (a fresh code per GET), check the proof.
125
+ const metadata = buildIssuerMetadata({
126
+ credentialIssuer: 'https://host/identity',
127
+ configurations: { IdentityLevel1: { vct } },
128
+ });
129
+ const asMetadata = buildAuthorizationServerMetadata({
130
+ issuer: 'https://host/identity',
131
+ });
132
+ const offer = buildCredentialOffer({
133
+ credentialIssuer: 'https://host/identity',
134
+ configurationIds: ['IdentityLevel1'],
135
+ preAuthorizedCode,
136
+ });
137
+ const proof = await verifyProofJwt(body.proofs.jwt[0], {
138
+ audience: 'https://host/identity',
139
+ nonce: checkMyNonce,
140
+ resolver,
141
+ });
142
+ if (
143
+ proof.did !== verification.subjectDid
144
+ ) /* 400 invalid_proof: subject binding */ ;
145
+ // mint with cnf = { jwk: proof.jwk }
146
+ ```
147
+
148
+ `verifyProofJwt` throws `Oid4vciError` whose `code` is the spec's error (`invalid_proof` /
149
+ `invalid_nonce`), ready for the error response. It checks the signature before it calls your
150
+ `nonce` callback, so a single-use nonce store is only consulted for a proof the holder signed.
151
+
152
+ ## Verifier
153
+
154
+ ```ts
155
+ import {
156
+ createAuthorizationRequest,
157
+ decodeOwnAuthorizationRequest,
158
+ generateResponseKeyPair,
159
+ parseAuthorizationResponse,
160
+ verifyAuthorizationResponse,
161
+ } from '@ixo/credentials-kit/oid4vp';
162
+ import { createMemoryCache } from '@ixo/credentials-kit/did';
163
+
164
+ const { publicJwk, privateJwk } = await generateResponseKeyPair(); // keep privateJwk with the request
165
+ const request = await createAuthorizationRequest({
166
+ signer,
167
+ clientDid: MY_DID,
168
+ responseUri: `https://me/response/${id}`,
169
+ nonce: randomNonce,
170
+ state: id,
171
+ exp: nowS + 900,
172
+ responseEncryptionJwk: publicJwk,
173
+ dcql: {
174
+ credentials: [
175
+ {
176
+ id: 'age',
177
+ format: 'dc+sd-jwt',
178
+ meta: { vct_values: [VCT] },
179
+ claims: [
180
+ { path: ['age_over_18'], values: [true] },
181
+ { path: ['iss'], values: TRUSTED_ISSUERS },
182
+ ],
183
+ },
184
+ ],
185
+ },
186
+ });
187
+ // … deposit at the Credentials worker; later, at the response URI:
188
+ const { vpToken, state } = await parseAuthorizationResponse(await req.text(), {
189
+ decryptionKey: privateJwk,
190
+ request: decodeOwnAuthorizationRequest(request), // makes the key mandatory
191
+ });
192
+ const { ok, results, holderAuthenticated } = await verifyAuthorizationResponse({
193
+ vpToken,
194
+ request,
195
+ state, // required when the request has one
196
+ policyFor: () => ({
197
+ issuers: TRUSTED_ISSUERS,
198
+ vcts: [VCT],
199
+ expectedSubject: subjectDid,
200
+ }),
201
+ deps: { resolver, fetch, statusCache: env.CACHE_KV },
202
+ });
203
+ if (!holderAuthenticated) {
204
+ // Could have come from anyone holding the (public) request object: keep the request open.
205
+ } else {
206
+ // The person answered for THIS nonce: record ok / the failure code, once.
207
+ }
208
+ ```
209
+
210
+ Anyone who can read a request object (its `request_uri` is public) can build a response that
211
+ decrypts and carries the right `state`. Only `holderAuthenticated: true` (trusted issuer, valid
212
+ signature, key binding for this request's nonce and audience, AND `expectedSubject` given and
213
+ matched) proves the person you asked sent it; close your request only then
214
+ (`examples/verifier` shows how). Without `expectedSubject`, `holderAuthenticated` is always
215
+ `false`: anyone holding a credential from an accepted issuer could answer. `keyBindingValid`
216
+ reports the weaker fact ("the holder of the presented credential signed for this nonce"); never
217
+ close a request on it alone. A subject-less flow needs a different binding (OpenID4VP §14.2:
218
+ a redirect URI with a response code tied to the browser session).
219
+
220
+ `verifyPresentation` (in `/verify`) is the same check for one presentation outside OpenID4VP. When
221
+ key binding is required (the default) it needs `audience` and `nonce` (a `TypeError` otherwise):
222
+ pass the nonce you issued for this check, or a callback `nonce => boolean` that accepts each
223
+ issued nonce once. There is no "accept any nonce". It returns `{ ok: false, code,
224
+ holderAuthenticated, keyBindingValid }` for every refusal and throws `VerificationUnavailable` when the issuer's DID
225
+ document or the status list cannot be read or verified (fail closed: answer 503). Status lists are
226
+ fetched without following redirects (a deliberate rule; TSL §8.2 only RECOMMENDS following them).
227
+
228
+ ## Wallet
229
+
230
+ ```ts
231
+ import { redeemOffer, sendNotification } from '@ixo/credentials-kit/oid4vci';
232
+ import {
233
+ authorizationErrorFor,
234
+ createAuthorizationResponse,
235
+ createPresentation,
236
+ matchCredential,
237
+ parseWalletLink,
238
+ sendAuthorizationError,
239
+ verifyAuthorizationRequest,
240
+ } from '@ixo/credentials-kit/oid4vp';
241
+ import {
242
+ parseSdJwt,
243
+ credentialRecordFromSdJwt,
244
+ credentialCid,
245
+ } from '@ixo/credentials-kit/sdjwt';
246
+
247
+ // Issuance.
248
+ // Offers travel by reference: fetched fresh on every call (single-use code), never cached.
249
+ // redeemOffer checks what it receives (verifyIssuedCredential: the issuer's signature, iss, sub =
250
+ // the holder, cnf = the holder's key, vct, validity, disclosures) and throws
251
+ // Oid4vciError('invalid_credential') instead of returning a credential that does not check out.
252
+ const out = await redeemOffer({
253
+ credentialOfferUri: offer.credential_offer_uri,
254
+ signer: userSigner, // kid = a DID URL of the holder; publicKey when it has one
255
+ resolver, // resolves the issuer (and the holder key when the signer has no publicKey)
256
+ // REQUIRED: the DID that deposited the offer. `false` accepts any issuer (unsafe: anyone can
257
+ // mint a credential with your vct; judge out.verified.issuer yourself).
258
+ expectedIssuer: offer.issuer,
259
+ fetch,
260
+ });
261
+ if (out.status === 'issued') {
262
+ const record = credentialRecordFromSdJwt(out.credential, {
263
+ holderDid: userDid,
264
+ issuerName,
265
+ });
266
+ await vault.store({ record, cid: credentialCid(record) });
267
+ if (out.notificationId && out.notificationEndpoint) {
268
+ await sendNotification({
269
+ notificationEndpoint: out.notificationEndpoint,
270
+ accessToken: out.accessToken,
271
+ notificationId: out.notificationId,
272
+ event: 'credential_accepted',
273
+ fetch,
274
+ });
275
+ }
276
+ }
277
+
278
+ // Presentation. A link from a non-Hub verifier: openid4vp://?client_id=…&request_uri=…
279
+ // request_uri must pass isFetchableUrl (https, public host); local development passes
280
+ // { allowHttp: true, allowLocal: true }.
281
+ const link = parseWalletLink(scannedLink);
282
+ // Your fetch: no redirects, a timeout, a size cap. Request objects from scanned links should
283
+ // also be checked with requireExpiry: true below.
284
+ const requestJwt = await fetchRequestObject(link.requestUri);
285
+ let request;
286
+ try {
287
+ request = await verifyAuthorizationRequest(requestJwt, {
288
+ resolver,
289
+ expectedClientId: link.clientId, // §5.10.1: must be identical
290
+ requireExpiry: true,
291
+ });
292
+ } catch (e) {
293
+ // A verified request we cannot answer (credential_sets, claim_sets, …): tell the verifier.
294
+ const error = authorizationErrorFor(e);
295
+ if (error)
296
+ await sendAuthorizationError({ target: e.errorTarget, error, fetch });
297
+ throw e;
298
+ }
299
+ const query = request.dcql.credentials[0];
300
+ const match = matchCredential(query, parseSdJwt(record.credential));
301
+ if (match.ok) {
302
+ const vp = await createPresentation({
303
+ sdjwt: record.credential,
304
+ disclose: match.disclose,
305
+ signer: userSigner,
306
+ audience: request.clientId,
307
+ nonce: request.nonce,
308
+ });
309
+ const { url, body, contentType } = await createAuthorizationResponse({
310
+ request,
311
+ vpToken: { [query.id]: [vp] },
312
+ });
313
+ await fetch(url, {
314
+ method: 'POST',
315
+ headers: { 'content-type': contentType },
316
+ body,
317
+ redirect: 'manual',
318
+ });
319
+ } else {
320
+ // After the person decided (OpenID4VP §15.9: never before, it would reveal what they hold).
321
+ await sendAuthorizationError({
322
+ target: request,
323
+ error: 'access_denied',
324
+ fetch,
325
+ });
326
+ }
327
+ ```
328
+
329
+ Verifiers build the link for other wallets with
330
+ `buildWalletLink({ clientId, requestUri, scheme = 'openid4vp://' })`.
331
+
332
+ ### Blocking a sender (SPEC §16.1)
333
+
334
+ A person can block any issuer or verifier at the Credentials worker (the gateway): its waiting
335
+ offers and requests for the person are closed as declined, and its later deposits are refused
336
+ (the sender gets 403 `blocked`, "This person is not accepting offers or requests from you.").
337
+ One list for issuers and verifiers. A block is of exactly the DID named (every DID is its own
338
+ sender: blocking `did:web:a.example.com` does not block `did:web:b.example.com` or
339
+ `did:web:example.com:x`), and the list's `sender` is that DID. At most 500 per person. Each call needs a fresh single-use UCAN invocation as the person (`credentials/hold` on
340
+ `ixo:credentials`, audience the worker's did:web) bound to that request: you mint it in
341
+ `authorize`, the kit tells you the `{ method, path }` to bind.
342
+
343
+ ```ts
344
+ import { createInvocation, serializeInvocation } from '@ixo/ucan';
345
+ import {
346
+ blockSender,
347
+ listBlocks,
348
+ unblockSender,
349
+ } from '@ixo/credentials-kit/holder';
350
+
351
+ const service = {
352
+ serviceUrl: 'https://credentials.ixo.world',
353
+ fetch,
354
+ authorize: async binding =>
355
+ serializeInvocation(
356
+ await createInvocation({
357
+ issuer: personUcanSigner,
358
+ audience: workerDid, // from <serviceUrl>/.well-known/did.json
359
+ capability: { can: 'credentials/hold', with: 'ixo:credentials' },
360
+ expiration: Math.floor(Date.now() / 1000) + 120,
361
+ facts: [{ ...binding, nonce: crypto.randomUUID() }],
362
+ })
363
+ ),
364
+ };
365
+ const { closed } = await blockSender({ ...service, sender: issuerDid });
366
+ const page = await listBlocks({ ...service, limit: 50 }); // { items, next_cursor }
367
+ await unblockSender({ ...service, sender: issuerDid }); // { sender, removed }
368
+ ```
369
+
370
+ A refusal throws `CredentialsServiceError` (`code` = the worker's error code, `status`,
371
+ `reference`); a network failure, a timeout, a redirect or an answer whose body fails half way is
372
+ `unreachable` (`status` null for all but a redirect). A `sender` that `senderKeyOf` refuses throws
373
+ `TypeError` before anything is sent (such a block could never match).
374
+
375
+ ### Sender keys (SPEC §17.4)
376
+
377
+ `@ixo/credentials-kit/sender` says who a sender is, the way the worker counts caps, blocks and
378
+ catalog entries: every DID is its own sender, exactly as written.
379
+
380
+ - `senderKeyOf(did)`: the DID itself when it is a did:ixo person (`did:ixo:ixo1…`, lower-case
381
+ bech32, `IXO_PERSON_DID`) or entity (`did:ixo:entity:` + 32 lower-case hex, `IXO_ENTITY_DID`),
382
+ or a did:web in canonical form (`parseDidWeb`). `did:web:example.com`, `did:web:a.example.com`
383
+ and `did:web:example.com:user:alice` are three senders. Null for any other did:ixo
384
+ (`did:ixo:IXO1…`, `did:ixo:anything`) or did:web spelling (refuse it), and for other methods.
385
+ The bech32 checksum is not verified (no bech32 decoder in the kit); a DID with a bad checksum
386
+ never resolves, so it never deposits anything.
387
+
388
+ Wallets: show a sender by its DID (offers and requests carry `sender_key`, the sender's DID), and
389
+ before storing a credential check `senderKeyOf(iss)` (the exact DID) against the person's blocks.
390
+
391
+ ### Card look (SPEC §15.1)
392
+
393
+ Pure functions, no fetch. The look comes only from the issuer's catalog entry
394
+ (`types[].display`, and `logo_uri` / `color` for its mark); an offer carries none (SPEC §16.1).
395
+ Validate what the entry says, then draw the card (a neutral one from the DID when nothing is
396
+ set). The image URLs are only validated: fetch them yourself under your own
397
+ rules (png, jpeg or webp; never SVG). `parseCardUri` takes an image address only when it is https
398
+ on the default port with no query string and no fragment (no credentials, a public host, at most
399
+ 512 characters): a per-person query string would turn the image into a tracking pixel, and an odd
400
+ port would turn your image proxy into a fetch relay. `{ allowHttp: true, allowLocal: true }` (local
401
+ development only) also takes http and any port on a loopback host, still without a query string
402
+ or fragment.
403
+
404
+ ```ts
405
+ import {
406
+ cardLook,
407
+ markLook,
408
+ parseCardDisplay,
409
+ } from '@ixo/credentials-kit/display';
410
+
411
+ const type = entry.types.find(t => t.vct === credentialVct);
412
+ const display = parseCardDisplay(type?.display); // bad and unknown members dropped
413
+ const look = cardLook({
414
+ display,
415
+ issuerDid: entry.did,
416
+ issuerName: entry.name,
417
+ });
418
+ // { background: '#0b6b7e', foreground: '#ffffff', monogram: 'IS', pattern: 3 }
419
+ const mark = markLook({ color: entry.color, did: entry.did, name: entry.name });
420
+ // { color: '#5b21b6', monogram: 'YR' }
421
+ ```
422
+
423
+ ## Choices to know about
424
+
425
+ - Disclosures are processed per RFC 9901 §7.1, recursively (nested objects, array elements);
426
+ `_sd`, `_sd_alg` and `...` never reach `claims`; a repeated digest, an unreferenced disclosure,
427
+ or a claim named `__proto__` / `constructor` / `prototype` fails. `iss`, `nbf`, `exp`, `cnf`,
428
+ `vct`, `vct#integrity`, `aka_vcts` and `status` are never accepted from a disclosure
429
+ (SD-JWT VC §2.2.2.3); `sub` and `iat` may be disclosed. Every credential needs `exp` and a
430
+ `sub`.
431
+ - DCQL: unknown members are ignored, duplicate claim queries dropped, `multiple: true` answered
432
+ with one presentation. `credential_sets`, `claim_sets`, `trusted_authorities`, multi-segment
433
+ paths and formats other than `dc+sd-jwt` are refused with `unsupported_request` (and
434
+ `error.feature`). Claim paths are exact: a one-segment path names the TOP-LEVEL claim only.
435
+ `matchCredential`, `selectDisclosures` and `createPresentation` never release a nested
436
+ disclosure that happens to carry the same name, and the Vault record
437
+ (`credentialRecordFromSdJwt`) keeps nested disclosures inside their parent's value. A requested
438
+ `sub` or `iat` is answered from the issuer-signed credential.
439
+ - The Vault index slot (`stateKeyForVct`) is chosen by the FULL `vct`, exact match: known types
440
+ keep their slot (`identitylevel1`, `kycamllevel1`), any other `vct` gets `vct-<hash>`.
441
+ - Request objects without `exp`, `iat` or `aud` are accepted by the wallet (the spec's examples
442
+ have none); each one that is present is enforced (lifetime at most 24 h, `aud` the static
443
+ `https://self-issued.me/v2`). The verifier side always writes all three.
444
+ - Status values other than 0 are reported as `revoked`; an index outside the list is `no_status`.
445
+ - Webhooks: a signature older than 5 minutes or more than 60 s in the future is refused. A
446
+ signed webhook still verifies again within that window: de-duplicate on the event `id`.
447
+
448
+ ## Errors
449
+
450
+ Every error extends `CredentialsKitError` with a constant `code` (`JoseError`, `SdJwtError`,
451
+ `StatusListError`, `Oid4vciError`, `Oid4vpError`, `ResolverUnavailable`,
452
+ `VerificationUnavailable`). Messages are fixed sentences: they never contain credentials, keys or
453
+ tokens, so they are safe to log. They are for logs, not for end users.
454
+
455
+ ## Development
456
+
457
+ ```sh
458
+ pnpm --filter @ixo/credentials-kit build # tsup → dist/ (ESM + .d.ts)
459
+ pnpm --filter @ixo/credentials-kit type-check
460
+ pnpm --filter @ixo/credentials-kit lint
461
+ pnpm --filter @ixo/credentials-kit test # vitest, Node
462
+ ```
463
+
464
+ `test/self-interop.test.ts` mints a credential and a status list with Self's own issuer code when
465
+ the ixo-self checkout is present (`UPDATE_SELF_FIXTURE=1` refreshes the committed fixture) and
466
+ always checks the committed fixture.
467
+
468
+ Install from npm: `pnpm add @ixo/credentials-kit` (or `npm install`). Inside this repo the worker
469
+ and the examples use `workspace:*`. How a version is released: `packages/kit/RELEASING.md` in the
470
+ repository.