@ixo/credentials-kit 0.0.0-stage → 0.1.0
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.
- package/README.md +469 -2
- package/dist/chunk-2SF56NEJ.js +1053 -0
- package/dist/chunk-2SF56NEJ.js.map +1 -0
- package/dist/chunk-46VRPQPD.js +176 -0
- package/dist/chunk-46VRPQPD.js.map +1 -0
- package/dist/chunk-AQ3IINHC.js +138 -0
- package/dist/chunk-AQ3IINHC.js.map +1 -0
- package/dist/chunk-FSKCDHZ7.js +613 -0
- package/dist/chunk-FSKCDHZ7.js.map +1 -0
- package/dist/chunk-I6GBYFIJ.js +89 -0
- package/dist/chunk-I6GBYFIJ.js.map +1 -0
- package/dist/chunk-JBUX6V2Q.js +210 -0
- package/dist/chunk-JBUX6V2Q.js.map +1 -0
- package/dist/chunk-Q3MZNMCK.js +183 -0
- package/dist/chunk-Q3MZNMCK.js.map +1 -0
- package/dist/chunk-RKKRO4ZK.js +133 -0
- package/dist/chunk-RKKRO4ZK.js.map +1 -0
- package/dist/chunk-STMMFNVW.js +391 -0
- package/dist/chunk-STMMFNVW.js.map +1 -0
- package/dist/chunk-VXKONZK4.js +189 -0
- package/dist/chunk-VXKONZK4.js.map +1 -0
- package/dist/chunk-WIEGZFUO.js +337 -0
- package/dist/chunk-WIEGZFUO.js.map +1 -0
- package/dist/chunk-WND2LZ4O.js +66 -0
- package/dist/chunk-WND2LZ4O.js.map +1 -0
- package/dist/chunk-XREZY53L.js +260 -0
- package/dist/chunk-XREZY53L.js.map +1 -0
- package/dist/chunk-ZZY4WGQ6.js +13 -0
- package/dist/chunk-ZZY4WGQ6.js.map +1 -0
- package/dist/did.d.ts +182 -0
- package/dist/did.js +6 -0
- package/dist/did.js.map +1 -0
- package/dist/display.d.ts +117 -0
- package/dist/display.js +5 -0
- package/dist/display.js.map +1 -0
- package/dist/errors-BKQmNpXX.d.ts +11 -0
- package/dist/holder.d.ts +85 -0
- package/dist/holder.js +8 -0
- package/dist/holder.js.map +1 -0
- package/dist/http-BhuJ_-RZ.d.ts +25 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/jose.d.ts +134 -0
- package/dist/jose.js +4 -0
- package/dist/jose.js.map +1 -0
- package/dist/oid4vci.d.ts +428 -0
- package/dist/oid4vci.js +11 -0
- package/dist/oid4vci.js.map +1 -0
- package/dist/oid4vp.d.ts +416 -0
- package/dist/oid4vp.js +11 -0
- package/dist/oid4vp.js.map +1 -0
- package/dist/profile.d.ts +218 -0
- package/dist/profile.js +4 -0
- package/dist/profile.js.map +1 -0
- package/dist/sdjwt.d.ts +133 -0
- package/dist/sdjwt.js +5 -0
- package/dist/sdjwt.js.map +1 -0
- package/dist/sender.d.ts +8 -0
- package/dist/sender.js +7 -0
- package/dist/sender.js.map +1 -0
- package/dist/status.d.ts +108 -0
- package/dist/status.js +7 -0
- package/dist/status.js.map +1 -0
- package/dist/util-DOTEZgTt.d.ts +3 -0
- package/dist/verify.d.ts +144 -0
- package/dist/verify.js +9 -0
- package/dist/verify.js.map +1 -0
- package/dist/webhook.d.ts +62 -0
- package/dist/webhook.js +7 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +168 -4
package/README.md
CHANGED
|
@@ -1,3 +1,470 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ixo/credentials-kit
|
|
2
2
|
|
|
3
|
-
|
|
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.
|