@metalabel/dfos-client 0.34.0 → 0.36.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 CHANGED
@@ -136,6 +136,8 @@ It throws `ApiRequestVerifyError`, carrying `reason` (`invalid` / `unverifiable`
136
136
 
137
137
  ### `@metalabel/dfos-client/siwd`
138
138
 
139
+ The end-to-end integration recipe (mint an app identity, serve the app description, verify the callback) is at <https://docs.dfos.com/docs/developers/sign-in-with-dfos/setup>.
140
+
139
141
  ```typescript
140
142
  import {
141
143
  createSiwdLoginRequest,
@@ -170,7 +172,54 @@ await verifySiwd(client, jws, {
170
172
 
171
173
  `consumeNonce` returns true iff **this** verifier minted the nonce and it was unspent — membership in verifier-minted state is what satisfies the spec's rule that the verifier MUST have minted the nonce it checks, and deleting it in the same operation is what makes it single-use. The atomicity is the caller's: a get-then-delete lets two concurrent replays both win, where a Redis `GETDEL` or a `DELETE … RETURNING` does not. Under either discipline the nonce check runs at most once, and only after every other check has passed, so an invalid presentation can never burn a nonce the user is still holding.
172
174
 
173
- `createSiwdLoginRequest` throws rather than returning an error on the two things that are RP misconfiguration: an `authorizeUrl` or `redirectUri` that is not an absolute URL, and any scope other than `identity` over a loopback redirect (a local port holds no `client_did` for a credential to be issued to — see [SIWD.md](../../specs/SIWD.md)).
175
+ `createSiwdLoginRequest` throws rather than returning an error on the two things that are RP misconfiguration: an `authorizeUrl` or `redirectUri` that is not an absolute URL, and any scope other than `identity` over a loopback redirect that names no client identity.
176
+
177
+ **Loopback redirects** — `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, on any port — come in two shapes. The **anonymous** one is unchanged: no `client_did`, `scope=identity` only, and the consent screen shows the local delivery target and nothing else. The **key-proven** one is the [loopback credential tier](../../specs/SIWD.md#loopback-clients): the request carries a `client_did`, an ask proof over its own challenge bytes, and — unless the DID is already resident on the host — the client's identity chain. That is what lets local software receive a credential. It cannot prove where it came from, but it can prove it controls the keys, and the host's consent screen says exactly that. Credentials minted this way come back in the URL **fragment** and carry a hard expiry ceiling the host enforces; 14 days is the spec's recommendation.
178
+
179
+ The fragment is what keeps the credential off every server, and it is also why a CLI needs one extra step: a browser does not send the fragment to your loopback listener either, so the request line your local server sees carries the query and nothing else. Answer it with a small page whose script reads `location.href` and posts the whole URL back to your server, then feed _that_ to `readSiwdCallback`. A browser relying party just passes `location.href`.
180
+
181
+ ```typescript
182
+ import {
183
+ createSiwdLoopbackLoginRequest,
184
+ mintSiwdClientIdentity,
185
+ restoreSiwdClientIdentity,
186
+ } from '@metalabel/dfos-client/siwd';
187
+
188
+ // The one place this kit generates a key. Persist `privateKey` + `chain` and
189
+ // restore them next run: a client that re-mints is a NEW did, so it re-consents
190
+ // every time and orphans the credentials the last run earned.
191
+ const app = saved
192
+ ? await restoreSiwdClientIdentity(saved) // { privateKey, chain }
193
+ : await mintSiwdClientIdentity();
194
+
195
+ const request = await createSiwdLoopbackLoginRequest({
196
+ authorizeUrl: 'https://app.example.com/authorize',
197
+ redirectUri: 'http://127.0.0.1:8976/callback',
198
+ scope: 'read:profile',
199
+ client: app, // did + kid + signer, and the chain to carry
200
+ });
201
+ // open request.url, hold request.expect.nonce in memory, and read back the FULL
202
+ // callback URL (see above — location.search alone would miss the credential).
203
+ const result = readSiwdCallback(callbackUrl);
204
+ if (result.kind === 'success') {
205
+ // Consumed verification, store of size one: a credential-returning scope
206
+ // requires it, and one outstanding nonce in memory IS the store. `client` is
207
+ // the resolver from the setup section, not the identity above.
208
+ let outstanding: string | undefined = request.expect.nonce;
209
+ const session = await verifySiwd(client, result.jws, {
210
+ domain: request.expect.domain,
211
+ consumeNonce: (nonce) => {
212
+ if (outstanding === undefined || nonce !== outstanding) return false;
213
+ outstanding = undefined;
214
+ return true;
215
+ },
216
+ });
217
+ if (session.ok) {
218
+ // only now is result.credential a credential you earned — store it, and
219
+ // send a per-request proof signed by app.signer when you spend it
220
+ }
221
+ }
222
+ ```
174
223
 
175
224
  `siwdSigningInput(challenge)` is the pure byte contract both the signer and the verifier share (see [SIWD.md](../../specs/SIWD.md)); `createSiwdChallenge` mints a challenge on its own for a caller building its own redirect; `verifySiwd` is a no-throw verifier that accepts only a current `authKeys` entry of a non-deleted identity.
176
225
 
package/dist/siwd.d.ts CHANGED
@@ -9,6 +9,19 @@ import '@metalabel/dfos-web-relay/peer-client';
9
9
  * routing dispatchers tell a SIWD proof apart from credentials and chain ops.
10
10
  */
11
11
  declare const SIWD_JWS_TYP = "did:dfos:siwd";
12
+ /**
13
+ * The normative JWS header `typ` for a client ASK PROOF — the artifact a
14
+ * loopback client signs to prove key control at ask-time, registered alongside
15
+ * `SIWD_JWS_TYP` by SIWD.md §The ask proof.
16
+ *
17
+ * The two artifacts cover the SAME canonical challenge bytes, so the distinct
18
+ * `typ` is the only thing keeping them from being fungible: without it, an ask
19
+ * proof a client signed to open a consent screen would present as a subject's
20
+ * sign-in for that same challenge, and a subject's sign-in would present as a
21
+ * client's ask. The typ gate on each side is what scopes a signature to the
22
+ * purpose it was produced for.
23
+ */
24
+ declare const SIWD_ASK_JWS_TYP = "did:dfos:siwd-ask";
12
25
  interface SiwdChallenge {
13
26
  /** Origin domain of the requesting application. */
14
27
  domain: string;
@@ -86,7 +99,11 @@ interface SiwdLoginRequestInput {
86
99
  * only the signature would accept it.
87
100
  */
88
101
  did?: string;
89
- /** The RP's own DID. Omitted from the URL automatically for loopback redirects. */
102
+ /**
103
+ * The RP's own DID. Rides through on a loopback redirect too, but a host
104
+ * honors it there only under the loopback credential tier — see
105
+ * `createSiwdLoopbackLoginRequest`, which adds the proof that backs it.
106
+ */
90
107
  clientDid?: string;
91
108
  /** Supply a nonce minted elsewhere (e.g. by your backend); default: minted here. */
92
109
  nonce?: string;
@@ -132,32 +149,196 @@ interface SiwdLoginRequest {
132
149
  * OUTBOUND half of profile A. `readSiwdCallback` is the inbound half, and
133
150
  * `verifySiwd` is what both compose around: mint → redirect, read → verify.
134
151
  *
135
- * The rule this function exists to own is the LOOPBACK OMISSION. Nothing can
136
- * prove a client DID for an app on a local port there is no domain serving a
137
- * well-known and no registration to checkso a host REFUSES a `client_did`
138
- * on a loopback redirect rather than displaying an identity it cannot stand
139
- * behind. A CLI that passed its own DID would have the whole request rejected,
140
- * not downgraded, so the param is dropped here instead of being forwarded into
141
- * a guaranteed refusal.
152
+ * The rule this function owns is the LOOPBACK RULE, and what it turns on is
153
+ * whether the request names a client identity. A bare `client_did` from an app
154
+ * on a local port used to be refused outright there is no domain serving a
155
+ * well-known and no registration to check, so nothing backed the DID and a host
156
+ * would not display an identity it could not stand behind. The LOOPBACK
157
+ * CREDENTIAL TIER (specs/SIWD.md §Loopback Clients) replaces "nothing backs it"
158
+ * with the one thing local software can prove: control of that identity's
159
+ * current keys. So the param now rides through on a loopback redirect instead
160
+ * of being dropped — but it is honored only when the request ALSO carries an
161
+ * ask proof and, unless the DID is already resident on the host, the client's
162
+ * identity chain. `createSiwdLoopbackLoginRequest` composes all three; a
163
+ * `clientDid` passed to this function alone is an unbacked assertion the host
164
+ * will refuse.
142
165
  *
143
166
  * The same judgment BOUNDS THE SCOPE. Every scope past `identity` returns a
144
- * credential issued to a `client_did` the one param a loopback request cannot
145
- * carry so specs/SIWD.md admits a loopback target for `scope=identity` only.
146
- * Asking for more from a local port is not a downgrade the way `client_did` is;
147
- * there is nothing to drop, so it throws.
167
+ * credential issued to a `client_did`, so a loopback request with no client
168
+ * identity at all still has nothing to issue to and specs/SIWD.md admits it for
169
+ * `scope=identity` only there is nothing to downgrade, so it throws. With a
170
+ * client identity the tier is open and every scope is available.
148
171
  *
149
172
  * It also owns the WIRE PARAM NAMES (`challenge`, `redirect_uri`, `scope`,
150
- * `client_did`) as their single source in this package. They are snake_case on
151
- * the wire and camelCase everywhere else, which is exactly the kind of seam
173
+ * `client_did`, and via `createSiwdLoopbackLoginRequest` `client_proof` and
174
+ * `client_chain`) as their single source in this package. They are snake_case
175
+ * on the wire and camelCase everywhere else, which is exactly the kind of seam
152
176
  * every hand-rolled RP re-implements and eventually gets wrong.
153
177
  *
154
178
  * PURE: no DOM, no storage, no navigation, no fetch — identical in a browser
155
179
  * and in Node. Throws on an unparseable `authorizeUrl` or `redirectUri`, and on
156
- * a non-`identity` scope over a loopback redirect, because those are mistakes
157
- * in the RP's own configuration rather than runtime conditions a result type
158
- * would help a caller recover from.
180
+ * a non-`identity` scope over a loopback redirect with no client identity,
181
+ * because those are mistakes in the RP's own configuration rather than runtime
182
+ * conditions a result type would help a caller recover from.
159
183
  */
160
184
  declare const createSiwdLoginRequest: (input: SiwdLoginRequestInput) => SiwdLoginRequest;
185
+ /**
186
+ * Sign the ask proof for a loopback authorize request: a JWS over the exact
187
+ * canonical bytes of the request's own challenge, under `SIWD_ASK_JWS_TYP`,
188
+ * signed by a CURRENT auth key of the client identity's chain (SIWD.md §The ask
189
+ * proof). It is what makes a `client_did` on a loopback request mean something
190
+ * — the host verifies it against the chain's current state before rendering any
191
+ * consent, so key control is established at ask-time, not just at spend-time.
192
+ *
193
+ * BYTE-PRECISE, and deliberately not built with `createJws`. The host compares
194
+ * the proof's payload SEGMENT by string equality against its own re-derivation
195
+ * of `base64url(siwdSigningInput(challenge))`; a construction that round-tripped
196
+ * the challenge through a JSON object would re-serialize it, and any spelling
197
+ * that differed from the canonical one by a single byte would fail that
198
+ * comparison while looking correct here. So this signs the `siwdSigningInput`
199
+ * output directly.
200
+ *
201
+ * `kid` is the DID URL of the signing key. The host ignores it for key
202
+ * SELECTION — it tries the chain's current auth keys — but it is set honestly
203
+ * because a proof that names a key it was not signed with is a lie the wire
204
+ * format has no reason to carry.
205
+ *
206
+ * The `client_proof` param that carries this is the REFERENCE wire surface, not
207
+ * a normative name: SIWD.md defers how the ask proof travels to the hosted
208
+ * endpoint's reference implementation, exactly as it does the endpoint itself.
209
+ * What is normative is that it arrives WITH the ask and verifies BEFORE any
210
+ * consent is rendered.
211
+ */
212
+ declare const signSiwdAskProof: (input: {
213
+ challenge: SiwdChallenge;
214
+ /** DID URL of the signing key: `<did>#<keyId>`. Must be a CURRENT auth key. */
215
+ kid: string;
216
+ signer: Signer;
217
+ }) => Promise<string>;
218
+ /** SIWD.md carriage cap — an identity that has outgrown it has outgrown carriage. */
219
+ declare const MAX_SIWD_CLIENT_CHAIN_OPS = 100;
220
+ /**
221
+ * Encode a client identity chain for carriage on the authorize request: the
222
+ * FULL ordered operation log, genesis first, as base64url of its JSON array —
223
+ * the same grammar as the `challenge` param, so one decoder shape serves both.
224
+ *
225
+ * The DID derived from the genesis operation MUST equal the `client_did` the
226
+ * request names; a request where the two disagree makes no claim at all and the
227
+ * host refuses it WHOLE rather than ingesting the chain and ignoring the
228
+ * mismatch (SIWD.md §Chain residence). Carriage is only needed when the DID is
229
+ * not already resident on the verifying host.
230
+ *
231
+ * The 100-operation cap is spec-normative and enforced here. Hosts MAY
232
+ * additionally bound the ENCODED size for URL-transport reasons — the reference
233
+ * host refuses carriages over 8KiB — which is transport policy rather than
234
+ * protocol, so it is not a client-side throw; the practical reading is that a
235
+ * chain anywhere near the op cap belongs on relays, not in a URL.
236
+ *
237
+ * As with the ask proof, the `client_chain` param is the REFERENCE wire surface
238
+ * rather than a normative name — SIWD.md leaves the carriage encoding to the
239
+ * hosted endpoint's reference implementation and pins only that the chain
240
+ * arrives with the ask and verifies before any consent is rendered.
241
+ */
242
+ declare const encodeSiwdClientChain: (log: string[]) => string;
243
+ interface SiwdClientIdentity {
244
+ did: string;
245
+ /** Verbatim identity-op JWS log, genesis first — feed to encodeSiwdClientChain. */
246
+ chain: string[];
247
+ /** DID URL of the current auth key — feed to signSiwdAskProof. */
248
+ kid: string;
249
+ signer: Signer;
250
+ /** Raw Ed25519 private key — persist it (with `chain`) to keep this DID across runs. */
251
+ privateKey: Uint8Array;
252
+ }
253
+ /**
254
+ * Mint a fresh client identity for the loopback credential tier: one Ed25519
255
+ * keypair and a single genesis `create` operation naming it as the auth,
256
+ * assertion, and controller key. That is the whole identity a CLI needs to ask
257
+ * under this tier — a DID it can prove control of, and a one-operation chain
258
+ * small enough to carry on the request itself.
259
+ *
260
+ * KEY CUSTODY IS THE CALLER'S, and the identity is only as durable as the
261
+ * custody: persist `privateKey` and `chain` — an OS keychain, a file the caller
262
+ * protects — and hand them back to `restoreSiwdClientIdentity` on the next run.
263
+ * That is the other half of this function, and it is not optional in practice.
264
+ * A client that re-mints instead of restoring arrives as a DIFFERENT DID every
265
+ * time, so every run asks the user to consent again and every credential the
266
+ * last run earned belongs to an identity nothing will ever present again.
267
+ * Nothing here touches storage; where the key lives is the caller's to decide.
268
+ */
269
+ declare const mintSiwdClientIdentity: () => Promise<SiwdClientIdentity>;
270
+ /**
271
+ * Rebuild a client identity from what a previous run persisted — the other half
272
+ * of `mintSiwdClientIdentity`, and what makes the DID (and the consent it
273
+ * earned) survive across runs.
274
+ *
275
+ * It re-derives the public key from `privateKey` and finds the CURRENT auth key
276
+ * of the verified chain that key belongs to, which is the same currency rule the
277
+ * host applies to the ask proof. A key that has been ROTATED OUT of the chain
278
+ * therefore refuses to restore rather than producing an identity whose proofs
279
+ * would be rejected at the far end: failing here, with the reason in hand, is
280
+ * strictly better than failing after a redirect. The `kid` comes from the chain
281
+ * rather than from storage for the same reason — the chain is the authority on
282
+ * which key is current, and a persisted `kid` is a guess that ages.
283
+ *
284
+ * `chain` is the caller's stored log, and it is verified here, so a corrupted or
285
+ * truncated log surfaces as a throw rather than as a silently wrong DID.
286
+ */
287
+ declare const restoreSiwdClientIdentity: (input: {
288
+ privateKey: Uint8Array;
289
+ /** The verbatim log `mintSiwdClientIdentity` returned, genesis first. */
290
+ chain: string[];
291
+ }) => Promise<SiwdClientIdentity>;
292
+ interface SiwdLoopbackLoginRequestInput {
293
+ /** The host's authorize endpoint, e.g. `https://app.example.com/authorize`. */
294
+ authorizeUrl: string;
295
+ /** Loopback redirect target — `http://` on localhost / 127.0.0.1 / [::1], any port/path. */
296
+ redirectUri: string;
297
+ /**
298
+ * Requested scope. Naming a client identity opens the tier, so every scope the
299
+ * HOST offers is available here — the `identity`-only bound belongs to the
300
+ * anonymous loopback shape, which has no `client_did` to issue a credential to.
301
+ */
302
+ scope: string;
303
+ /** Consent-screen prose. A host MAY decline to render it; see specs/SIWD.md. */
304
+ statement?: string;
305
+ /** Bind the challenge to ONE subject DID (sign in as this DID or not at all). */
306
+ did?: string;
307
+ /** The client identity asking under the loopback credential tier. */
308
+ client: Pick<SiwdClientIdentity, 'did' | 'kid' | 'signer'> & {
309
+ /** Verbatim chain to carry; omit when the DID is already resident on the host. */
310
+ chain?: string[];
311
+ };
312
+ /** Supply a nonce minted elsewhere; default: minted here. Verify against it. */
313
+ nonce?: string;
314
+ }
315
+ /**
316
+ * Build a loopback authorize URL under the LOOPBACK CREDENTIAL TIER — the
317
+ * outbound half of what `createSiwdLoginRequest` alone cannot produce. It is
318
+ * that function plus the two things that back the `client_did` it now carries:
319
+ * an ask proof (SIWD.md §The ask proof) and, unless the DID is already resident
320
+ * on the host, the client's identity chain (SIWD.md §Chain residence).
321
+ *
322
+ * `domain` is DERIVED, not accepted. SIWD.md pins a loopback challenge's domain
323
+ * to the BARE loopback host — the port is not part of the binding, because a
324
+ * local application cannot reserve one — and the host compares that value
325
+ * literally against the redirect's host. Taking a `domain` input here would be
326
+ * an invitation to a mismatch that fails only after the redirect.
327
+ *
328
+ * WHAT THIS PROVES IS KEY CONTROL, NOT PROVENANCE. The chain says which keys
329
+ * the asking party holds; nothing about a loopback client's origin or authorship
330
+ * is checkable, and the host's consent screen says exactly that rather than
331
+ * displaying a domain it cannot stand behind. The residue is bounded on the
332
+ * other side: a credential minted to a loopback client carries a hard expiry
333
+ * ceiling — RECOMMENDED 14 days — so a proven-but-unvouched-for client's grant
334
+ * ages out on its own, with revocation remaining the user's real disconnect.
335
+ *
336
+ * Async (it signs) and throwing on configuration errors — a `redirectUri` that
337
+ * is not an `http://` loopback target, a `kid` that does not belong to the
338
+ * client's own DID, a chain past the carriage cap — consistent with the
339
+ * constructor half of this module.
340
+ */
341
+ declare const createSiwdLoopbackLoginRequest: (input: SiwdLoopbackLoginRequestInput) => Promise<SiwdLoginRequest>;
161
342
  /**
162
343
  * What came back on the redirect. `none` means this was a plain page load, not
163
344
  * a callback at all — the common case on an RP's own landing page.
@@ -166,6 +347,8 @@ type SiwdCallbackResult = {
166
347
  kind: 'success';
167
348
  jws: string;
168
349
  did: string;
350
+ /** Present only when the host minted one and the input carried a fragment. */
351
+ credential?: string;
169
352
  } | {
170
353
  kind: 'denied';
171
354
  error: string;
@@ -182,17 +365,39 @@ type SiwdCallbackResult = {
182
365
  * Takes an absolute URL string, a `URL`, or a bare `?…` query string (so
183
366
  * `readSiwdCallback(location.search)` works, including when it is empty).
184
367
  *
185
- * SCRUB THE URL YOURSELF, IMMEDIATELY. A signed JWS is sitting in the query
186
- * string, which means it is in the address bar, in `history`, in the referrer
187
- * of anything the page loads next, and in any analytics that samples the
188
- * location. This function cannot do the scrubbing for you `history` is
189
- * environment-owned and this package stays free of the DOM so a browser RP
190
- * should follow the read with a `history.replaceState` back to the bare path.
368
+ * A CREDENTIAL COMES BACK IN THE FRAGMENT, and that is a deliberate asymmetry
369
+ * with `jws`/`did`, which ride the query. A fragment is never sent to a server:
370
+ * it lands in no access log, no proxy log, and no `Referer` header, so the one
371
+ * artifact that is redeemable outside this channel is the one that stays on the
372
+ * client. Reading it therefore requires the WHOLE URL`location.href`, not
373
+ * `location.search`.
374
+ *
375
+ * THAT SAME PROPERTY IS A PROBLEM FOR A CLI, and this tier's primary consumer is
376
+ * a CLI. A browser does not send the fragment to the loopback listener either,
377
+ * so the request line a local HTTP server sees carries the query and nothing
378
+ * else. The standard resolution (SIWD.md §4's loopback note) is for the listener
379
+ * to answer with a small page whose script reads `location.href` and posts the
380
+ * whole URL back to the local server; feed THAT to this function. A browser RP
381
+ * passes `location.href` directly and needs no relay.
382
+ *
383
+ * SCRUB THE URL YOURSELF, IMMEDIATELY. The signed JWS in the query string is in
384
+ * the address bar, in `history`, in the referrer of anything the page loads
385
+ * next, and in any analytics that samples the location; a credential in the
386
+ * fragment is spared the referrer and the server-side logs but is in the address
387
+ * bar and `history` just the same. This function cannot do the scrubbing for you
388
+ * — `history` is environment-owned and this package stays free of the DOM — so a
389
+ * browser RP should follow the read with a `history.replaceState` back to the
390
+ * bare path, which clears both halves.
191
391
  *
192
392
  * A HALF-CALLBACK IS A FAILURE, NOT A NON-EVENT: `jws` without `did` (or the
193
393
  * reverse) resolves to `denied` carrying a synthetic reason rather than `none`.
194
394
  * Silently treating it as a plain page load would strand the user on a
195
395
  * sign-in button with no explanation of why the last attempt vanished.
396
+ *
397
+ * THE QUERY HALF'S VERDICT WINS. A credential in the fragment is lifted only
398
+ * onto a `success`, never on its own: a `denied` or `none` stays exactly what
399
+ * it was. There is no half-callback state a stray fragment could promote —
400
+ * a fragment on a non-callback page load is noise, not a session.
196
401
  */
197
402
  declare const readSiwdCallback: (url: string | URL) => SiwdCallbackResult;
198
403
  interface BuildSiwdSignRequestInput {
@@ -273,4 +478,4 @@ interface SiwdExpectations {
273
478
  */
274
479
  declare const verifySiwd: (client: Client, jws: string, expect: SiwdExpectations) => Promise<VerifyResult<SiwdSession>>;
275
480
 
276
- export { type BuildSiwdSignRequestInput, type CreateChallengeInput, SIWD_JWS_TYP, type SiwdCallbackResult, type SiwdChallenge, type SiwdExpectations, type SiwdLoginRequest, type SiwdLoginRequestInput, type SiwdSession, type ValidateSiwdSignRequestOptions, type ValidatedSiwdSignRequest, buildSiwdSignRequest, createSiwdChallenge, createSiwdLoginRequest, decodeSiwdChallenge, parseSiwdChallenge, readSiwdCallback, siwdSigningInput, validateSiwdSignRequest, verifySiwd };
481
+ export { type BuildSiwdSignRequestInput, type CreateChallengeInput, MAX_SIWD_CLIENT_CHAIN_OPS, SIWD_ASK_JWS_TYP, SIWD_JWS_TYP, type SiwdCallbackResult, type SiwdChallenge, type SiwdClientIdentity, type SiwdExpectations, type SiwdLoginRequest, type SiwdLoginRequestInput, type SiwdLoopbackLoginRequestInput, type SiwdSession, type ValidateSiwdSignRequestOptions, type ValidatedSiwdSignRequest, buildSiwdSignRequest, createSiwdChallenge, createSiwdLoginRequest, createSiwdLoopbackLoginRequest, decodeSiwdChallenge, encodeSiwdClientChain, mintSiwdClientIdentity, parseSiwdChallenge, readSiwdCallback, restoreSiwdClientIdentity, signSiwdAskProof, siwdSigningInput, validateSiwdSignRequest, verifySiwd };
package/dist/siwd.js CHANGED
@@ -2,18 +2,26 @@
2
2
  import {
3
3
  buildSignRequest,
4
4
  decodeMultikey,
5
+ encodeEd25519Multikey,
6
+ signIdentityOperation,
5
7
  SignRequestVerifyError,
8
+ verifyIdentityChain,
6
9
  verifySignRequest
7
10
  } from "@metalabel/dfos-protocol/chain";
8
11
  import {
9
12
  assertJwsProfile,
10
13
  base64urlDecode,
11
14
  base64urlEncode,
15
+ createNewEd25519Keypair,
12
16
  decodeJwsUnsafe,
17
+ generateId,
13
18
  generateIdNoPrefix,
19
+ importEd25519Keypair,
20
+ signPayloadEd25519,
14
21
  verifyJws
15
22
  } from "@metalabel/dfos-protocol/crypto";
16
23
  var SIWD_JWS_TYP = "did:dfos:siwd";
24
+ var SIWD_ASK_JWS_TYP = "did:dfos:siwd-ask";
17
25
  var MAX_CLOCK_SKEW_SECONDS = 60;
18
26
  var SIWD_CHALLENGE_FIELDS = /* @__PURE__ */ new Set(["domain", "nonce", "timestamp", "statement", "did"]);
19
27
  var WHOLE_SECOND_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/;
@@ -125,8 +133,10 @@ var createSiwdLoginRequest = (input) => {
125
133
  const authorizeUrl = parseUrlOrThrow(input.authorizeUrl, "authorizeUrl");
126
134
  const redirect = parseUrlOrThrow(input.redirectUri, "redirectUri");
127
135
  const isLoopback = SIWD_LOOPBACK_HOSTS.has(bareHostname(redirect));
128
- if (isLoopback && input.scope !== "identity") {
129
- throw new Error("invalid SIWD login request: loopback redirects support scope=identity only");
136
+ if (isLoopback && input.scope !== "identity" && input.clientDid === void 0) {
137
+ throw new Error(
138
+ "invalid SIWD login request: loopback redirects support scope=identity only without a client identity \u2014 a credential scope needs a client_did proven under the loopback credential tier (specs/SIWD.md \xA7Loopback Clients)"
139
+ );
130
140
  }
131
141
  const { challenge, encoded, nonce } = createSiwdChallenge({
132
142
  domain: input.domain,
@@ -138,7 +148,7 @@ var createSiwdLoginRequest = (input) => {
138
148
  url.searchParams.set("challenge", encoded);
139
149
  url.searchParams.set("redirect_uri", input.redirectUri);
140
150
  url.searchParams.set("scope", input.scope);
141
- if (input.clientDid !== void 0 && !isLoopback) {
151
+ if (input.clientDid !== void 0) {
142
152
  url.searchParams.set("client_did", input.clientDid);
143
153
  }
144
154
  return {
@@ -152,21 +162,152 @@ var createSiwdLoginRequest = (input) => {
152
162
  timestamp: challenge.timestamp
153
163
  };
154
164
  };
165
+ var signSiwdAskProof = async (input) => {
166
+ const hashIdx = input.kid.indexOf("#");
167
+ if (hashIdx <= 0 || hashIdx === input.kid.length - 1) {
168
+ throw new Error(
169
+ "invalid SIWD ask proof: kid must be a DID URL with both halves non-empty (<did>#<keyId>)"
170
+ );
171
+ }
172
+ const headerB64 = base64urlEncode(
173
+ JSON.stringify({ alg: "EdDSA", typ: SIWD_ASK_JWS_TYP, kid: input.kid })
174
+ );
175
+ const payloadB64 = base64urlEncode(siwdSigningInput(input.challenge));
176
+ const signingInput = `${headerB64}.${payloadB64}`;
177
+ const signature = await input.signer(new TextEncoder().encode(signingInput));
178
+ return `${signingInput}.${base64urlEncode(signature)}`;
179
+ };
180
+ var MAX_SIWD_CLIENT_CHAIN_OPS = 100;
181
+ var encodeSiwdClientChain = (log) => {
182
+ if (!Array.isArray(log)) {
183
+ throw new Error("invalid SIWD client chain: expected an array of identity-op JWS strings");
184
+ }
185
+ if (log.length === 0) {
186
+ throw new Error("invalid SIWD client chain: log is empty \u2014 carriage needs the full log");
187
+ }
188
+ if (log.length > MAX_SIWD_CLIENT_CHAIN_OPS) {
189
+ throw new Error(
190
+ `invalid SIWD client chain: ${log.length} operations exceeds the ${MAX_SIWD_CLIENT_CHAIN_OPS}-operation carriage cap`
191
+ );
192
+ }
193
+ if (log.some((operation) => typeof operation !== "string" || operation.length === 0)) {
194
+ throw new Error(
195
+ "invalid SIWD client chain: every member must be a non-empty identity-op JWS string"
196
+ );
197
+ }
198
+ return base64urlEncode(JSON.stringify(log));
199
+ };
200
+ var mintSiwdClientIdentity = async () => {
201
+ const keypair = createNewEd25519Keypair();
202
+ const keyId = generateId("key");
203
+ const key = {
204
+ id: keyId,
205
+ type: "Multikey",
206
+ publicKeyMultibase: encodeEd25519Multikey(keypair.publicKey)
207
+ };
208
+ const signer = async (message) => signPayloadEd25519(message, keypair.privateKey);
209
+ const genesis = {
210
+ version: 1,
211
+ type: "create",
212
+ authKeys: [key],
213
+ assertKeys: [key],
214
+ controllerKeys: [key],
215
+ createdAt: new Date(Math.floor(Date.now() / 1e3) * 1e3).toISOString()
216
+ };
217
+ const { jwsToken } = await signIdentityOperation({ operation: genesis, signer, keyId });
218
+ const { did } = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
219
+ return {
220
+ did,
221
+ chain: [jwsToken],
222
+ kid: `${did}#${keyId}`,
223
+ signer,
224
+ privateKey: keypair.privateKey
225
+ };
226
+ };
227
+ var restoreSiwdClientIdentity = async (input) => {
228
+ const keypair = importEd25519Keypair(input.privateKey);
229
+ const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: input.chain });
230
+ const authKey = identity.authKeys.find((key) => {
231
+ const keyBytes = decodeMultikey(key.publicKeyMultibase).keyBytes;
232
+ return keyBytes.length === keypair.publicKey.length && keyBytes.every((byte, index) => byte === keypair.publicKey[index]);
233
+ });
234
+ if (!authKey) {
235
+ throw new Error(
236
+ "invalid SIWD client identity: the private key is not a CURRENT authentication key of the supplied chain \u2014 a rotated-out key cannot restore, because the host would refuse the ask proof it signs"
237
+ );
238
+ }
239
+ return {
240
+ did: identity.did,
241
+ chain: input.chain,
242
+ kid: `${identity.did}#${authKey.id}`,
243
+ signer: async (message) => signPayloadEd25519(message, keypair.privateKey),
244
+ privateKey: input.privateKey
245
+ };
246
+ };
247
+ var createSiwdLoopbackLoginRequest = async (input) => {
248
+ const redirect = parseUrlOrThrow(input.redirectUri, "redirectUri");
249
+ const domain = bareHostname(redirect);
250
+ if (!SIWD_LOOPBACK_HOSTS.has(domain) || redirect.protocol !== "http:") {
251
+ throw new Error(
252
+ "invalid SIWD login request: redirectUri must be an http:// loopback target (localhost, 127.0.0.1, [::1])"
253
+ );
254
+ }
255
+ if (!input.client.kid.startsWith(`${input.client.did}#`)) {
256
+ throw new Error(
257
+ "invalid SIWD login request: client.kid must be a DID URL of client.did (<did>#<keyId>)"
258
+ );
259
+ }
260
+ const request = createSiwdLoginRequest({
261
+ authorizeUrl: input.authorizeUrl,
262
+ domain,
263
+ redirectUri: input.redirectUri,
264
+ scope: input.scope,
265
+ clientDid: input.client.did,
266
+ ...input.statement !== void 0 ? { statement: input.statement } : {},
267
+ ...input.did !== void 0 ? { did: input.did } : {},
268
+ ...input.nonce !== void 0 ? { nonce: input.nonce } : {}
269
+ });
270
+ const proof = await signSiwdAskProof({
271
+ challenge: decodeSiwdChallenge(request.challenge),
272
+ kid: input.client.kid,
273
+ signer: input.client.signer
274
+ });
275
+ const url = new URL(request.url);
276
+ url.searchParams.set("client_proof", proof);
277
+ if (input.client.chain !== void 0) {
278
+ url.searchParams.set("client_chain", encodeSiwdClientChain(input.client.chain));
279
+ }
280
+ return { ...request, url: url.toString() };
281
+ };
155
282
  var callbackParam = (params, key) => {
156
283
  const value = params.get(key);
157
284
  return value === null || value === "" ? void 0 : value;
158
285
  };
159
- var callbackParams = (url) => {
160
- if (typeof url !== "string") return url.searchParams;
161
- if (url === "" || url.startsWith("?")) return new URLSearchParams(url);
162
- return parseUrlOrThrow(url, "url").searchParams;
286
+ var callbackParts = (url) => {
287
+ if (typeof url === "string" && (url === "" || url.startsWith("?"))) {
288
+ const hashIdx = url.indexOf("#");
289
+ const hash2 = hashIdx < 0 ? "" : url.slice(hashIdx + 1);
290
+ return {
291
+ query: new URLSearchParams(hashIdx < 0 ? url : url.slice(0, hashIdx)),
292
+ ...hash2 !== "" ? { fragment: new URLSearchParams(hash2) } : {}
293
+ };
294
+ }
295
+ const parsed = typeof url === "string" ? parseUrlOrThrow(url, "url") : url;
296
+ const hash = parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash;
297
+ return {
298
+ query: parsed.searchParams,
299
+ ...hash !== "" ? { fragment: new URLSearchParams(hash) } : {}
300
+ };
163
301
  };
164
302
  var readSiwdCallback = (url) => {
165
- const params = callbackParams(url);
166
- const jws = callbackParam(params, "jws");
167
- const did = callbackParam(params, "did");
168
- const error = callbackParam(params, "error");
169
- if (jws !== void 0 && did !== void 0) return { kind: "success", jws, did };
303
+ const { query, fragment } = callbackParts(url);
304
+ const jws = callbackParam(query, "jws");
305
+ const did = callbackParam(query, "did");
306
+ const error = callbackParam(query, "error");
307
+ if (jws !== void 0 && did !== void 0) {
308
+ const credential = fragment ? callbackParam(fragment, "credential") : void 0;
309
+ return { kind: "success", jws, did, ...credential !== void 0 ? { credential } : {} };
310
+ }
170
311
  if (error !== void 0) return { kind: "denied", error };
171
312
  if (jws !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing did" };
172
313
  if (did !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing jws" };
@@ -321,13 +462,20 @@ var verifySiwd = async (client, jws, expect) => {
321
462
  }
322
463
  };
323
464
  export {
465
+ MAX_SIWD_CLIENT_CHAIN_OPS,
466
+ SIWD_ASK_JWS_TYP,
324
467
  SIWD_JWS_TYP,
325
468
  buildSiwdSignRequest,
326
469
  createSiwdChallenge,
327
470
  createSiwdLoginRequest,
471
+ createSiwdLoopbackLoginRequest,
328
472
  decodeSiwdChallenge,
473
+ encodeSiwdClientChain,
474
+ mintSiwdClientIdentity,
329
475
  parseSiwdChallenge,
330
476
  readSiwdCallback,
477
+ restoreSiwdClientIdentity,
478
+ signSiwdAskProof,
331
479
  siwdSigningInput,
332
480
  validateSiwdSignRequest,
333
481
  verifySiwd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "description": "DFOS Client — the client-side kit for participating in the protocol: resolve, verify, prove. Fetch, resolve, verify-orchestration and cache over untrusted relays, plus the SIWD and API-AUTH proof surfaces. Holds no keys; all crypto truth comes from @metalabel/dfos-protocol",
6
6
  "license": "MIT",
@@ -47,15 +47,15 @@
47
47
  "README.md"
48
48
  ],
49
49
  "peerDependencies": {
50
- "@metalabel/dfos-protocol": "^0.34.0",
51
- "@metalabel/dfos-web-relay": "^0.34.0"
50
+ "@metalabel/dfos-protocol": "^0.36.0",
51
+ "@metalabel/dfos-web-relay": "^0.36.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^24.10.4",
55
55
  "tsup": "^8.5.1",
56
56
  "vitest": "^4.1.8",
57
- "@metalabel/dfos-protocol": "0.34.0",
58
- "@metalabel/dfos-web-relay": "0.34.0"
57
+ "@metalabel/dfos-web-relay": "0.36.0",
58
+ "@metalabel/dfos-protocol": "0.36.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsup",