@metalabel/dfos-client 0.37.0 → 0.38.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/dist/api-auth.d.ts +88 -142
- package/dist/api-auth.js +58 -235
- package/package.json +5 -5
package/dist/api-auth.d.ts
CHANGED
|
@@ -1,124 +1,10 @@
|
|
|
1
|
+
export { ApiRequestVerifyError, DEFAULT_PROOF_SKEW_SECONDS, DEFAULT_PROOF_WINDOW_SECONDS, DFOS_AUTH_SCHEME, EMPTY_BODY_SHA256, IDENTITY_PROOF_JWS_TYP, IdentityProofPayload, MAX_BODY_BYTES, MAX_PROOF_FRESHNESS_SPAN_SECONDS, MAX_REQUEST_PROOF_SIZE, ProofExtraMembers, REQUEST_PROOF_JWS_TYP, RequestProofFailurePhase, RequestProofFailureReason, RequestProofPayload, SignApiIdentityRequestInput, SignApiRequestInput, apiIdentitySigningInput, apiRequestSigningInput, assertProofVerifierConfig, buildApiAuthHeaders, buildApiIdentityHeaders, parseDfosAuthorization, sha256BodyHash, signApiIdentityRequest, signApiRequest } from '@metalabel/dfos-protocol/credentials';
|
|
1
2
|
import { a as Client } from './types-ByxTj1u-.js';
|
|
2
3
|
import '@metalabel/dfos-protocol/chain';
|
|
3
|
-
import '@metalabel/dfos-protocol/credentials';
|
|
4
4
|
import '@metalabel/dfos-web-relay/peer-client';
|
|
5
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
6
|
/** The v0 action registry's only token. */
|
|
32
7
|
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
8
|
interface CreateApiAuthFetchOptions {
|
|
123
9
|
/**
|
|
124
10
|
* The leaf credential JWS to present — the `X-Credential` value, and the
|
|
@@ -172,32 +58,6 @@ interface CreateApiAuthFetchOptions {
|
|
|
172
58
|
* proof-signed at all, in any implementation.
|
|
173
59
|
*/
|
|
174
60
|
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
61
|
interface VerifyApiRequestInput {
|
|
202
62
|
/** The request-proof JWS — the `Authorization: DFOS <token>` token, scheme stripped. */
|
|
203
63
|
proof: string;
|
|
@@ -292,5 +152,91 @@ interface VerifiedRequestProof {
|
|
|
292
152
|
* verification too), tracked outside this kit.
|
|
293
153
|
*/
|
|
294
154
|
declare const verifyApiRequest: (client: Client, input: VerifyApiRequestInput) => Promise<VerifiedRequestProof>;
|
|
155
|
+
interface VerifyApiIdentityRequestInput {
|
|
156
|
+
/** The identity-proof JWS — the `Authorization: DFOS <token>` token, scheme stripped. */
|
|
157
|
+
proof: string;
|
|
158
|
+
/**
|
|
159
|
+
* THE VERIFIER'S OWN CONFIGURED AUTHORITY for the route being served — a value
|
|
160
|
+
* the deployment holds, NEVER one read from the request. `Host`,
|
|
161
|
+
* `X-Forwarded-Host`, and the request URL's authority are all attacker-supplied:
|
|
162
|
+
* a verifier that compared the proof's `host` against a request header would
|
|
163
|
+
* have no host binding at all. Include the port when it is not 443.
|
|
164
|
+
*/
|
|
165
|
+
host: string;
|
|
166
|
+
/** The received request's method. */
|
|
167
|
+
method: string;
|
|
168
|
+
/** The received origin-form request target — path plus query string, byte for byte. */
|
|
169
|
+
path: string;
|
|
170
|
+
/** The received application body octets, post-content-decoding. Omitted = no body. */
|
|
171
|
+
body?: Uint8Array;
|
|
172
|
+
/**
|
|
173
|
+
* Cap on the decoded body this verifier will hash, in bytes. Default
|
|
174
|
+
* `MAX_BODY_BYTES`. A body over the cap is refused BEFORE the SHA-256 (a
|
|
175
|
+
* proof-layer `413`). As in `verifyApiRequest`, aborting DECODE at the cap
|
|
176
|
+
* remains a middleware obligation upstream — a decompression bomb inflates
|
|
177
|
+
* before this helper sees a buffered `Uint8Array`.
|
|
178
|
+
*/
|
|
179
|
+
maxBodyBytes?: number;
|
|
180
|
+
/** Acceptance window `W`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
|
|
181
|
+
windowSeconds?: number;
|
|
182
|
+
/** Clock-skew allowance `S`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
|
|
183
|
+
skewSeconds?: number;
|
|
184
|
+
/**
|
|
185
|
+
* Accept a presenter resolution whose tip could not be verified (cache-only or
|
|
186
|
+
* empty-delta-against-cache). Default FALSE: key resolution is CURRENT-STATE,
|
|
187
|
+
* and a rotated-out key must not keep minting proofs against a stale cache.
|
|
188
|
+
*/
|
|
189
|
+
allowStale?: boolean;
|
|
190
|
+
/** Clock injection (unix ms). Default `Date.now()`. */
|
|
191
|
+
now?: () => number;
|
|
192
|
+
}
|
|
193
|
+
interface VerifiedIdentityProof {
|
|
194
|
+
/**
|
|
195
|
+
* THE PRINCIPAL — the `kid`'s DID, which is who the request is from. What that
|
|
196
|
+
* DID may do is the resource's local policy (quotas, reputation, self-access
|
|
197
|
+
* rules, admission tiers); this kit deliberately says nothing about it.
|
|
198
|
+
* Authentication travels on the wire; authorization stays home.
|
|
199
|
+
*/
|
|
200
|
+
presenterDID: string;
|
|
201
|
+
/** The full `kid` DID URL that signed, key fragment included. */
|
|
202
|
+
kid: string;
|
|
203
|
+
/** The authority the binding names — the verifier's own configured value. */
|
|
204
|
+
host: string;
|
|
205
|
+
/** The proof's issued-at, unix seconds. */
|
|
206
|
+
iat: number;
|
|
207
|
+
/**
|
|
208
|
+
* The DECODED payload, unknown members included — where a caller reads an
|
|
209
|
+
* ADDITIVE member (`jti`) the envelope verifier ignored. The signature already
|
|
210
|
+
* covers it; the canonical member set stays closed.
|
|
211
|
+
*/
|
|
212
|
+
rawPayload: Record<string, unknown>;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Verify an identity-proven request — API-AUTH.md's PROOF PHASE (steps 1–7) with
|
|
216
|
+
* the identity `typ`, and nothing more. Steps 8–11 do not exist for this
|
|
217
|
+
* artifact: there is no credential to walk, so there is no chain, no revocation
|
|
218
|
+
* lookup, and no attenuation coverage.
|
|
219
|
+
*
|
|
220
|
+
* The verdicts are therefore two, not three: `invalid` → 401, `unverifiable` →
|
|
221
|
+
* 503 (plus `config` → 500 for a deployment whose `W + S` is out of bounds).
|
|
222
|
+
* NOTHING credential-shaped can fail, so there is no 403 tier.
|
|
223
|
+
*
|
|
224
|
+
* A REQUEST PROOF PRESENTED HERE IS REJECTED at the header gate, and an identity
|
|
225
|
+
* proof presented to `verifyApiRequest` is rejected at the same gate. The typ
|
|
226
|
+
* scoping is what keeps a grant-bearing claim from ever being spent as a bare
|
|
227
|
+
* one, or the reverse.
|
|
228
|
+
*
|
|
229
|
+
* The header-layer rule this helper cannot see: on an `api:<host>` surface a
|
|
230
|
+
* request carrying `X-Credential` alongside an identity proof is MALFORMED (401)
|
|
231
|
+
* — the headers assert two different claims at once and a verifier MUST NOT pick
|
|
232
|
+
* one. That refusal belongs to the middleware, which is the only layer holding
|
|
233
|
+
* the header bag. (A relay content-plane read is a different surface: there the
|
|
234
|
+
* identity proof is the AuthN half and a DFOS credential presentation is the
|
|
235
|
+
* separate authorization artifact.)
|
|
236
|
+
*
|
|
237
|
+
* Throws `ApiRequestVerifyError`; branch on `reason`/`phase`/`status`, never on
|
|
238
|
+
* message text. `phase` is always `'proof'` or `'config'` here.
|
|
239
|
+
*/
|
|
240
|
+
declare const verifyApiIdentityRequest: (client: Client, input: VerifyApiIdentityRequestInput) => Promise<VerifiedIdentityProof>;
|
|
295
241
|
|
|
296
|
-
export {
|
|
242
|
+
export { type CreateApiAuthFetchOptions, DEFAULT_API_ACTION, type VerifiedIdentityProof, type VerifiedRequestProof, type VerifyApiIdentityRequestInput, type VerifyApiRequestInput, createApiAuthFetch, verifyApiIdentityRequest, verifyApiRequest };
|
package/dist/api-auth.js
CHANGED
|
@@ -1,139 +1,36 @@
|
|
|
1
1
|
// src/api-auth.ts
|
|
2
|
-
import { decodeMultikey } from "@metalabel/dfos-protocol/chain";
|
|
3
2
|
import {
|
|
3
|
+
apiIdentitySigningInput,
|
|
4
|
+
apiRequestSigningInput,
|
|
5
|
+
ApiRequestVerifyError,
|
|
6
|
+
assertProofVerifierConfig,
|
|
7
|
+
buildApiAuthHeaders,
|
|
8
|
+
buildApiIdentityHeaders,
|
|
4
9
|
CredentialVerificationError,
|
|
5
10
|
decodeDFOSCredentialUnsafe,
|
|
11
|
+
DEFAULT_PROOF_SKEW_SECONDS,
|
|
12
|
+
DEFAULT_PROOF_WINDOW_SECONDS,
|
|
13
|
+
DFOS_AUTH_SCHEME,
|
|
14
|
+
EMPTY_BODY_SHA256,
|
|
15
|
+
IDENTITY_PROOF_JWS_TYP,
|
|
6
16
|
matchesResource,
|
|
17
|
+
MAX_BODY_BYTES,
|
|
7
18
|
MAX_CREDENTIAL_SIZE,
|
|
19
|
+
MAX_PROOF_FRESHNESS_SPAN_SECONDS,
|
|
20
|
+
MAX_REQUEST_PROOF_SIZE,
|
|
21
|
+
parseDfosAuthorization,
|
|
22
|
+
REQUEST_PROOF_JWS_TYP,
|
|
23
|
+
sha256BodyHash,
|
|
24
|
+
signApiIdentityRequest,
|
|
25
|
+
signApiRequest,
|
|
8
26
|
verifyDelegationChain,
|
|
9
|
-
verifyDFOSCredential
|
|
27
|
+
verifyDFOSCredential,
|
|
28
|
+
verifyIdentityProofEnvelope,
|
|
29
|
+
verifyRequestProofEnvelope
|
|
10
30
|
} 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;
|
|
31
|
+
import { decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
|
|
26
32
|
var DEFAULT_API_ACTION = "read:profile";
|
|
27
|
-
var MAX_BODY_BYTES = 1048576;
|
|
28
33
|
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
34
|
var credentialCIDFromHeader = (credential) => {
|
|
138
35
|
const decoded = decodeJwsUnsafe(credential);
|
|
139
36
|
if (!decoded) throw new Error("invalid credential: failed to decode the credential JWS");
|
|
@@ -182,24 +79,25 @@ var createApiAuthFetch = (options) => {
|
|
|
182
79
|
return send(new Request(request, { headers, redirect: "manual" }));
|
|
183
80
|
};
|
|
184
81
|
};
|
|
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
82
|
var invalidProof = (message) => new ApiRequestVerifyError("invalid", "proof", 401, message);
|
|
199
83
|
var invalidCredential = (message) => new ApiRequestVerifyError("invalid", "credential", 403, message);
|
|
200
84
|
var unverifiableProof = (message) => new ApiRequestVerifyError("unverifiable", "proof", 503, message);
|
|
201
85
|
var unverifiableCredential = (message) => new ApiRequestVerifyError("unverifiable", "credential", 503, message);
|
|
202
86
|
var misconfigured = (message) => new ApiRequestVerifyError("config", "config", 500, message);
|
|
87
|
+
var clientPresenterResolver = (client, allowStale) => async (did) => {
|
|
88
|
+
const resolved = await client.identity(did);
|
|
89
|
+
const axes = resolved.trust.unverifiable ?? [];
|
|
90
|
+
if (!allowStale && (axes.includes("tip") || resolved.provenance.fromCache)) {
|
|
91
|
+
throw unverifiableProof(
|
|
92
|
+
"presenter identity resolution is stale (tip unverified) \u2014 refusing to authenticate against a cached identity state; pass allowStale: true to accept the risk"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const state = resolved.value;
|
|
96
|
+
return {
|
|
97
|
+
isDeleted: state.isDeleted,
|
|
98
|
+
keys: [...state.authKeys, ...state.assertKeys, ...state.controllerKeys]
|
|
99
|
+
};
|
|
100
|
+
};
|
|
203
101
|
var discoverChainRoot = (leafToken) => {
|
|
204
102
|
let token = leafToken;
|
|
205
103
|
for (let depth = 0; depth < MAX_DELEGATION_DEPTH; depth++) {
|
|
@@ -214,111 +112,21 @@ var discoverChainRoot = (leafToken) => {
|
|
|
214
112
|
throw invalidCredential("delegation chain too deep (max 16 credentials)");
|
|
215
113
|
};
|
|
216
114
|
var verifyApiRequest = async (client, input) => {
|
|
217
|
-
|
|
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
|
-
}
|
|
115
|
+
assertProofVerifierConfig(input);
|
|
232
116
|
const action = input.action ?? DEFAULT_API_ACTION;
|
|
233
117
|
if (action.split(",").every((token) => token.trim() === "")) {
|
|
234
118
|
throw misconfigured("required action must name a non-empty token");
|
|
235
119
|
}
|
|
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
120
|
if (input.credential.length > MAX_CREDENTIAL_SIZE) {
|
|
246
121
|
throw invalidProof(
|
|
247
122
|
`credential exceeds max size: ${input.credential.length} > ${MAX_CREDENTIAL_SIZE}`
|
|
248
123
|
);
|
|
249
124
|
}
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
125
|
+
const { payload, presenterDID, now } = await verifyRequestProofEnvelope(
|
|
126
|
+
input,
|
|
127
|
+
clientPresenterResolver(client, input.allowStale === true)
|
|
315
128
|
);
|
|
316
|
-
|
|
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
|
-
}
|
|
129
|
+
const proofCredentialCID = payload.credentialCID;
|
|
322
130
|
const { isRevoked, resolveIdentity } = client.callbacks();
|
|
323
131
|
const rootDID = discoverChainRoot(input.credential);
|
|
324
132
|
let leaf;
|
|
@@ -342,7 +150,7 @@ var verifyApiRequest = async (client, input) => {
|
|
|
342
150
|
`credential verification could not complete: ${err instanceof Error ? err.message : String(err)}`
|
|
343
151
|
);
|
|
344
152
|
}
|
|
345
|
-
if (leaf.credentialCID !==
|
|
153
|
+
if (leaf.credentialCID !== proofCredentialCID) {
|
|
346
154
|
throw invalidCredential("request proof credentialCID does not match the presented credential");
|
|
347
155
|
}
|
|
348
156
|
for (const hop of chain) {
|
|
@@ -366,20 +174,35 @@ var verifyApiRequest = async (client, input) => {
|
|
|
366
174
|
credentialCID: leaf.credentialCID
|
|
367
175
|
};
|
|
368
176
|
};
|
|
177
|
+
var verifyApiIdentityRequest = async (client, input) => {
|
|
178
|
+
const { payload, rawPayload, presenterDID, kid } = await verifyIdentityProofEnvelope(
|
|
179
|
+
input,
|
|
180
|
+
clientPresenterResolver(client, input.allowStale === true)
|
|
181
|
+
);
|
|
182
|
+
return { presenterDID, kid, host: input.host, iat: payload.iat, rawPayload };
|
|
183
|
+
};
|
|
369
184
|
export {
|
|
370
185
|
ApiRequestVerifyError,
|
|
371
186
|
DEFAULT_API_ACTION,
|
|
372
187
|
DEFAULT_PROOF_SKEW_SECONDS,
|
|
373
188
|
DEFAULT_PROOF_WINDOW_SECONDS,
|
|
189
|
+
DFOS_AUTH_SCHEME,
|
|
374
190
|
EMPTY_BODY_SHA256,
|
|
191
|
+
IDENTITY_PROOF_JWS_TYP,
|
|
375
192
|
MAX_BODY_BYTES,
|
|
376
193
|
MAX_PROOF_FRESHNESS_SPAN_SECONDS,
|
|
377
194
|
MAX_REQUEST_PROOF_SIZE,
|
|
378
195
|
REQUEST_PROOF_JWS_TYP,
|
|
196
|
+
apiIdentitySigningInput,
|
|
379
197
|
apiRequestSigningInput,
|
|
198
|
+
assertProofVerifierConfig,
|
|
380
199
|
buildApiAuthHeaders,
|
|
200
|
+
buildApiIdentityHeaders,
|
|
381
201
|
createApiAuthFetch,
|
|
202
|
+
parseDfosAuthorization,
|
|
382
203
|
sha256BodyHash,
|
|
204
|
+
signApiIdentityRequest,
|
|
383
205
|
signApiRequest,
|
|
206
|
+
verifyApiIdentityRequest,
|
|
384
207
|
verifyApiRequest
|
|
385
208
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalabel/dfos-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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.
|
|
51
|
-
"@metalabel/dfos-web-relay": "^0.
|
|
50
|
+
"@metalabel/dfos-protocol": "^0.38.0",
|
|
51
|
+
"@metalabel/dfos-web-relay": "^0.38.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.38.0",
|
|
58
|
+
"@metalabel/dfos-web-relay": "0.38.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsup",
|