@fora-protocol/sdk 1.0.3
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/LICENSE +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- package/sdk/ts/src/wire.js +114 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { type ChainLink } from "./sign-request.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The classified reject reason (connectserver classify.go RejectReason.String()).
|
|
4
|
+
* SINGLE-SIG surface: "signature" (the default — authenticity/freshness/key/
|
|
5
|
+
* covered-set failures) and "replay". The multisig tokens broken_chain /
|
|
6
|
+
* hop_budget are out of scope here.
|
|
7
|
+
*/
|
|
8
|
+
export type RejectReason = "signature" | "replay";
|
|
9
|
+
/**
|
|
10
|
+
* The injected keyid-keyed verifying-key resolver (ADR-020 §4). Distinct from
|
|
11
|
+
* core/verifier.ts::OfferKeyResolver which is EXCHANGE-keyed for offer verify: a
|
|
12
|
+
* request-verify resolver is keyed by the Signature-Input keyid. Returns the raw
|
|
13
|
+
* 32-byte Ed25519 public key, or undefined when the key is unknown.
|
|
14
|
+
*/
|
|
15
|
+
export interface RequestKeyResolver {
|
|
16
|
+
resolve(keyid: string | null): Uint8Array<ArrayBuffer> | undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The injected replay-nonce store (mirrors Go core.ReplayStore). The SDK ships NO
|
|
20
|
+
* default — replay state lives entirely in the injected implementation.
|
|
21
|
+
* `seenNonce` is the read-only first phase; `seenOrAdd` is the commit phase
|
|
22
|
+
* (resolves true if the nonce was already present).
|
|
23
|
+
*/
|
|
24
|
+
export interface ReplayStore {
|
|
25
|
+
seenNonce(nonce: string): Promise<boolean>;
|
|
26
|
+
seenOrAdd(nonce: string): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The request headers a server-verify reads.
|
|
30
|
+
*
|
|
31
|
+
* Lowercased keys are the convention and the members below spell them that way, but
|
|
32
|
+
* every name is matched case-insensitively and repeated spellings of one name are
|
|
33
|
+
* JOINED before the base is rebuilt — the wire has one field per name however an
|
|
34
|
+
* object spells it. Declared as a type alias rather than an interface because only a
|
|
35
|
+
* type alias gets the implicit index signature that lets a value of this type reach
|
|
36
|
+
* readHeader's `Record<string, string | undefined>` parameter; an interface is open to
|
|
37
|
+
* declaration merging and TypeScript withholds it. Extra properties were already
|
|
38
|
+
* assignable either way — that is not what the alias buys.
|
|
39
|
+
*/
|
|
40
|
+
export type VerifyRequestHeaders = {
|
|
41
|
+
"content-digest": string;
|
|
42
|
+
"signature-input": string;
|
|
43
|
+
signature: string;
|
|
44
|
+
/**
|
|
45
|
+
* The two covered headers whose value may legitimately be EMPTY. Optional so an
|
|
46
|
+
* ABSENT one is distinguishable from an empty one, which is the whole reason an
|
|
47
|
+
* empty one is put on the wire: the base is rebuilt from the request that ARRIVED,
|
|
48
|
+
* so a name the signature covers and the request does not carry cannot be
|
|
49
|
+
* reconstructed, and defaulting it to "" would invent a value the signer may never
|
|
50
|
+
* have bound. The oracle draws the same line by reading these with Values rather
|
|
51
|
+
* than Get. See docs/design-history.md, "A covered header the peer never receives
|
|
52
|
+
* is not bound".
|
|
53
|
+
*/
|
|
54
|
+
authorization?: string;
|
|
55
|
+
"signature-agent"?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The entitlement-token header (mirrors Go entitlementHeaderLower). When
|
|
58
|
+
* present, the covered set MUST commit to it (enforceEntitlementCoverage);
|
|
59
|
+
* omit/empty when the request carries no entitlement. Format-neutral —
|
|
60
|
+
* JWT/opaque token; coverage is enforced, contents are not.
|
|
61
|
+
*/
|
|
62
|
+
"x-entitlement-token"?: string;
|
|
63
|
+
};
|
|
64
|
+
/** Inputs for verifyRequestServer — the request material plus the injected boundary. */
|
|
65
|
+
export interface VerifyRequestServerInput {
|
|
66
|
+
method: string;
|
|
67
|
+
url: string;
|
|
68
|
+
body: Uint8Array<ArrayBuffer>;
|
|
69
|
+
headers: VerifyRequestHeaders;
|
|
70
|
+
resolve: RequestKeyResolver;
|
|
71
|
+
/** Omit to disable replay detection (verify-only), matching Go WithReplayStore-absent. */
|
|
72
|
+
replayStore?: ReplayStore;
|
|
73
|
+
/** Injected clock returning unix seconds — verify reads time ONLY through this. */
|
|
74
|
+
now: () => number;
|
|
75
|
+
/**
|
|
76
|
+
* The per-hop signature-lifetime clamp in SECONDS (mirrors Go
|
|
77
|
+
* VerifyOptions.MaxSignatureAge). When > 0, a signature whose declared window
|
|
78
|
+
* (expires − created) EXCEEDS this is rejected as "signature"; the bound is
|
|
79
|
+
* inclusive (a window exactly equal to it is accepted). 0/undefined = unbounded.
|
|
80
|
+
*/
|
|
81
|
+
maxSignatureAge?: number;
|
|
82
|
+
}
|
|
83
|
+
/** The returned verdict: valid, or invalid with the classified reason. */
|
|
84
|
+
export interface VerifyVerdict {
|
|
85
|
+
valid: boolean;
|
|
86
|
+
reason?: RejectReason;
|
|
87
|
+
}
|
|
88
|
+
export declare const ENTITLEMENT_COVERED = "x-entitlement-token";
|
|
89
|
+
/**
|
|
90
|
+
* Read the request's value for `name`, or undefined when it carries no such field.
|
|
91
|
+
*
|
|
92
|
+
* The port of the oracle's covered-component read (`http.Header.Values` plus the
|
|
93
|
+
* RFC 9421 join), and it makes two distinctions a plain property lookup erases.
|
|
94
|
+
*
|
|
95
|
+
* ABSENT is not EMPTY. undefined means the request carried no field line under this
|
|
96
|
+
* name at all; "" means it carried one whose value is empty. The base is rebuilt
|
|
97
|
+
* from the request that ARRIVED, so a covered name with nothing under it cannot be
|
|
98
|
+
* reconstructed, while an empty one reconstructs to the empty value the signer
|
|
99
|
+
* bound — which is the whole reason an empty covered header is put on the wire.
|
|
100
|
+
*
|
|
101
|
+
* Repeated spellings JOIN, they do not shadow. Header names are case-insensitive on
|
|
102
|
+
* the wire and a JavaScript object's keys are not, so a caller's `Authorization` and
|
|
103
|
+
* a signer's `authorization` are two field lines under ONE covered name. The oracle
|
|
104
|
+
* joins them with ", " before rebuilding the base, so the covered value changes and
|
|
105
|
+
* the signature no longer reproduces. Returning the first match instead would hand
|
|
106
|
+
* back the signed value and accept whatever was slipped in beside it — the token
|
|
107
|
+
* injection the covered set exists to prevent.
|
|
108
|
+
*
|
|
109
|
+
* Exported for the multisig sibling, which reads the same request the same way.
|
|
110
|
+
* See docs/design-history.md, "A covered header the peer never receives is not bound".
|
|
111
|
+
*/
|
|
112
|
+
export declare function readHeader(headers: Record<string, string | undefined>, name: string): string | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* Assemble the request-level covered fields, or undefined when the request does not
|
|
115
|
+
* CARRY one of the headers its signature covers.
|
|
116
|
+
*
|
|
117
|
+
* The absent-header rule lives here rather than at each entry point, because it is a
|
|
118
|
+
* property of the covered SET and not of any one face: every name in
|
|
119
|
+
* COVERED_HEADER_NAMES must arrive, and an absent one cannot be defaulted to "" —
|
|
120
|
+
* that would invent a value the signer may never have bound. The oracle draws the
|
|
121
|
+
* same line one layer down, in the component resolver, for any covered name at all.
|
|
122
|
+
*
|
|
123
|
+
* Callers keep their own reject reason and their own place in the gate sequence: the
|
|
124
|
+
* single-sig and multisig faces refuse at different points on purpose, and the corpus
|
|
125
|
+
* pins that difference.
|
|
126
|
+
*
|
|
127
|
+
* Note the base builder still renders a fixed component list, so a name added to the
|
|
128
|
+
* covered set is guarded here but does not yet reach the signature base.
|
|
129
|
+
*/
|
|
130
|
+
export declare function requestVerifyFields(method: string, url: string, body: Uint8Array<ArrayBuffer>, headers: Record<string, string | undefined>): RequestVerifyFields | undefined;
|
|
131
|
+
export declare function ed25519Verify(pub: Uint8Array<ArrayBuffer>, sig: Uint8Array<ArrayBuffer>, message: Uint8Array<ArrayBuffer>): Promise<boolean>;
|
|
132
|
+
/** The request-level covered fields shared across every hop of a request. */
|
|
133
|
+
export interface RequestVerifyFields {
|
|
134
|
+
method: string;
|
|
135
|
+
url: string;
|
|
136
|
+
digestHeader: string;
|
|
137
|
+
authorization: string;
|
|
138
|
+
signatureAgent: string;
|
|
139
|
+
body: Uint8Array<ArrayBuffer>;
|
|
140
|
+
/**
|
|
141
|
+
* The entitlement-token header value (empty/undefined when absent). When
|
|
142
|
+
* non-empty, enforceEntitlementCoverage requires the covered set to commit to
|
|
143
|
+
* "x-entitlement-token" — an entitlement token cannot be slipped under an
|
|
144
|
+
* otherwise-valid signature.
|
|
145
|
+
*/
|
|
146
|
+
entitlementHeader?: string;
|
|
147
|
+
}
|
|
148
|
+
/** The parsed per-signature params the verify core judges (one hop). */
|
|
149
|
+
export interface ParsedSignatureParams {
|
|
150
|
+
/** Verbatim inner-list + params tail — the base @signature-params value. */
|
|
151
|
+
rawParams: string;
|
|
152
|
+
covered: ReadonlySet<string>;
|
|
153
|
+
keyid: string | null;
|
|
154
|
+
alg: string | null;
|
|
155
|
+
created?: number;
|
|
156
|
+
expires?: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The per-signature verify core shared by the single-sig and multisig paths
|
|
160
|
+
* (mirrors Go verifySingleSignature, MINUS replay): alg + required
|
|
161
|
+
* covered set + created/expires window + content-digest + key resolution +
|
|
162
|
+
* Ed25519 over the reconstructed base. Returns true iff the signature is authentic
|
|
163
|
+
* and policy-valid. `chainLink`, when present, inserts the forwarding-chain base
|
|
164
|
+
* line for a chained hop (sigN, N>1). NO replay — the caller owns that so the
|
|
165
|
+
* multisig loop never touches a ReplayStore. `maxSignatureAge` (seconds, mirrors
|
|
166
|
+
* Go VerifyOptions.MaxSignatureAge) clamps the declared lifetime per hop: when
|
|
167
|
+
* > 0 a window (expires − created) EXCEEDING it is rejected; 0/undefined =
|
|
168
|
+
* unbounded, and the bound is inclusive.
|
|
169
|
+
*/
|
|
170
|
+
export declare function verifyParsedSignature(fields: RequestVerifyFields, parsed: ParsedSignatureParams, sigBytes: Uint8Array<ArrayBuffer> | undefined, resolve: RequestKeyResolver, nowSec: number, chainLink?: ChainLink, maxSignatureAge?: number): Promise<boolean>;
|
|
171
|
+
/**
|
|
172
|
+
* Verify an inbound single-signature FORA request; return a reason-tagged verdict.
|
|
173
|
+
*
|
|
174
|
+
* Mirrors the Go connectserver single-sig verify order: required covered-set →
|
|
175
|
+
* created/expires window → content-digest → key resolution → Ed25519 check over
|
|
176
|
+
* the reconstructed base → two-phase replay. Every authenticity/freshness/key/
|
|
177
|
+
* covered-set failure collapses to "signature" (the Go default branch); a replayed
|
|
178
|
+
* nonce is "replay". Keys resolve ONLY through `resolve`; replay state lives ONLY
|
|
179
|
+
* in `replayStore` when supplied; time is read ONLY through `now`.
|
|
180
|
+
*/
|
|
181
|
+
export declare function verifyRequestServer(input: VerifyRequestServerInput): Promise<VerifyVerdict>;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// sdk/ts framework-agnostic RFC 9421 single-signature SERVER-verify face — the
|
|
2
|
+
// verify sibling of core/sign-request.ts and the TS port of sdk/go/connectserver's
|
|
3
|
+
// single-sig verify path (verify.go / classify.go over helpers/verify.go +
|
|
4
|
+
// sigbase.go). Where hono/middleware.ts::foraVerify is the edge-GET-PoP path
|
|
5
|
+
// (2-component, self-verifying, no resolver) and core/verifier.ts is OFFER verify
|
|
6
|
+
// (JCS), this is the request-verify a Broker/Exchange built in TS wires behind its
|
|
7
|
+
// framework: it parses the inbound Signature-Input/Signature, enforces the FORA
|
|
8
|
+
// required-5 covered set + content-digest + created/expires window, resolves the
|
|
9
|
+
// keyid through an INJECTED KeyResolver (the SDK owns no keys), runs the two-phase
|
|
10
|
+
// replay check over an INJECTED store (the SDK owns no replay state), reads time
|
|
11
|
+
// through an INJECTED clock (the SDK owns no wall clock), and returns a VERDICT
|
|
12
|
+
// carrying the reject reason mirroring the Go taxonomy — never throws.
|
|
13
|
+
//
|
|
14
|
+
// SINGLE-SIG scope only:
|
|
15
|
+
// multisig forwarding-chain verify (hop budget, broken_chain) is owned by the
|
|
16
|
+
// core/verify-multisig-request.ts sibling.
|
|
17
|
+
// The reject reasons this face emits are exactly the two the single-sig surface
|
|
18
|
+
// produces (connectserver classify.go RejectReason.String()): "signature" (bad
|
|
19
|
+
// sig / expiry / future-created / wrong-or-unresolvable key / tampered covered
|
|
20
|
+
// field / missing component — the default) and "replay".
|
|
21
|
+
import { decodeBase64Url, utf8Bytes } from "../src/base64url.js";
|
|
22
|
+
import { stdBase64 } from "./sign.js";
|
|
23
|
+
import { buildRequestSignatureBase, COVERED_COMPONENTS, contentDigest, } from "./sign-request.js";
|
|
24
|
+
// A created timestamp may not lead the verifier clock by more than this
|
|
25
|
+
// (mirrors Go helpers.defaultMaxFutureSkew = 300s).
|
|
26
|
+
const MAX_FUTURE_SKEW_SEC = 300;
|
|
27
|
+
// The FORA required covered set, lowercased (mirrors Go requiredCoveredComponents).
|
|
28
|
+
const REQUIRED_COVERED = new Set(COVERED_COMPONENTS);
|
|
29
|
+
// The entitlement-token header in covered-component (lowercased) form
|
|
30
|
+
// (mirrors Go entitlementHeaderLower). When the request carries this header the
|
|
31
|
+
// signature's covered set MUST commit to it; absent → no constraint.
|
|
32
|
+
export const ENTITLEMENT_COVERED = "x-entitlement-token";
|
|
33
|
+
const REASON_SIGNATURE = "signature";
|
|
34
|
+
const REASON_REPLAY = "replay";
|
|
35
|
+
/**
|
|
36
|
+
* Read the request's value for `name`, or undefined when it carries no such field.
|
|
37
|
+
*
|
|
38
|
+
* The port of the oracle's covered-component read (`http.Header.Values` plus the
|
|
39
|
+
* RFC 9421 join), and it makes two distinctions a plain property lookup erases.
|
|
40
|
+
*
|
|
41
|
+
* ABSENT is not EMPTY. undefined means the request carried no field line under this
|
|
42
|
+
* name at all; "" means it carried one whose value is empty. The base is rebuilt
|
|
43
|
+
* from the request that ARRIVED, so a covered name with nothing under it cannot be
|
|
44
|
+
* reconstructed, while an empty one reconstructs to the empty value the signer
|
|
45
|
+
* bound — which is the whole reason an empty covered header is put on the wire.
|
|
46
|
+
*
|
|
47
|
+
* Repeated spellings JOIN, they do not shadow. Header names are case-insensitive on
|
|
48
|
+
* the wire and a JavaScript object's keys are not, so a caller's `Authorization` and
|
|
49
|
+
* a signer's `authorization` are two field lines under ONE covered name. The oracle
|
|
50
|
+
* joins them with ", " before rebuilding the base, so the covered value changes and
|
|
51
|
+
* the signature no longer reproduces. Returning the first match instead would hand
|
|
52
|
+
* back the signed value and accept whatever was slipped in beside it — the token
|
|
53
|
+
* injection the covered set exists to prevent.
|
|
54
|
+
*
|
|
55
|
+
* Exported for the multisig sibling, which reads the same request the same way.
|
|
56
|
+
* See docs/design-history.md, "A covered header the peer never receives is not bound".
|
|
57
|
+
*/
|
|
58
|
+
export function readHeader(headers, name) {
|
|
59
|
+
const lower = name.toLowerCase();
|
|
60
|
+
const values = [];
|
|
61
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
62
|
+
if (key.toLowerCase() === lower && value !== undefined)
|
|
63
|
+
values.push(value);
|
|
64
|
+
}
|
|
65
|
+
if (values.length === 0)
|
|
66
|
+
return undefined;
|
|
67
|
+
return values.join(", ").trim();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The covered names that are HEADER fields. Derived from COVERED_COMPONENTS rather
|
|
71
|
+
* than restated, so a name added to the covered set is absent-checked the day it is
|
|
72
|
+
* added: the two "@" components are reconstructed from the request line, every other
|
|
73
|
+
* covered name has to arrive as a field.
|
|
74
|
+
*/
|
|
75
|
+
const COVERED_HEADER_NAMES = COVERED_COMPONENTS.filter((c) => !c.startsWith("@"));
|
|
76
|
+
/**
|
|
77
|
+
* Assemble the request-level covered fields, or undefined when the request does not
|
|
78
|
+
* CARRY one of the headers its signature covers.
|
|
79
|
+
*
|
|
80
|
+
* The absent-header rule lives here rather than at each entry point, because it is a
|
|
81
|
+
* property of the covered SET and not of any one face: every name in
|
|
82
|
+
* COVERED_HEADER_NAMES must arrive, and an absent one cannot be defaulted to "" —
|
|
83
|
+
* that would invent a value the signer may never have bound. The oracle draws the
|
|
84
|
+
* same line one layer down, in the component resolver, for any covered name at all.
|
|
85
|
+
*
|
|
86
|
+
* Callers keep their own reject reason and their own place in the gate sequence: the
|
|
87
|
+
* single-sig and multisig faces refuse at different points on purpose, and the corpus
|
|
88
|
+
* pins that difference.
|
|
89
|
+
*
|
|
90
|
+
* Note the base builder still renders a fixed component list, so a name added to the
|
|
91
|
+
* covered set is guarded here but does not yet reach the signature base.
|
|
92
|
+
*/
|
|
93
|
+
export function requestVerifyFields(method, url, body, headers) {
|
|
94
|
+
const covered = new Map();
|
|
95
|
+
for (const name of COVERED_HEADER_NAMES) {
|
|
96
|
+
const value = readHeader(headers, name);
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
covered.set(name, value);
|
|
100
|
+
}
|
|
101
|
+
// The loop above returned already if any of these were absent; the ?? "" is
|
|
102
|
+
// unreachable and exists only to satisfy Map's optional return type.
|
|
103
|
+
const entitlement = readHeader(headers, ENTITLEMENT_COVERED);
|
|
104
|
+
return {
|
|
105
|
+
method,
|
|
106
|
+
url,
|
|
107
|
+
digestHeader: covered.get("content-digest") ?? "",
|
|
108
|
+
authorization: covered.get("authorization") ?? "",
|
|
109
|
+
signatureAgent: covered.get("signature-agent") ?? "",
|
|
110
|
+
body,
|
|
111
|
+
...(entitlement ? { entitlementHeader: entitlement } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const reject = (reason) => ({
|
|
115
|
+
valid: false,
|
|
116
|
+
reason,
|
|
117
|
+
});
|
|
118
|
+
const accept = { valid: true };
|
|
119
|
+
/**
|
|
120
|
+
* Parse `label=("c1" "c2" ...);keyid="..";alg="..";created=..;expires=..` — a
|
|
121
|
+
* minimal RFC 8941-shaped parser for the SINGLE-SIG surface (one label, the FORA
|
|
122
|
+
* covered set, string-valued keyid/alg). It keeps the VERBATIM params tail so the
|
|
123
|
+
* verify base terminates with the signer's exact @signature-params bytes (Go
|
|
124
|
+
* RawInner), never a re-rendering.
|
|
125
|
+
*/
|
|
126
|
+
function parseSignatureInput(raw) {
|
|
127
|
+
const eq = raw.indexOf("=");
|
|
128
|
+
if (eq < 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
const rawParams = raw.slice(eq + 1).trim();
|
|
131
|
+
const open = rawParams.indexOf("(");
|
|
132
|
+
const close = rawParams.indexOf(")");
|
|
133
|
+
if (open !== 0 || close < 0)
|
|
134
|
+
return undefined;
|
|
135
|
+
const inner = rawParams.slice(open + 1, close);
|
|
136
|
+
const covered = new Set([...inner.matchAll(/"([^"]*)"/g)].map((m) => (m[1] ?? "").toLowerCase()));
|
|
137
|
+
const tail = rawParams.slice(close + 1);
|
|
138
|
+
const keyid = matchQuoted(tail, /;keyid="([^"]*)"/);
|
|
139
|
+
const alg = matchQuoted(tail, /;alg="([^"]*)"/);
|
|
140
|
+
const created = matchInt(tail, /;created=(\d+)/);
|
|
141
|
+
const expires = matchInt(tail, /;expires=(\d+)/);
|
|
142
|
+
return {
|
|
143
|
+
rawParams,
|
|
144
|
+
covered,
|
|
145
|
+
keyid,
|
|
146
|
+
alg,
|
|
147
|
+
...(created !== undefined ? { created } : {}),
|
|
148
|
+
...(expires !== undefined ? { expires } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function matchQuoted(s, re) {
|
|
152
|
+
const m = s.match(re);
|
|
153
|
+
return m ? (m[1] ?? "") : null;
|
|
154
|
+
}
|
|
155
|
+
function matchInt(s, re) {
|
|
156
|
+
const m = s.match(re);
|
|
157
|
+
return m ? Number(m[1]) : undefined;
|
|
158
|
+
}
|
|
159
|
+
/** Parse the RFC 9421 `Signature` header value `label=:<STANDARD-base64>:`. */
|
|
160
|
+
function parseSignatureBytes(raw) {
|
|
161
|
+
const first = raw.indexOf(":");
|
|
162
|
+
const last = raw.lastIndexOf(":");
|
|
163
|
+
if (first < 0 || last <= first)
|
|
164
|
+
return undefined;
|
|
165
|
+
return decodeBase64Url(raw.slice(first + 1, last));
|
|
166
|
+
}
|
|
167
|
+
// The replay nonce mirrors connectserver.replayNonce: keyid + NUL +
|
|
168
|
+
// STANDARD-base64(signature bytes), so neither part can forge the boundary and
|
|
169
|
+
// the value is independent of incidental wire whitespace.
|
|
170
|
+
function replayNonce(keyid, sigBytes) {
|
|
171
|
+
return `${keyid}\u0000${stdBase64(sigBytes)}`;
|
|
172
|
+
}
|
|
173
|
+
export async function ed25519Verify(pub, sig, message) {
|
|
174
|
+
try {
|
|
175
|
+
const key = await crypto.subtle.importKey("raw", pub, { name: "Ed25519" }, false, ["verify"]);
|
|
176
|
+
return await crypto.subtle.verify("Ed25519", key, sig, message);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The per-signature verify core shared by the single-sig and multisig paths
|
|
184
|
+
* (mirrors Go verifySingleSignature, MINUS replay): alg + required
|
|
185
|
+
* covered set + created/expires window + content-digest + key resolution +
|
|
186
|
+
* Ed25519 over the reconstructed base. Returns true iff the signature is authentic
|
|
187
|
+
* and policy-valid. `chainLink`, when present, inserts the forwarding-chain base
|
|
188
|
+
* line for a chained hop (sigN, N>1). NO replay — the caller owns that so the
|
|
189
|
+
* multisig loop never touches a ReplayStore. `maxSignatureAge` (seconds, mirrors
|
|
190
|
+
* Go VerifyOptions.MaxSignatureAge) clamps the declared lifetime per hop: when
|
|
191
|
+
* > 0 a window (expires − created) EXCEEDING it is rejected; 0/undefined =
|
|
192
|
+
* unbounded, and the bound is inclusive.
|
|
193
|
+
*/
|
|
194
|
+
export async function verifyParsedSignature(fields, parsed, sigBytes, resolve, nowSec, chainLink, maxSignatureAge) {
|
|
195
|
+
if (parsed.keyid === null)
|
|
196
|
+
return false;
|
|
197
|
+
if (parsed.alg === null || parsed.alg.toLowerCase() !== "ed25519")
|
|
198
|
+
return false;
|
|
199
|
+
for (const need of REQUIRED_COVERED) {
|
|
200
|
+
if (!parsed.covered.has(need))
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
// Entitlement coverage (mirrors Go enforceEntitlementCoverage, run right after
|
|
204
|
+
// enforceRequiredComponents): iff the request carries the entitlement-token
|
|
205
|
+
// header, the covered set MUST commit to it; absent → no constraint.
|
|
206
|
+
if (fields.entitlementHeader && !parsed.covered.has(ENTITLEMENT_COVERED)) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (parsed.created === undefined || parsed.expires === undefined)
|
|
210
|
+
return false;
|
|
211
|
+
if (parsed.expires < nowSec)
|
|
212
|
+
return false;
|
|
213
|
+
if (parsed.created > nowSec + MAX_FUTURE_SKEW_SEC)
|
|
214
|
+
return false;
|
|
215
|
+
// Lifetime clamp (mirrors Go enforceCreatedExpires MaxSignatureAge): reject a
|
|
216
|
+
// declared window longer than the verifier allows. 0/undefined = unbounded;
|
|
217
|
+
// the bound is inclusive (strictly-greater is rejected).
|
|
218
|
+
if (maxSignatureAge !== undefined &&
|
|
219
|
+
maxSignatureAge > 0 &&
|
|
220
|
+
parsed.expires - parsed.created > maxSignatureAge) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const expectedDigest = await contentDigest(fields.body);
|
|
224
|
+
if (fields.digestHeader.trim() !== expectedDigest)
|
|
225
|
+
return false;
|
|
226
|
+
if (!sigBytes)
|
|
227
|
+
return false;
|
|
228
|
+
const pub = resolve.resolve(parsed.keyid);
|
|
229
|
+
if (!pub || pub.length !== 32)
|
|
230
|
+
return false;
|
|
231
|
+
const base = buildRequestSignatureBase({
|
|
232
|
+
method: fields.method,
|
|
233
|
+
url: fields.url,
|
|
234
|
+
digestHeader: fields.digestHeader,
|
|
235
|
+
authorization: fields.authorization,
|
|
236
|
+
signatureAgent: fields.signatureAgent,
|
|
237
|
+
}, parsed.rawParams, chainLink);
|
|
238
|
+
return ed25519Verify(pub, sigBytes, utf8Bytes(base));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Verify an inbound single-signature FORA request; return a reason-tagged verdict.
|
|
242
|
+
*
|
|
243
|
+
* Mirrors the Go connectserver single-sig verify order: required covered-set →
|
|
244
|
+
* created/expires window → content-digest → key resolution → Ed25519 check over
|
|
245
|
+
* the reconstructed base → two-phase replay. Every authenticity/freshness/key/
|
|
246
|
+
* covered-set failure collapses to "signature" (the Go default branch); a replayed
|
|
247
|
+
* nonce is "replay". Keys resolve ONLY through `resolve`; replay state lives ONLY
|
|
248
|
+
* in `replayStore` when supplied; time is read ONLY through `now`.
|
|
249
|
+
*/
|
|
250
|
+
export async function verifyRequestServer(input) {
|
|
251
|
+
// Every read goes through the case-insensitive, duplicate-joining fold — see
|
|
252
|
+
// readHeader for why both properties are load-bearing. These two fail closed on
|
|
253
|
+
// their own: an absent one folds to "" and the parse then rejects, landing on
|
|
254
|
+
// the same reason the oracle does.
|
|
255
|
+
const signatureInput = readHeader(input.headers, "signature-input") ?? "";
|
|
256
|
+
const signatureHeader = readHeader(input.headers, "signature") ?? "";
|
|
257
|
+
// A covered header the request does not CARRY cannot be reconstructed, and "" is a
|
|
258
|
+
// value the signer may legitimately have bound — so absent is refused rather than
|
|
259
|
+
// defaulted. Derived from the covered set; see requestVerifyFields.
|
|
260
|
+
const fields = requestVerifyFields(input.method, input.url, input.body, input.headers);
|
|
261
|
+
if (fields === undefined)
|
|
262
|
+
return reject(REASON_SIGNATURE);
|
|
263
|
+
const parsed = parseSignatureInput(signatureInput);
|
|
264
|
+
if (!parsed)
|
|
265
|
+
return reject(REASON_SIGNATURE);
|
|
266
|
+
const nowSec = Math.floor(input.now());
|
|
267
|
+
const sigBytes = parseSignatureBytes(signatureHeader);
|
|
268
|
+
// The full per-signature core (covered set / window / digest / key / Ed25519),
|
|
269
|
+
// shared with the multisig path; replay stays here so the multisig loop never
|
|
270
|
+
// touches a ReplayStore.
|
|
271
|
+
const ok = await verifyParsedSignature(fields, parsed, sigBytes, input.resolve, nowSec, undefined, input.maxSignatureAge);
|
|
272
|
+
if (!ok || parsed.keyid === null || !sigBytes)
|
|
273
|
+
return reject(REASON_SIGNATURE);
|
|
274
|
+
if (input.replayStore) {
|
|
275
|
+
// Two-phase (read-only Seen, then SeenOrAdd) mirrors connectserver.verify so a
|
|
276
|
+
// part-way rejection never burns the nonce (single-sig: one signature).
|
|
277
|
+
const nonce = replayNonce(parsed.keyid, sigBytes);
|
|
278
|
+
if (await input.replayStore.seenNonce(nonce))
|
|
279
|
+
return reject(REASON_REPLAY);
|
|
280
|
+
if (await input.replayStore.seenOrAdd(nonce))
|
|
281
|
+
return reject(REASON_REPLAY);
|
|
282
|
+
}
|
|
283
|
+
return accept;
|
|
284
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A Window yields the [created, expires] unix-second cutoffs for one signature. */
|
|
2
|
+
export type Window = () => [created: number, expires: number];
|
|
3
|
+
/**
|
|
4
|
+
* clockWindow returns the plain production Window: it stamps each signature with
|
|
5
|
+
* clock-derived created = floor(now()) and expires = created + ttlSec. `now`
|
|
6
|
+
* reads seconds (fractional allowed); the floor reproduces Go's .Unix().
|
|
7
|
+
*/
|
|
8
|
+
export declare function clockWindow(now: () => number, ttlSec: number): Window;
|
|
9
|
+
/**
|
|
10
|
+
* monotonicWindow returns a Window whose expires cutoff strictly increases
|
|
11
|
+
* across calls: it tracks floor(now()) + ttlSec but, when a burst of requests
|
|
12
|
+
* lands in the same wall-clock second, bumps expires by one second per call so
|
|
13
|
+
* no two back-to-back signatures share an (keyid, expires) pair — keeping
|
|
14
|
+
* identical relay requests from colliding in the server's replay store. created
|
|
15
|
+
* tracks floor(now()), so the pair stays clock-consistent.
|
|
16
|
+
*
|
|
17
|
+
* ONE INSTANCE PER CLIENT, never one per call. The running maximum is the whole
|
|
18
|
+
* mechanism: a window created per request starts from zero, cannot see the
|
|
19
|
+
* previous signature, and provides exactly none of the uniqueness it was chosen
|
|
20
|
+
* for — while still looking correct at the call site.
|
|
21
|
+
*/
|
|
22
|
+
export declare function monotonicWindow(now: () => number, ttlSec: number): Window;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Signature Window (sdk/ts/core) — TS port of the sdk/go oracle
|
|
2
|
+
// (core/sigwindow.go). A Window returns the RFC 9421 (created, expires) cutoffs
|
|
3
|
+
// (unix seconds) to stamp on the next outbound signature; it is invoked once per
|
|
4
|
+
// signed request. Both values derive from a single now() reading so a
|
|
5
|
+
// deterministic-clock test stays inside the verifier's freshness window.
|
|
6
|
+
//
|
|
7
|
+
// The clock is in the SECONDS domain (matching Go's now().Unix()); both faces
|
|
8
|
+
// FLOOR to integer seconds via Math.floor so the produced @signature-params
|
|
9
|
+
// bytes stay byte-identical to the sign-site's historical inline mint.
|
|
10
|
+
/**
|
|
11
|
+
* clockWindow returns the plain production Window: it stamps each signature with
|
|
12
|
+
* clock-derived created = floor(now()) and expires = created + ttlSec. `now`
|
|
13
|
+
* reads seconds (fractional allowed); the floor reproduces Go's .Unix().
|
|
14
|
+
*/
|
|
15
|
+
export function clockWindow(now, ttlSec) {
|
|
16
|
+
return () => {
|
|
17
|
+
const created = Math.floor(now());
|
|
18
|
+
return [created, created + ttlSec];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* monotonicWindow returns a Window whose expires cutoff strictly increases
|
|
23
|
+
* across calls: it tracks floor(now()) + ttlSec but, when a burst of requests
|
|
24
|
+
* lands in the same wall-clock second, bumps expires by one second per call so
|
|
25
|
+
* no two back-to-back signatures share an (keyid, expires) pair — keeping
|
|
26
|
+
* identical relay requests from colliding in the server's replay store. created
|
|
27
|
+
* tracks floor(now()), so the pair stays clock-consistent.
|
|
28
|
+
*
|
|
29
|
+
* ONE INSTANCE PER CLIENT, never one per call. The running maximum is the whole
|
|
30
|
+
* mechanism: a window created per request starts from zero, cannot see the
|
|
31
|
+
* previous signature, and provides exactly none of the uniqueness it was chosen
|
|
32
|
+
* for — while still looking correct at the call site.
|
|
33
|
+
*/
|
|
34
|
+
export function monotonicWindow(now, ttlSec) {
|
|
35
|
+
let lastExpires = 0;
|
|
36
|
+
return () => {
|
|
37
|
+
const created = Math.floor(now());
|
|
38
|
+
const floor = created + ttlSec;
|
|
39
|
+
const next = lastExpires >= floor ? lastExpires + 1 : floor;
|
|
40
|
+
lastExpires = next;
|
|
41
|
+
return [created, next];
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface ZodDef {
|
|
2
|
+
readonly typeName?: string;
|
|
3
|
+
readonly type?: AnyZod | string;
|
|
4
|
+
readonly innerType?: AnyZod;
|
|
5
|
+
readonly schema?: AnyZod;
|
|
6
|
+
readonly element?: AnyZod;
|
|
7
|
+
readonly in?: AnyZod;
|
|
8
|
+
}
|
|
9
|
+
interface AnyZod {
|
|
10
|
+
readonly _def: ZodDef;
|
|
11
|
+
}
|
|
12
|
+
export declare function kindOf(schema: AnyZod): string;
|
|
13
|
+
/**
|
|
14
|
+
* fromWireOffer reconstructs the canonical (signed) offer object from the WIRE
|
|
15
|
+
* offer object, mirroring sdk/python from_wire_offer. Feed its result to
|
|
16
|
+
* canonicalOfferPayload (or RFC 8785 JCS directly) to reproduce the exact bytes the
|
|
17
|
+
* offer signature covers.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fromWireOffer(offerWire: Record<string, unknown>): Record<string, unknown>;
|
|
20
|
+
export {};
|