@metalabel/dfos-client 0.31.0 → 0.33.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 +76 -3
- package/dist/api-auth.d.ts +296 -0
- package/dist/api-auth.js +385 -0
- package/package.json +10 -6
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @metalabel/dfos-client
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The client-side kit for participating in the [DFOS protocol](https://protocol.dfos.com) — **resolve, verify, prove**. The protocol library owns the crypto truth (CID re-derivation, signature verification, chain folding); this client owns the four things it deliberately refuses to do: **fetch, resolve, verify-orchestration, and cache** — over an untrusted set of relays — plus the two proof surfaces that ride on top of them, [SIWD](#metalabeldfos-clientsiwd) and [API-AUTH](#metalabeldfos-clientapi-auth).
|
|
4
|
+
|
|
5
|
+
**It holds no keys.** Signing is always a `sign` callback the caller supplies: this kit composes the exact bytes that must be signed and hands them over, and key material never crosses into it.
|
|
4
6
|
|
|
5
7
|
If verification logic appears in this package, that is the bug: every proof comes from `@metalabel/dfos-protocol`.
|
|
6
8
|
|
|
@@ -73,6 +75,65 @@ import { indexedDbStore, memoryStore } from '@metalabel/dfos-client/store';
|
|
|
73
75
|
|
|
74
76
|
`memoryStore()` (the isomorphic default) caches the **log**. Chain reads fully drain from zero, require the fetched JWS tokens to match the trusted cached prefix, and verify forward only the suffix, so a key rotation costs one verification op and the cache is never stale-wrong. `indexedDbStore()` is the browser-only durable adapter — the only heavy dependency, quarantined behind this subpath.
|
|
75
77
|
|
|
78
|
+
### `@metalabel/dfos-client/api-auth`
|
|
79
|
+
|
|
80
|
+
API Authentication request proofs. A proof is a short-lived JWS, signed by the key a DFOS credential was issued to, that binds one exact HTTP request — method, host, path, body — to that credential. The credential says what its holder may do; the proof says the holder is the one doing it, and doing exactly this. See the [API-AUTH specification](https://protocol.dfos.com/api-auth).
|
|
81
|
+
|
|
82
|
+
**Spending a credential: a signing `fetch`.** Hand it to any API client with a fetch seam, and every request that client composes goes out credential-gated.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { createDfosApi } from '@metalabel/dfos-api';
|
|
86
|
+
import { createApiAuthFetch } from '@metalabel/dfos-client/api-auth';
|
|
87
|
+
|
|
88
|
+
const api = createDfosApi({ fetch: createApiAuthFetch({ credential, kid, sign }) });
|
|
89
|
+
|
|
90
|
+
const { data } = await api.GET('/profile');
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Three inputs, and they are the irreducible ones: the credential JWS to present, the DID URL of the key it was issued to, and a `sign` callback over bytes. The proof's `credentialCID` is read from the credential's own header, so the two can never drift apart. Pass `fetch` to supply the underlying transport (default `globalThis.fetch`).
|
|
94
|
+
|
|
95
|
+
It signs **exactly the `Request` it receives** — the method, the origin-form target, and the body octets already composed — rather than a description of one. That is what keeps the binding honest: the bytes the proof covers are the bytes that go on the wire.
|
|
96
|
+
|
|
97
|
+
Three consequences worth knowing before you wire it up:
|
|
98
|
+
|
|
99
|
+
- **It refuses to sign a plaintext request** to anything but loopback (`localhost`, `127.0.0.1`, `[::1]`). `api:` surfaces are HTTPS surfaces, and a proof sent in the clear replays for its whole freshness window.
|
|
100
|
+
- **It does not follow redirects** (`redirect: 'manual'`): a 3xx comes back to you as-is, because following it would re-issue the request at coordinates the proof does not cover and carry `X-Credential` to whatever authority the `Location` names.
|
|
101
|
+
- **It buffers the request body before sending.** The proof covers the whole body, so there is nothing to sign until the last octet is in hand — size-bounded requests only. An unbounded or live stream cannot be proof-signed, in any implementation.
|
|
102
|
+
|
|
103
|
+
**A backend that must not proxy uses the decomposed form.** A signing backend fronting a browser must authorize the coordinates it is about to sign against its own session, not sign whatever `{method, path, body}` the browser hands it — a backend that signs blindly is an oracle for every credential it holds ([Security Considerations](https://protocol.dfos.com/api-auth#security-considerations)). Such a backend describes the one request it is willing to make, so there is no `Request` for the adapter above to cover:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { buildApiAuthHeaders, signApiRequest } from '@metalabel/dfos-client/api-auth';
|
|
107
|
+
|
|
108
|
+
const { proof } = await signApiRequest({
|
|
109
|
+
method: 'GET',
|
|
110
|
+
host: 'api.example',
|
|
111
|
+
path: '/profile',
|
|
112
|
+
credentialCID,
|
|
113
|
+
kid,
|
|
114
|
+
sign,
|
|
115
|
+
});
|
|
116
|
+
const headers = buildApiAuthHeaders({ proof, credential });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Verifying** is the other half, and it lives here so that an API host's middleware is a thin adapter over the kit rather than a second implementation of the spec's eleven steps:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { verifyApiRequest } from '@metalabel/dfos-client/api-auth';
|
|
123
|
+
|
|
124
|
+
await verifyApiRequest(client, {
|
|
125
|
+
proof,
|
|
126
|
+
credential,
|
|
127
|
+
method: 'GET',
|
|
128
|
+
host: 'api.example', // the verifier's OWN configured authority, never a request header
|
|
129
|
+
path: '/profile',
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
It throws `ApiRequestVerifyError`, carrying `reason` (`invalid` / `unverifiable` / `config`), `phase`, and the recommended `status` — branch on those, never on message text.
|
|
134
|
+
|
|
135
|
+
`apiRequestSigningInput(payload)` is the pure byte contract both halves share, and the one place per language the canonical bytes are built.
|
|
136
|
+
|
|
76
137
|
### `@metalabel/dfos-client/siwd`
|
|
77
138
|
|
|
78
139
|
```typescript
|
|
@@ -86,7 +147,19 @@ import {
|
|
|
86
147
|
|
|
87
148
|
Sign In With DFOS. The three verbs above are the relying-party login kit, in the order a login uses them: `createSiwdLoginRequest` mints the challenge and builds the `/authorize` URL to redirect to, `readSiwdCallback` parses what comes back, and `verifySiwd` verifies it — mint → redirect, read → verify. The `expect` object `createSiwdLoginRequest` returns (nonce, domain, and the DID when the challenge is bound to one) is what `verifySiwd` checks against, so the relying party MUST persist it across the redirect: a verifier that takes its expectation from the callback has implemented the check and none of the protection. See [`examples/siwd-demo`](../../examples/siwd-demo) for the reference consumer.
|
|
88
149
|
|
|
89
|
-
|
|
150
|
+
The `nonce`/`consumeNonce` pair on the expectation (supply exactly one) is the spec's [two replay disciplines](../../specs/SIWD.md#replay-prevention), one field each:
|
|
151
|
+
|
|
152
|
+
**`expect.nonce` — flow-bound login.** For a backend granting only a browser session (`scope=identity`), source the expected nonce from state you bound to that browser at mint time — a server-side session, or the nonce sealed under your own key in an `httpOnly` cookie — and compare:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// mint: cookie = `${nonce}.${hmacSha256(secret, nonce)}`, httpOnly, Max-Age ≤ your window
|
|
156
|
+
// verify: unseal the cookie back to `nonce` (full-length tag, constant-time compare), then
|
|
157
|
+
await verifySiwd(client, jws, { domain, nonce });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The seal (or the session) is what makes this a defense at all: a _bare_ cookie value is presenter-supplied, and an attacker replaying a captured JWS can read the nonce out of the artifact and send it as the cookie. Never compare against anything the presenter could have authored.
|
|
161
|
+
|
|
162
|
+
**`consumeNonce` — spend the nonce.** Required the moment success grants anything beyond a session with the presenting browser (a credential-returning scope, a portable token, a profile-B mailbox flow):
|
|
90
163
|
|
|
91
164
|
```typescript
|
|
92
165
|
await verifySiwd(client, jws, {
|
|
@@ -95,7 +168,7 @@ await verifySiwd(client, jws, {
|
|
|
95
168
|
});
|
|
96
169
|
```
|
|
97
170
|
|
|
98
|
-
`consumeNonce`
|
|
171
|
+
`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.
|
|
99
172
|
|
|
100
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)).
|
|
101
174
|
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { a as Client } from './types-ByxTj1u-.js';
|
|
2
|
+
import '@metalabel/dfos-protocol/chain';
|
|
3
|
+
import '@metalabel/dfos-protocol/credentials';
|
|
4
|
+
import '@metalabel/dfos-web-relay/peer-client';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The normative JWS header `typ` for a request proof (API-AUTH.md). Signers MUST
|
|
8
|
+
* set it; `verifyApiRequest` rejects anything else — it is also what lets
|
|
9
|
+
* typ-routing dispatchers tell a proof apart from credentials and chain ops.
|
|
10
|
+
*/
|
|
11
|
+
declare const REQUEST_PROOF_JWS_TYP = "did:dfos:request-proof";
|
|
12
|
+
/**
|
|
13
|
+
* The digest of zero octets. A request with no body hashes the empty string —
|
|
14
|
+
* there is deliberately no absent-member form for bodyless requests, so every
|
|
15
|
+
* proof is checked the same way.
|
|
16
|
+
*/
|
|
17
|
+
declare const EMPTY_BODY_SHA256 = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
|
|
18
|
+
/** Size cap on the serialized proof token, checked BEFORE any decode. */
|
|
19
|
+
declare const MAX_REQUEST_PROOF_SIZE = 4096;
|
|
20
|
+
/** RECOMMENDED acceptance window `W` — how old a proof may be, in seconds. */
|
|
21
|
+
declare const DEFAULT_PROOF_WINDOW_SECONDS = 60;
|
|
22
|
+
/** RECOMMENDED clock-skew allowance `S` — how forward-dated a proof may be. */
|
|
23
|
+
declare const DEFAULT_PROOF_SKEW_SECONDS = 60;
|
|
24
|
+
/**
|
|
25
|
+
* The binding cap on `W + S`: the total span over which any one proof is
|
|
26
|
+
* accepted, and therefore its worst-case replay window. A configuration
|
|
27
|
+
* exceeding it is refused rather than clamped — a deployment that silently got a
|
|
28
|
+
* 10-minute replay window it did not ask for is the failure this forbids.
|
|
29
|
+
*/
|
|
30
|
+
declare const MAX_PROOF_FRESHNESS_SPAN_SECONDS = 300;
|
|
31
|
+
/** The v0 action registry's only token. */
|
|
32
|
+
declare const DEFAULT_API_ACTION = "read:profile";
|
|
33
|
+
/**
|
|
34
|
+
* Default cap on the decoded body a verifier will hash, in bytes (1 MiB). The v0
|
|
35
|
+
* action registry is bodyless, so this never binds today; it is the defensive
|
|
36
|
+
* ceiling for the first body-bearing action, overridable per verifier.
|
|
37
|
+
*/
|
|
38
|
+
declare const MAX_BODY_BYTES = 1048576;
|
|
39
|
+
interface RequestProofPayload {
|
|
40
|
+
/** The HTTP method, uppercase. */
|
|
41
|
+
method: string;
|
|
42
|
+
/** The API's lowercase authority — `host` on 443, `host:port` otherwise. */
|
|
43
|
+
host: string;
|
|
44
|
+
/** The exact origin-form request target — path plus query string, byte for byte. */
|
|
45
|
+
path: string;
|
|
46
|
+
/** Canonical unpadded base64url of the SHA-256 of the raw request body octets. */
|
|
47
|
+
bodyHash: string;
|
|
48
|
+
/** CID of the leaf credential presented alongside this proof. */
|
|
49
|
+
credentialCID: string;
|
|
50
|
+
/** Issued-at — unix seconds (positive integer). */
|
|
51
|
+
iat: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* THE BYTE CONTRACT. Serializes a payload to the canonical bytes that ARE the
|
|
55
|
+
* JWS payload segment — a fixed key order (method, host, path, bodyHash,
|
|
56
|
+
* credentialCID, iat) with no insignificant whitespace, and `iat` as a bare JSON
|
|
57
|
+
* integer.
|
|
58
|
+
*
|
|
59
|
+
* HTML ESCAPING IS OFF, by construction: `path` routinely carries `&` and admits
|
|
60
|
+
* `<` and `>`, and `JSON.stringify` emits all three literally. The Go byte-twin
|
|
61
|
+
* hand-rolls the same serialization (`ApiRequestSigningInput`) precisely because
|
|
62
|
+
* `encoding/json` would emit `\u0026` / `\u003c` / `\u003e` instead and silently fork
|
|
63
|
+
* the signed bytes.
|
|
64
|
+
*
|
|
65
|
+
* PURE and clientless: import it in a signing backend and in a verifier alike.
|
|
66
|
+
*/
|
|
67
|
+
declare const apiRequestSigningInput: (payload: RequestProofPayload) => Uint8Array;
|
|
68
|
+
/**
|
|
69
|
+
* The `bodyHash` member: canonical unpadded base64url of the SHA-256 of the
|
|
70
|
+
* APPLICATION body octets — the bytes the sender handed its HTTP client, which a
|
|
71
|
+
* verifier obtains after reversing transfer encoding and content encoding. Zero
|
|
72
|
+
* octets hash to `EMPTY_BODY_SHA256`.
|
|
73
|
+
*/
|
|
74
|
+
declare const sha256BodyHash: (body: Uint8Array) => string;
|
|
75
|
+
interface SignApiRequestInput {
|
|
76
|
+
/** The HTTP method, uppercase. */
|
|
77
|
+
method: string;
|
|
78
|
+
/** The API's lowercase authority — `host` on 443, `host:port` otherwise. */
|
|
79
|
+
host: string;
|
|
80
|
+
/** The exact origin-form request target this proof will ride. */
|
|
81
|
+
path: string;
|
|
82
|
+
/** Application body octets; omitted or empty hashes to `EMPTY_BODY_SHA256`. */
|
|
83
|
+
body?: Uint8Array;
|
|
84
|
+
/** CID of the leaf credential presented alongside this proof. */
|
|
85
|
+
credentialCID: string;
|
|
86
|
+
/**
|
|
87
|
+
* The signing key's DID URL. Its DID portion MUST be the leaf credential's
|
|
88
|
+
* `aud` — that equality IS the possession being proven.
|
|
89
|
+
*/
|
|
90
|
+
kid: string;
|
|
91
|
+
/** Raw Ed25519 signer over the JWS signing input. */
|
|
92
|
+
sign: (message: Uint8Array) => Promise<Uint8Array>;
|
|
93
|
+
/** Issued-at override — unix seconds. Default `Math.floor(Date.now() / 1000)`. */
|
|
94
|
+
iat?: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Sign one request. The producer half of the byte contract.
|
|
98
|
+
*
|
|
99
|
+
* `createJws` serializes the payload with `JSON.stringify`, so passing the
|
|
100
|
+
* fixed-order object makes the emitted payload segment EXACTLY
|
|
101
|
+
* `apiRequestSigningInput(payload)` — the equivalence is pinned by a test rather
|
|
102
|
+
* than assumed, because it is the whole reason there is one byte contract and
|
|
103
|
+
* not two.
|
|
104
|
+
*/
|
|
105
|
+
declare const signApiRequest: (input: SignApiRequestInput) => Promise<{
|
|
106
|
+
proof: string;
|
|
107
|
+
payload: RequestProofPayload;
|
|
108
|
+
}>;
|
|
109
|
+
/**
|
|
110
|
+
* The two headers a credential-gated request carries. The `Authorization` scheme
|
|
111
|
+
* is the token `DFOS`, deliberately NOT `Bearer`: nothing carried here is a
|
|
112
|
+
* bearer token, and naming it one invites bearer handling (logging, caching,
|
|
113
|
+
* forwarding) that this artifact exists to make useless.
|
|
114
|
+
*/
|
|
115
|
+
declare const buildApiAuthHeaders: (input: {
|
|
116
|
+
proof: string;
|
|
117
|
+
credential: string;
|
|
118
|
+
}) => {
|
|
119
|
+
Authorization: string;
|
|
120
|
+
"X-Credential": string;
|
|
121
|
+
};
|
|
122
|
+
interface CreateApiAuthFetchOptions {
|
|
123
|
+
/**
|
|
124
|
+
* The leaf credential JWS to present — the `X-Credential` value, and the
|
|
125
|
+
* source of every proof's `credentialCID`. It embeds its chain in `prf`.
|
|
126
|
+
*/
|
|
127
|
+
credential: string;
|
|
128
|
+
/**
|
|
129
|
+
* The signing key's DID URL. Its DID portion MUST be the credential's `aud` —
|
|
130
|
+
* that equality IS the possession being proven.
|
|
131
|
+
*/
|
|
132
|
+
kid: string;
|
|
133
|
+
/** Raw Ed25519 signer over the JWS signing input. Never key material. */
|
|
134
|
+
sign: (message: Uint8Array) => Promise<Uint8Array>;
|
|
135
|
+
/** The underlying transport. Default `globalThis.fetch`. */
|
|
136
|
+
fetch?: typeof fetch;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A signing `fetch`. Hand it to any API client with a fetch seam and every
|
|
140
|
+
* request that client composes goes out credential-gated:
|
|
141
|
+
*
|
|
142
|
+
* ```ts
|
|
143
|
+
* createDfosApi({ fetch: createApiAuthFetch({ credential, kid, sign }) })
|
|
144
|
+
* ```
|
|
145
|
+
*
|
|
146
|
+
* It signs EXACTLY the `Request` it receives — the method, the origin-form
|
|
147
|
+
* target, and the body octets already composed — rather than a description of
|
|
148
|
+
* one. That is what keeps the binding honest: the bytes the proof covers are the
|
|
149
|
+
* bytes that go on the wire.
|
|
150
|
+
*
|
|
151
|
+
* `signApiRequest` stays exported for the backends that must NOT proxy. A
|
|
152
|
+
* signing backend fronting a browser MUST authorize the coordinates it is about
|
|
153
|
+
* to sign against its own session (API-AUTH.md, Security Considerations) — it
|
|
154
|
+
* describes the one request it is willing to make rather than receiving one, so
|
|
155
|
+
* there is no `Request` for this adapter to cover.
|
|
156
|
+
*
|
|
157
|
+
* Two things are deliberately absent. There is no credential-provider callback:
|
|
158
|
+
* a caller whose credential rotates builds a new fetch, which is one line and
|
|
159
|
+
* has no lifecycle to get wrong. And there is no host allowlist: the caller
|
|
160
|
+
* composing the URL is already the party choosing the host, so an allowlist here
|
|
161
|
+
* would guard a decision it does not make.
|
|
162
|
+
*
|
|
163
|
+
* TWO REFUSALS, both because the adapter is the last place that can see them.
|
|
164
|
+
* It will not sign a plaintext request to a real host (`api:` surfaces are HTTPS
|
|
165
|
+
* surfaces — the proof and the credential would go out in the clear, and the
|
|
166
|
+
* proof replays over HTTPS for its whole freshness window), and it will not
|
|
167
|
+
* follow redirects.
|
|
168
|
+
*
|
|
169
|
+
* BUFFERING IS INHERENT. The byte contract hashes the complete body before
|
|
170
|
+
* signing, so a request body is buffered in full before anything is sent. This
|
|
171
|
+
* adapter is for size-bounded requests; an unbounded or live stream cannot be
|
|
172
|
+
* proof-signed at all, in any implementation.
|
|
173
|
+
*/
|
|
174
|
+
declare const createApiAuthFetch: (options: CreateApiAuthFetchOptions) => typeof fetch;
|
|
175
|
+
/**
|
|
176
|
+
* The verdict class. Branch on `reason`, never on message text.
|
|
177
|
+
*
|
|
178
|
+
* - `invalid` — checked and failed.
|
|
179
|
+
* - `unverifiable` — could not check (an unresolvable presenter, an unreachable
|
|
180
|
+
* revocation source). A transient resolution failure is the server's
|
|
181
|
+
* condition, not the caller's.
|
|
182
|
+
* - `config` — the DEPLOYMENT is misconfigured (a `W + S` over the 300-second
|
|
183
|
+
* ceiling, or an empty required action). Not a judgment about the artifact.
|
|
184
|
+
*/
|
|
185
|
+
type RequestProofFailureReason = 'invalid' | 'unverifiable' | 'config';
|
|
186
|
+
/**
|
|
187
|
+
* The verification phase a failure arose in. Load-bearing for HTTP mapping: an
|
|
188
|
+
* `invalid` proof-layer failure is a 401 (with a `WWW-Authenticate: DFOS`
|
|
189
|
+
* challenge), an `invalid` credential-layer failure is a 403. `status` carries
|
|
190
|
+
* the recommended code directly so middleware never has to re-derive it.
|
|
191
|
+
*/
|
|
192
|
+
type RequestProofFailurePhase = 'proof' | 'credential' | 'config';
|
|
193
|
+
/** Branch on `reason`/`phase`/`status`, never on message text. */
|
|
194
|
+
declare class ApiRequestVerifyError extends Error {
|
|
195
|
+
readonly reason: RequestProofFailureReason;
|
|
196
|
+
readonly phase: RequestProofFailurePhase;
|
|
197
|
+
/** Recommended HTTP status: 401 proof-invalid, 403 credential-invalid, 503 unverifiable, 500 config. */
|
|
198
|
+
readonly status: number;
|
|
199
|
+
constructor(reason: RequestProofFailureReason, phase: RequestProofFailurePhase, status: number, message: string);
|
|
200
|
+
}
|
|
201
|
+
interface VerifyApiRequestInput {
|
|
202
|
+
/** The request-proof JWS — the `Authorization: DFOS <token>` token, scheme stripped. */
|
|
203
|
+
proof: string;
|
|
204
|
+
/** The leaf credential JWS — the `X-Credential` value. It embeds its chain in `prf`. */
|
|
205
|
+
credential: string;
|
|
206
|
+
/**
|
|
207
|
+
* THE VERIFIER'S OWN CONFIGURED AUTHORITY for the route being served — a value
|
|
208
|
+
* the deployment holds, NEVER one read from the request. `Host`,
|
|
209
|
+
* `X-Forwarded-Host`, and the request URL's authority are all attacker-supplied:
|
|
210
|
+
* a verifier that compared the proof's `host` against a request header would
|
|
211
|
+
* have no host binding at all. Include the port when it is not 443.
|
|
212
|
+
*
|
|
213
|
+
* It is also the id half of the `api:<host>` resource string this verifier
|
|
214
|
+
* requires, so the request binding and the grant name the same origin.
|
|
215
|
+
*/
|
|
216
|
+
host: string;
|
|
217
|
+
/** The received request's method. */
|
|
218
|
+
method: string;
|
|
219
|
+
/** The received origin-form request target — path plus query string, byte for byte. */
|
|
220
|
+
path: string;
|
|
221
|
+
/** The received application body octets, post-content-decoding. Omitted = no body. */
|
|
222
|
+
body?: Uint8Array;
|
|
223
|
+
/**
|
|
224
|
+
* Cap on the decoded body this verifier will hash, in bytes. Default
|
|
225
|
+
* `MAX_BODY_BYTES`. A body over the cap is refused BEFORE the SHA-256 (a
|
|
226
|
+
* proof-layer `413`), so a well-formed proof with a bad signature cannot force
|
|
227
|
+
* an unbounded hash. NOTE: the spec's "abort decode at the cap" is a MIDDLEWARE
|
|
228
|
+
* obligation — by the time the body reaches this helper it is already a buffered
|
|
229
|
+
* `Uint8Array`, so this is the second, defensive cap; the middleware must still
|
|
230
|
+
* bound decoding upstream (a decompression bomb inflates before the kit sees it).
|
|
231
|
+
*/
|
|
232
|
+
maxBodyBytes?: number;
|
|
233
|
+
/** The action token this route requires. Default `read:profile`. */
|
|
234
|
+
action?: string;
|
|
235
|
+
/** Acceptance window `W`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
|
|
236
|
+
windowSeconds?: number;
|
|
237
|
+
/** Clock-skew allowance `S`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
|
|
238
|
+
skewSeconds?: number;
|
|
239
|
+
/**
|
|
240
|
+
* Accept a presenter resolution whose tip could not be verified (cache-only or
|
|
241
|
+
* empty-delta-against-cache). Default FALSE: key resolution is CURRENT-STATE,
|
|
242
|
+
* and a rotated-out key must not keep minting proofs against a stale cache.
|
|
243
|
+
*/
|
|
244
|
+
allowStale?: boolean;
|
|
245
|
+
/** Clock injection (unix ms). Default `Date.now()`. */
|
|
246
|
+
now?: () => number;
|
|
247
|
+
}
|
|
248
|
+
interface VerifiedRequestProof {
|
|
249
|
+
/** The chain's root `iss` — the DID whose data this request serves. */
|
|
250
|
+
subjectDID: string;
|
|
251
|
+
/** The authority the grant and the binding both name. */
|
|
252
|
+
host: string;
|
|
253
|
+
/** The action token the leaf's attenuation was found to cover. */
|
|
254
|
+
action: string;
|
|
255
|
+
/** The proof's issued-at, unix seconds. */
|
|
256
|
+
iat: number;
|
|
257
|
+
/** The leaf credential's CID, re-derived and equal to the proof's member. */
|
|
258
|
+
credentialCID: string;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Verify a credential-gated request — API-AUTH.md's eleven steps, in an order
|
|
262
|
+
* that honors both load-bearing ordering rules: the proof signature gates every
|
|
263
|
+
* credential-chain step, and body hashing runs after the cheaper binding checks.
|
|
264
|
+
*
|
|
265
|
+
* `client` supplies the resolver seam (current-state identity resolution plus the
|
|
266
|
+
* revocation checker) exactly as `verifySiwd` does. Everything the verifier
|
|
267
|
+
* compares against — host, method, path, body, action — is passed in BY THE
|
|
268
|
+
* DEPLOYMENT: this helper never reads a request object, because the one thing a
|
|
269
|
+
* host binding must not be sourced from is the request.
|
|
270
|
+
*
|
|
271
|
+
* Throws `ApiRequestVerifyError`; branch on `reason`/`phase`/`status`, never on
|
|
272
|
+
* message text. `status` is the recommended HTTP code (401 proof-invalid, 403
|
|
273
|
+
* credential-invalid, 503 unverifiable, 500 config).
|
|
274
|
+
*
|
|
275
|
+
* REVOCATION AND RESOLUTION AVAILABILITY — read before deploying. This helper
|
|
276
|
+
* rejects a credential it KNOWS is revoked (`isRevoked` true at any chain level).
|
|
277
|
+
* It does NOT, with the default client, fail closed when the revocation source is
|
|
278
|
+
* unreachable: the stock `createRevocationChecker` is fail-open by design
|
|
279
|
+
* ("no revocation found" and "could not reach any relay" both return false), the
|
|
280
|
+
* system-wide v1 stance that "non-revocation is never provable." Likewise a
|
|
281
|
+
* credential-issuer that is unresolvable because relays are down surfaces from the
|
|
282
|
+
* protocol verifier as a `CredentialVerificationError` and is reported here as
|
|
283
|
+
* `invalid` (403), not `unverifiable` (503) — the underlying callback cannot
|
|
284
|
+
* distinguish "genuinely absent" from "transiently unreachable." The PRESENTER
|
|
285
|
+
* side is availability-aware (a resolution failure or unverified/stale tip is
|
|
286
|
+
* `unverifiable`, failing closed unless `allowStale`); the CREDENTIAL side inherits
|
|
287
|
+
* the v1 primitives' limitation. A deployment that needs fail-closed-on-outage for
|
|
288
|
+
* the credential/revocation phase MUST inject an availability-aware `isRevoked`
|
|
289
|
+
* (one that THROWS when it reaches zero sources — the throw is surfaced here as
|
|
290
|
+
* `unverifiable`) via the client config. Tightening the default is a client-level
|
|
291
|
+
* change to the shared revocation/resolution contract (it governs SIWD and relay
|
|
292
|
+
* verification too), tracked outside this kit.
|
|
293
|
+
*/
|
|
294
|
+
declare const verifyApiRequest: (client: Client, input: VerifyApiRequestInput) => Promise<VerifiedRequestProof>;
|
|
295
|
+
|
|
296
|
+
export { ApiRequestVerifyError, type CreateApiAuthFetchOptions, DEFAULT_API_ACTION, DEFAULT_PROOF_SKEW_SECONDS, DEFAULT_PROOF_WINDOW_SECONDS, EMPTY_BODY_SHA256, MAX_BODY_BYTES, MAX_PROOF_FRESHNESS_SPAN_SECONDS, MAX_REQUEST_PROOF_SIZE, REQUEST_PROOF_JWS_TYP, type RequestProofFailurePhase, type RequestProofFailureReason, type RequestProofPayload, type SignApiRequestInput, type VerifiedRequestProof, type VerifyApiRequestInput, apiRequestSigningInput, buildApiAuthHeaders, createApiAuthFetch, sha256BodyHash, signApiRequest, verifyApiRequest };
|
package/dist/api-auth.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// src/api-auth.ts
|
|
2
|
+
import { decodeMultikey } from "@metalabel/dfos-protocol/chain";
|
|
3
|
+
import {
|
|
4
|
+
CredentialVerificationError,
|
|
5
|
+
decodeDFOSCredentialUnsafe,
|
|
6
|
+
matchesResource,
|
|
7
|
+
MAX_CREDENTIAL_SIZE,
|
|
8
|
+
verifyDelegationChain,
|
|
9
|
+
verifyDFOSCredential
|
|
10
|
+
} from "@metalabel/dfos-protocol/credentials";
|
|
11
|
+
import {
|
|
12
|
+
assertJwsProfile,
|
|
13
|
+
base64urlDecode,
|
|
14
|
+
base64urlEncode,
|
|
15
|
+
createJws,
|
|
16
|
+
decodeJwsUnsafe,
|
|
17
|
+
sha256,
|
|
18
|
+
verifyJws
|
|
19
|
+
} from "@metalabel/dfos-protocol/crypto";
|
|
20
|
+
var REQUEST_PROOF_JWS_TYP = "did:dfos:request-proof";
|
|
21
|
+
var EMPTY_BODY_SHA256 = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
|
|
22
|
+
var MAX_REQUEST_PROOF_SIZE = 4096;
|
|
23
|
+
var DEFAULT_PROOF_WINDOW_SECONDS = 60;
|
|
24
|
+
var DEFAULT_PROOF_SKEW_SECONDS = 60;
|
|
25
|
+
var MAX_PROOF_FRESHNESS_SPAN_SECONDS = 300;
|
|
26
|
+
var DEFAULT_API_ACTION = "read:profile";
|
|
27
|
+
var MAX_BODY_BYTES = 1048576;
|
|
28
|
+
var MAX_DELEGATION_DEPTH = 16;
|
|
29
|
+
var encoder = new TextEncoder();
|
|
30
|
+
var EMPTY_BODY = new Uint8Array(0);
|
|
31
|
+
var UPPERCASE_METHOD = /^[A-Z0-9!#$%&'*+.^_`|~-]+$/;
|
|
32
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
33
|
+
var CTL_OR_SPACE = /[\u0000-\u0020\u007f]/;
|
|
34
|
+
var BASE64URL_32 = /^[A-Za-z0-9_-]{43}$/;
|
|
35
|
+
var assertNoLoneSurrogate = (value, field) => {
|
|
36
|
+
if (LONE_SURROGATE.test(value)) {
|
|
37
|
+
throw new Error(`invalid request proof: ${field} must be well-formed Unicode`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var validateRequestProofPayload = (value) => {
|
|
41
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
42
|
+
throw new Error("invalid request proof: expected a JSON object");
|
|
43
|
+
}
|
|
44
|
+
const raw = value;
|
|
45
|
+
for (const field of ["method", "host", "path", "bodyHash", "credentialCID"]) {
|
|
46
|
+
if (typeof raw[field] !== "string" || raw[field] === "") {
|
|
47
|
+
throw new Error(`invalid request proof: ${field} must be a non-empty string`);
|
|
48
|
+
}
|
|
49
|
+
assertNoLoneSurrogate(raw[field], field);
|
|
50
|
+
}
|
|
51
|
+
const method = raw["method"];
|
|
52
|
+
if (!UPPERCASE_METHOD.test(method)) {
|
|
53
|
+
throw new Error("invalid request proof: method must be an uppercase HTTP method token");
|
|
54
|
+
}
|
|
55
|
+
const host = raw["host"];
|
|
56
|
+
if (host !== host.toLowerCase() || /[\s/\\?#]/.test(host)) {
|
|
57
|
+
throw new Error("invalid request proof: host must be a lowercase authority, without a scheme");
|
|
58
|
+
}
|
|
59
|
+
const path = raw["path"];
|
|
60
|
+
if (!path.startsWith("/")) {
|
|
61
|
+
throw new Error("invalid request proof: path must begin with /");
|
|
62
|
+
}
|
|
63
|
+
if (path.includes("#")) {
|
|
64
|
+
throw new Error("invalid request proof: path must not carry a fragment");
|
|
65
|
+
}
|
|
66
|
+
if (CTL_OR_SPACE.test(path)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"invalid request proof: path must not contain whitespace or control characters"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const bodyHash = raw["bodyHash"];
|
|
72
|
+
if (!BASE64URL_32.test(bodyHash) || base64urlEncode(base64urlDecode(bodyHash)) !== bodyHash) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"invalid request proof: bodyHash must be the canonical unpadded base64url of 32 bytes"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const iat = raw["iat"];
|
|
78
|
+
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat <= 0) {
|
|
79
|
+
throw new Error("invalid request proof: iat must be a positive integer");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
method,
|
|
83
|
+
host,
|
|
84
|
+
path,
|
|
85
|
+
bodyHash,
|
|
86
|
+
credentialCID: raw["credentialCID"],
|
|
87
|
+
iat
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
var apiRequestSigningInput = (payload) => {
|
|
91
|
+
const parsed = validateRequestProofPayload(payload);
|
|
92
|
+
return encoder.encode(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
method: parsed.method,
|
|
95
|
+
host: parsed.host,
|
|
96
|
+
path: parsed.path,
|
|
97
|
+
bodyHash: parsed.bodyHash,
|
|
98
|
+
credentialCID: parsed.credentialCID,
|
|
99
|
+
iat: parsed.iat
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
var sha256BodyHash = (body) => base64urlEncode(sha256(body));
|
|
104
|
+
var signApiRequest = async (input) => {
|
|
105
|
+
const payload = validateRequestProofPayload({
|
|
106
|
+
method: input.method,
|
|
107
|
+
host: input.host,
|
|
108
|
+
path: input.path,
|
|
109
|
+
bodyHash: sha256BodyHash(input.body ?? EMPTY_BODY),
|
|
110
|
+
credentialCID: input.credentialCID,
|
|
111
|
+
iat: input.iat ?? Math.floor(Date.now() / 1e3)
|
|
112
|
+
});
|
|
113
|
+
if (!input.kid.includes("#")) {
|
|
114
|
+
throw new Error("invalid request proof: kid must be a DID URL");
|
|
115
|
+
}
|
|
116
|
+
const proof = await createJws({
|
|
117
|
+
header: { alg: "EdDSA", typ: REQUEST_PROOF_JWS_TYP, kid: input.kid },
|
|
118
|
+
payload: {
|
|
119
|
+
method: payload.method,
|
|
120
|
+
host: payload.host,
|
|
121
|
+
path: payload.path,
|
|
122
|
+
bodyHash: payload.bodyHash,
|
|
123
|
+
credentialCID: payload.credentialCID,
|
|
124
|
+
iat: payload.iat
|
|
125
|
+
},
|
|
126
|
+
sign: input.sign
|
|
127
|
+
});
|
|
128
|
+
if (proof.length > MAX_REQUEST_PROOF_SIZE) {
|
|
129
|
+
throw new Error(`request proof exceeds max size: ${proof.length} > ${MAX_REQUEST_PROOF_SIZE}`);
|
|
130
|
+
}
|
|
131
|
+
return { proof, payload };
|
|
132
|
+
};
|
|
133
|
+
var buildApiAuthHeaders = (input) => ({
|
|
134
|
+
Authorization: `DFOS ${input.proof}`,
|
|
135
|
+
"X-Credential": input.credential
|
|
136
|
+
});
|
|
137
|
+
var credentialCIDFromHeader = (credential) => {
|
|
138
|
+
const decoded = decodeJwsUnsafe(credential);
|
|
139
|
+
if (!decoded) throw new Error("invalid credential: failed to decode the credential JWS");
|
|
140
|
+
const cid = decoded.header.cid;
|
|
141
|
+
if (typeof cid !== "string" || cid === "") {
|
|
142
|
+
throw new Error("invalid credential: the credential JWS carries no cid header");
|
|
143
|
+
}
|
|
144
|
+
return cid;
|
|
145
|
+
};
|
|
146
|
+
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
147
|
+
var createApiAuthFetch = (options) => {
|
|
148
|
+
const credentialCID = credentialCIDFromHeader(options.credential);
|
|
149
|
+
const send = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
150
|
+
return async (input, init) => {
|
|
151
|
+
const request = init === void 0 && input instanceof Request ? input : new Request(input, init);
|
|
152
|
+
const url = new URL(request.url);
|
|
153
|
+
if (url.protocol !== "https:" && !LOOPBACK_HOSTNAMES.has(url.hostname)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`refusing to sign a ${url.protocol}// request to ${url.host}: api: surfaces are HTTPS surfaces, and a proof sent in the clear replays for its whole freshness window (plaintext is allowed only to localhost, 127.0.0.1, and [::1])`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const { proof } = await signApiRequest({
|
|
159
|
+
method: request.method,
|
|
160
|
+
// `host`, never `hostname`: the authority carries the port when there is
|
|
161
|
+
// one, and the verifier compares it byte for byte.
|
|
162
|
+
host: url.host,
|
|
163
|
+
// Path plus query, byte for byte — no normalization, because the verifier
|
|
164
|
+
// compares against the request target it actually received. Dropping
|
|
165
|
+
// `.search` is the classic silent 401.
|
|
166
|
+
path: url.pathname + url.search,
|
|
167
|
+
// Hash the CLONE and forward the original: buffering the request's own
|
|
168
|
+
// stream would leave nothing to send. Buffering at all is inherent — the
|
|
169
|
+
// proof covers the WHOLE body, so there is nothing to sign until the last
|
|
170
|
+
// octet is in hand.
|
|
171
|
+
body: new Uint8Array(await request.clone().arrayBuffer()),
|
|
172
|
+
credentialCID,
|
|
173
|
+
kid: options.kid,
|
|
174
|
+
sign: options.sign
|
|
175
|
+
});
|
|
176
|
+
const headers = new Headers(request.headers);
|
|
177
|
+
for (const [name, value] of Object.entries(
|
|
178
|
+
buildApiAuthHeaders({ proof, credential: options.credential })
|
|
179
|
+
)) {
|
|
180
|
+
headers.set(name, value);
|
|
181
|
+
}
|
|
182
|
+
return send(new Request(request, { headers, redirect: "manual" }));
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
var ApiRequestVerifyError = class extends Error {
|
|
186
|
+
reason;
|
|
187
|
+
phase;
|
|
188
|
+
/** Recommended HTTP status: 401 proof-invalid, 403 credential-invalid, 503 unverifiable, 500 config. */
|
|
189
|
+
status;
|
|
190
|
+
constructor(reason, phase, status, message) {
|
|
191
|
+
super(message);
|
|
192
|
+
this.name = "ApiRequestVerifyError";
|
|
193
|
+
this.reason = reason;
|
|
194
|
+
this.phase = phase;
|
|
195
|
+
this.status = status;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
var invalidProof = (message) => new ApiRequestVerifyError("invalid", "proof", 401, message);
|
|
199
|
+
var invalidCredential = (message) => new ApiRequestVerifyError("invalid", "credential", 403, message);
|
|
200
|
+
var unverifiableProof = (message) => new ApiRequestVerifyError("unverifiable", "proof", 503, message);
|
|
201
|
+
var unverifiableCredential = (message) => new ApiRequestVerifyError("unverifiable", "credential", 503, message);
|
|
202
|
+
var misconfigured = (message) => new ApiRequestVerifyError("config", "config", 500, message);
|
|
203
|
+
var discoverChainRoot = (leafToken) => {
|
|
204
|
+
let token = leafToken;
|
|
205
|
+
for (let depth = 0; depth < MAX_DELEGATION_DEPTH; depth++) {
|
|
206
|
+
const decoded = decodeDFOSCredentialUnsafe(token);
|
|
207
|
+
if (!decoded) throw invalidCredential("failed to decode presented credential");
|
|
208
|
+
if (decoded.payload.prf.length === 0) return decoded.payload.iss;
|
|
209
|
+
if (decoded.payload.prf.length > 1) {
|
|
210
|
+
throw invalidCredential("delegation chain: multi-parent credentials are not supported");
|
|
211
|
+
}
|
|
212
|
+
token = decoded.payload.prf[0];
|
|
213
|
+
}
|
|
214
|
+
throw invalidCredential("delegation chain too deep (max 16 credentials)");
|
|
215
|
+
};
|
|
216
|
+
var verifyApiRequest = async (client, input) => {
|
|
217
|
+
const window = input.windowSeconds ?? DEFAULT_PROOF_WINDOW_SECONDS;
|
|
218
|
+
const skew = input.skewSeconds ?? DEFAULT_PROOF_SKEW_SECONDS;
|
|
219
|
+
for (const [name, value] of [
|
|
220
|
+
["windowSeconds", window],
|
|
221
|
+
["skewSeconds", skew]
|
|
222
|
+
]) {
|
|
223
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
224
|
+
throw misconfigured(`${name} must be a non-negative integer`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (window + skew > MAX_PROOF_FRESHNESS_SPAN_SECONDS) {
|
|
228
|
+
throw misconfigured(
|
|
229
|
+
`request proof freshness span W + S exceeds ${MAX_PROOF_FRESHNESS_SPAN_SECONDS} seconds: ${window} + ${skew}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const action = input.action ?? DEFAULT_API_ACTION;
|
|
233
|
+
if (action.split(",").every((token) => token.trim() === "")) {
|
|
234
|
+
throw misconfigured("required action must name a non-empty token");
|
|
235
|
+
}
|
|
236
|
+
const maxBodyBytes = input.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
237
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) {
|
|
238
|
+
throw misconfigured("maxBodyBytes must be a non-negative integer");
|
|
239
|
+
}
|
|
240
|
+
if (input.proof.length > MAX_REQUEST_PROOF_SIZE) {
|
|
241
|
+
throw invalidProof(
|
|
242
|
+
`request proof exceeds max size: ${input.proof.length} > ${MAX_REQUEST_PROOF_SIZE}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (input.credential.length > MAX_CREDENTIAL_SIZE) {
|
|
246
|
+
throw invalidProof(
|
|
247
|
+
`credential exceeds max size: ${input.credential.length} > ${MAX_CREDENTIAL_SIZE}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const decoded = decodeJwsUnsafe(input.proof);
|
|
251
|
+
if (!decoded) throw invalidProof("failed to decode request proof JWS");
|
|
252
|
+
const rawHeader = decoded.header;
|
|
253
|
+
if (typeof rawHeader !== "object" || rawHeader === null || Array.isArray(rawHeader)) {
|
|
254
|
+
throw invalidProof("request proof protected header must be an object");
|
|
255
|
+
}
|
|
256
|
+
assertJwsProfile(rawHeader, invalidProof);
|
|
257
|
+
if (decoded.header.typ !== REQUEST_PROOF_JWS_TYP) {
|
|
258
|
+
throw invalidProof(`invalid typ: expected ${REQUEST_PROOF_JWS_TYP}, got ${decoded.header.typ}`);
|
|
259
|
+
}
|
|
260
|
+
const kid = decoded.header.kid;
|
|
261
|
+
if (typeof kid !== "string" || !kid.includes("#")) {
|
|
262
|
+
throw invalidProof("request proof kid must be a DID URL");
|
|
263
|
+
}
|
|
264
|
+
const presenterDID = kid.substring(0, kid.indexOf("#"));
|
|
265
|
+
const presenterKeyId = kid.substring(kid.indexOf("#") + 1);
|
|
266
|
+
const payloadSegment = input.proof.split(".")[1];
|
|
267
|
+
if (payloadSegment === void 0) throw invalidProof("failed to decode request proof payload");
|
|
268
|
+
let payload;
|
|
269
|
+
try {
|
|
270
|
+
const source = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
271
|
+
base64urlDecode(payloadSegment)
|
|
272
|
+
);
|
|
273
|
+
payload = validateRequestProofPayload(JSON.parse(source));
|
|
274
|
+
} catch (err) {
|
|
275
|
+
throw invalidProof(err instanceof Error ? err.message : "invalid request proof payload");
|
|
276
|
+
}
|
|
277
|
+
const now = Math.floor((input.now ? input.now() : Date.now()) / 1e3);
|
|
278
|
+
if (now - payload.iat > window) throw invalidProof("request proof is stale");
|
|
279
|
+
if (payload.iat - now > skew) {
|
|
280
|
+
throw invalidProof("request proof iat is beyond the clock-skew allowance");
|
|
281
|
+
}
|
|
282
|
+
if (payload.method !== input.method) throw invalidProof("request proof method mismatch");
|
|
283
|
+
if (payload.host !== input.host) throw invalidProof("request proof host mismatch");
|
|
284
|
+
if (payload.path !== input.path) throw invalidProof("request proof path mismatch");
|
|
285
|
+
const body = input.body ?? EMPTY_BODY;
|
|
286
|
+
if (body.length > maxBodyBytes) {
|
|
287
|
+
throw new ApiRequestVerifyError(
|
|
288
|
+
"invalid",
|
|
289
|
+
"proof",
|
|
290
|
+
413,
|
|
291
|
+
`request body exceeds max size: ${body.length} > ${maxBodyBytes}`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (payload.bodyHash !== sha256BodyHash(body)) {
|
|
295
|
+
throw invalidProof("request proof bodyHash mismatch");
|
|
296
|
+
}
|
|
297
|
+
let resolved;
|
|
298
|
+
try {
|
|
299
|
+
resolved = await client.identity(presenterDID);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
throw unverifiableProof(
|
|
302
|
+
`failed to resolve request proof presenter: ${err instanceof Error ? err.message : String(err)}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const axes = resolved.trust.unverifiable ?? [];
|
|
306
|
+
if (!input.allowStale && (axes.includes("tip") || resolved.provenance.fromCache)) {
|
|
307
|
+
throw unverifiableProof(
|
|
308
|
+
"presenter identity resolution is stale (tip unverified) \u2014 refusing to authenticate against a cached identity state; pass allowStale: true to accept the risk"
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const state = resolved.value;
|
|
312
|
+
if (state.isDeleted) throw invalidProof("request proof presenter identity is deleted");
|
|
313
|
+
const key = [...state.authKeys, ...state.assertKeys, ...state.controllerKeys].find(
|
|
314
|
+
(candidate) => candidate.id === presenterKeyId
|
|
315
|
+
);
|
|
316
|
+
if (!key) throw invalidProof("request proof signing key is not a current key of the presenter");
|
|
317
|
+
try {
|
|
318
|
+
verifyJws({ token: input.proof, publicKey: decodeMultikey(key.publicKeyMultibase).keyBytes });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
throw invalidProof(err instanceof Error ? err.message : "invalid request proof signature");
|
|
321
|
+
}
|
|
322
|
+
const { isRevoked, resolveIdentity } = client.callbacks();
|
|
323
|
+
const rootDID = discoverChainRoot(input.credential);
|
|
324
|
+
let leaf;
|
|
325
|
+
let chain;
|
|
326
|
+
try {
|
|
327
|
+
leaf = await verifyDFOSCredential(input.credential, { resolveIdentity, now });
|
|
328
|
+
if (await isRevoked(leaf.iss, leaf.credentialCID)) {
|
|
329
|
+
throw new CredentialVerificationError("credential is revoked");
|
|
330
|
+
}
|
|
331
|
+
const verifiedChain = await verifyDelegationChain(leaf, {
|
|
332
|
+
resolveIdentity,
|
|
333
|
+
rootDID,
|
|
334
|
+
now,
|
|
335
|
+
isRevoked
|
|
336
|
+
});
|
|
337
|
+
chain = verifiedChain.chain;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (err instanceof ApiRequestVerifyError) throw err;
|
|
340
|
+
if (err instanceof CredentialVerificationError) throw invalidCredential(err.message);
|
|
341
|
+
throw unverifiableCredential(
|
|
342
|
+
`credential verification could not complete: ${err instanceof Error ? err.message : String(err)}`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (leaf.credentialCID !== payload.credentialCID) {
|
|
346
|
+
throw invalidCredential("request proof credentialCID does not match the presented credential");
|
|
347
|
+
}
|
|
348
|
+
for (const hop of chain) {
|
|
349
|
+
if (hop.aud === "*") {
|
|
350
|
+
throw invalidCredential(
|
|
351
|
+
'a credential in the presented chain carries a public audience (aud: "*")'
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (leaf.aud !== presenterDID) {
|
|
356
|
+
throw invalidCredential("credential audience does not match the request proof signing key");
|
|
357
|
+
}
|
|
358
|
+
if (!await matchesResource(leaf.att, `api:${input.host}`, action)) {
|
|
359
|
+
throw invalidCredential(`credential does not cover ${action} on api:${input.host}`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
subjectDID: rootDID,
|
|
363
|
+
host: input.host,
|
|
364
|
+
action,
|
|
365
|
+
iat: payload.iat,
|
|
366
|
+
credentialCID: leaf.credentialCID
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
export {
|
|
370
|
+
ApiRequestVerifyError,
|
|
371
|
+
DEFAULT_API_ACTION,
|
|
372
|
+
DEFAULT_PROOF_SKEW_SECONDS,
|
|
373
|
+
DEFAULT_PROOF_WINDOW_SECONDS,
|
|
374
|
+
EMPTY_BODY_SHA256,
|
|
375
|
+
MAX_BODY_BYTES,
|
|
376
|
+
MAX_PROOF_FRESHNESS_SPAN_SECONDS,
|
|
377
|
+
MAX_REQUEST_PROOF_SIZE,
|
|
378
|
+
REQUEST_PROOF_JWS_TYP,
|
|
379
|
+
apiRequestSigningInput,
|
|
380
|
+
buildApiAuthHeaders,
|
|
381
|
+
createApiAuthFetch,
|
|
382
|
+
sha256BodyHash,
|
|
383
|
+
signApiRequest,
|
|
384
|
+
verifyApiRequest
|
|
385
|
+
};
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalabel/dfos-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "DFOS Client —
|
|
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",
|
|
7
7
|
"author": "Metalabel <hello@metalabel.com> (https://metalabel.com)",
|
|
8
8
|
"repository": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"import": "./dist/index.js",
|
|
28
28
|
"types": "./dist/index.d.ts"
|
|
29
29
|
},
|
|
30
|
+
"./api-auth": {
|
|
31
|
+
"import": "./dist/api-auth.js",
|
|
32
|
+
"types": "./dist/api-auth.d.ts"
|
|
33
|
+
},
|
|
30
34
|
"./siwd": {
|
|
31
35
|
"import": "./dist/siwd.js",
|
|
32
36
|
"types": "./dist/siwd.d.ts"
|
|
@@ -43,15 +47,15 @@
|
|
|
43
47
|
"README.md"
|
|
44
48
|
],
|
|
45
49
|
"peerDependencies": {
|
|
46
|
-
"@metalabel/dfos-protocol": "^0.
|
|
47
|
-
"@metalabel/dfos-web-relay": "^0.
|
|
50
|
+
"@metalabel/dfos-protocol": "^0.33.0",
|
|
51
|
+
"@metalabel/dfos-web-relay": "^0.33.0"
|
|
48
52
|
},
|
|
49
53
|
"devDependencies": {
|
|
50
54
|
"@types/node": "^24.10.4",
|
|
51
55
|
"tsup": "^8.5.1",
|
|
52
56
|
"vitest": "^4.1.8",
|
|
53
|
-
"@metalabel/dfos-protocol": "0.
|
|
54
|
-
"@metalabel/dfos-web-relay": "0.
|
|
57
|
+
"@metalabel/dfos-protocol": "0.33.0",
|
|
58
|
+
"@metalabel/dfos-web-relay": "0.33.0"
|
|
55
59
|
},
|
|
56
60
|
"scripts": {
|
|
57
61
|
"build": "tsup",
|