@metalabel/dfos-client 0.32.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 +62 -1
- package/dist/api-auth.d.ts +54 -1
- package/dist/api-auth.js +49 -0
- package/package.json +6 -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
|
package/dist/api-auth.d.ts
CHANGED
|
@@ -119,6 +119,59 @@ declare const buildApiAuthHeaders: (input: {
|
|
|
119
119
|
Authorization: string;
|
|
120
120
|
"X-Credential": string;
|
|
121
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;
|
|
122
175
|
/**
|
|
123
176
|
* The verdict class. Branch on `reason`, never on message text.
|
|
124
177
|
*
|
|
@@ -240,4 +293,4 @@ interface VerifiedRequestProof {
|
|
|
240
293
|
*/
|
|
241
294
|
declare const verifyApiRequest: (client: Client, input: VerifyApiRequestInput) => Promise<VerifiedRequestProof>;
|
|
242
295
|
|
|
243
|
-
export { ApiRequestVerifyError, 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, sha256BodyHash, signApiRequest, verifyApiRequest };
|
|
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
CHANGED
|
@@ -134,6 +134,54 @@ var buildApiAuthHeaders = (input) => ({
|
|
|
134
134
|
Authorization: `DFOS ${input.proof}`,
|
|
135
135
|
"X-Credential": input.credential
|
|
136
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
|
+
};
|
|
137
185
|
var ApiRequestVerifyError = class extends Error {
|
|
138
186
|
reason;
|
|
139
187
|
phase;
|
|
@@ -330,6 +378,7 @@ export {
|
|
|
330
378
|
REQUEST_PROOF_JWS_TYP,
|
|
331
379
|
apiRequestSigningInput,
|
|
332
380
|
buildApiAuthHeaders,
|
|
381
|
+
createApiAuthFetch,
|
|
333
382
|
sha256BodyHash,
|
|
334
383
|
signApiRequest,
|
|
335
384
|
verifyApiRequest
|
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": {
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
"README.md"
|
|
48
48
|
],
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@metalabel/dfos-protocol": "^0.
|
|
51
|
-
"@metalabel/dfos-web-relay": "^0.
|
|
50
|
+
"@metalabel/dfos-protocol": "^0.33.0",
|
|
51
|
+
"@metalabel/dfos-web-relay": "^0.33.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.
|
|
58
|
-
"@metalabel/dfos-web-relay": "0.
|
|
57
|
+
"@metalabel/dfos-protocol": "0.33.0",
|
|
58
|
+
"@metalabel/dfos-web-relay": "0.33.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsup",
|