@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.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +37 -0
  3. package/gen/ts/vocab/functiontokens.js +78 -0
  4. package/gen/ts/vocab/functiontokens.ts +84 -0
  5. package/gen/ts/vocab/geographytokens.js +30 -0
  6. package/gen/ts/vocab/geographytokens.ts +37 -0
  7. package/gen/ts/vocab/pricingunits.js +56 -0
  8. package/gen/ts/vocab/pricingunits.ts +63 -0
  9. package/gen/ts/vocab/quotametrics.js +40 -0
  10. package/gen/ts/vocab/quotametrics.ts +47 -0
  11. package/gen/ts/vocab/usertypes.js +40 -0
  12. package/gen/ts/vocab/usertypes.ts +46 -0
  13. package/gen/ts/wire/base.js +245 -0
  14. package/gen/ts/wire/base.ts +255 -0
  15. package/gen/ts/wire/names.js +41 -0
  16. package/gen/ts/wire/names.ts +42 -0
  17. package/gen/ts/wire/schemas.js +111 -0
  18. package/gen/ts/wire/schemas.ts +216 -0
  19. package/package.json +164 -0
  20. package/sdk/ts/client/content.d.ts +74 -0
  21. package/sdk/ts/client/content.js +466 -0
  22. package/sdk/ts/client/errors.d.ts +79 -0
  23. package/sdk/ts/client/errors.js +148 -0
  24. package/sdk/ts/client/index.d.ts +241 -0
  25. package/sdk/ts/client/index.js +740 -0
  26. package/sdk/ts/client/route.d.ts +41 -0
  27. package/sdk/ts/client/route.js +107 -0
  28. package/sdk/ts/client/send.d.ts +64 -0
  29. package/sdk/ts/client/send.js +142 -0
  30. package/sdk/ts/client/transport.d.ts +187 -0
  31. package/sdk/ts/client/transport.js +381 -0
  32. package/sdk/ts/core/multisig-parse.d.ts +47 -0
  33. package/sdk/ts/core/multisig-parse.js +234 -0
  34. package/sdk/ts/core/sign-request.d.ts +83 -0
  35. package/sdk/ts/core/sign-request.js +146 -0
  36. package/sdk/ts/core/sign.d.ts +31 -0
  37. package/sdk/ts/core/sign.js +82 -0
  38. package/sdk/ts/core/signing-transport.d.ts +75 -0
  39. package/sdk/ts/core/signing-transport.js +172 -0
  40. package/sdk/ts/core/verifier.d.ts +177 -0
  41. package/sdk/ts/core/verifier.js +269 -0
  42. package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
  43. package/sdk/ts/core/verify-multisig-request.js +112 -0
  44. package/sdk/ts/core/verify-request.d.ts +181 -0
  45. package/sdk/ts/core/verify-request.js +284 -0
  46. package/sdk/ts/core/window.d.ts +22 -0
  47. package/sdk/ts/core/window.js +43 -0
  48. package/sdk/ts/core/wire-canon.d.ts +20 -0
  49. package/sdk/ts/core/wire-canon.js +174 -0
  50. package/sdk/ts/hono/middleware.d.ts +38 -0
  51. package/sdk/ts/hono/middleware.js +64 -0
  52. package/sdk/ts/resolvers/errors.d.ts +130 -0
  53. package/sdk/ts/resolvers/errors.js +150 -0
  54. package/sdk/ts/resolvers/http.d.ts +98 -0
  55. package/sdk/ts/resolvers/http.js +274 -0
  56. package/sdk/ts/resolvers/index.d.ts +9 -0
  57. package/sdk/ts/resolvers/index.js +15 -0
  58. package/sdk/ts/resolvers/jwks.d.ts +2 -0
  59. package/sdk/ts/resolvers/jwks.js +43 -0
  60. package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
  61. package/sdk/ts/resolvers/offer-key-cache.js +152 -0
  62. package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
  63. package/sdk/ts/resolvers/registration-requirements.js +220 -0
  64. package/sdk/ts/resolvers/ssrf.d.ts +28 -0
  65. package/sdk/ts/resolvers/ssrf.js +235 -0
  66. package/sdk/ts/resolvers/static.d.ts +7 -0
  67. package/sdk/ts/resolvers/static.js +16 -0
  68. package/sdk/ts/resolvers/wba.d.ts +134 -0
  69. package/sdk/ts/resolvers/wba.js +506 -0
  70. package/sdk/ts/resolvers/wellknown.d.ts +28 -0
  71. package/sdk/ts/resolvers/wellknown.js +169 -0
  72. package/sdk/ts/src/acceptance.d.ts +46 -0
  73. package/sdk/ts/src/acceptance.js +162 -0
  74. package/sdk/ts/src/base64url.d.ts +19 -0
  75. package/sdk/ts/src/base64url.js +55 -0
  76. package/sdk/ts/src/crossfield.d.ts +28 -0
  77. package/sdk/ts/src/crossfield.js +215 -0
  78. package/sdk/ts/src/endpoint-rule.d.ts +30 -0
  79. package/sdk/ts/src/endpoint-rule.js +63 -0
  80. package/sdk/ts/src/errordetail.d.ts +115 -0
  81. package/sdk/ts/src/errordetail.js +256 -0
  82. package/sdk/ts/src/hashurl.d.ts +5 -0
  83. package/sdk/ts/src/hashurl.js +15 -0
  84. package/sdk/ts/src/host-ref.d.ts +70 -0
  85. package/sdk/ts/src/host-ref.js +320 -0
  86. package/sdk/ts/src/hosts.d.ts +138 -0
  87. package/sdk/ts/src/hosts.js +214 -0
  88. package/sdk/ts/src/idempotency.d.ts +11 -0
  89. package/sdk/ts/src/idempotency.js +27 -0
  90. package/sdk/ts/src/jsondepth.d.ts +27 -0
  91. package/sdk/ts/src/jsondepth.js +77 -0
  92. package/sdk/ts/src/licenseterm.d.ts +113 -0
  93. package/sdk/ts/src/licenseterm.js +433 -0
  94. package/sdk/ts/src/money.d.ts +21 -0
  95. package/sdk/ts/src/money.js +60 -0
  96. package/sdk/ts/src/offer-sign.d.ts +11 -0
  97. package/sdk/ts/src/offer-sign.js +31 -0
  98. package/sdk/ts/src/opaque-url.d.ts +3 -0
  99. package/sdk/ts/src/opaque-url.js +20 -0
  100. package/sdk/ts/src/pop.d.ts +51 -0
  101. package/sdk/ts/src/pop.js +166 -0
  102. package/sdk/ts/src/regschema.d.ts +229 -0
  103. package/sdk/ts/src/regschema.js +1315 -0
  104. package/sdk/ts/src/scopes.d.ts +20 -0
  105. package/sdk/ts/src/scopes.js +45 -0
  106. package/sdk/ts/src/signurl.d.ts +23 -0
  107. package/sdk/ts/src/signurl.js +107 -0
  108. package/sdk/ts/src/thumbprint.d.ts +14 -0
  109. package/sdk/ts/src/thumbprint.js +24 -0
  110. package/sdk/ts/src/verify.d.ts +49 -0
  111. package/sdk/ts/src/verify.js +109 -0
  112. package/sdk/ts/src/wire-names.d.ts +1 -0
  113. package/sdk/ts/src/wire-names.js +16 -0
  114. package/sdk/ts/src/wire.d.ts +68 -0
  115. 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 {};