@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,220 @@
1
+ // Reading what one Exchange asks of a registration — the terms revision that
2
+ // submitting one accepts, and the schema its registration_data must match — out of
3
+ // that Exchange's own /.well-known/fora.json. TS port of the Go oracle
4
+ // (resolvers/registrationrequirements.go).
5
+ //
6
+ // EVERY READ IS A FRESH FETCH, and that is the whole design. The protocol requires
7
+ // it in as many words: a registering client MUST read the terms digest from a
8
+ // freshly fetched manifest rather than a cached copy. A cached ENDPOINT is fine — a
9
+ // wrong one fails loudly — but a cached DIGEST is not, because a client cannot
10
+ // detect staleness locally, so a warm cache would make it echo a value the Exchange
11
+ // has already stopped accepting and retry the same refusal until the cache expired.
12
+ //
13
+ // That is why this is a SEPARATE face rather than a third method on the endpoint
14
+ // resolver. That resolver is built out of exactly the mechanism this value may not
15
+ // touch — a per-host TTL cache with single-flight coalescing on top — and it exposes
16
+ // no bypass. A face that holds no document cache leaves no cache slot to reuse,
17
+ // which is what makes the rule structural rather than a convention callers remember.
18
+ //
19
+ // Not even the compiled validator is held. Memoising it is a cache, and the useful
20
+ // key for one is a property of a deployment's threat model rather than of the
21
+ // protocol; an application that wants it wraps this face.
22
+ import { isBareDomain } from "../src/hosts.js";
23
+ import { invalidHost } from "../src/host-ref.js";
24
+ import { compileRegistrationSchema, } from "../src/regschema.js";
25
+ import { manifestVersionRefusal, WellKnownPath } from "../src/wire.js";
26
+ import { DirectoryUnavailable, ExchangeNotPermitted, ManifestNotExchange, ManifestUnusable, } from "./errors.js";
27
+ import { fetchStrict, guardedFetchFromEnv } from "./http.js";
28
+ /** Read registration requirements from an Exchange's own well-known manifest. */
29
+ export function createWellKnownRequirementsReader(opts = {}) {
30
+ // Built ONCE per reader, never per read: guardedFetchFromEnv constructs a
31
+ // dispatcher, so a per-read default would trade an unguarded dial for a socket
32
+ // leak.
33
+ const fetchFn = opts.fetch ?? guardedFetchFromEnv();
34
+ const scheme = opts.scheme && opts.scheme !== "" ? opts.scheme : "https";
35
+ const allow = opts.allow;
36
+ return {
37
+ async resolveRegistrationRequirements(exchange) {
38
+ // The SHAPE predicate is the contract's isBareDomain, not the routing tier's
39
+ // isBareHost. The two are kept apart deliberately and this leg is where the
40
+ // difference bites: nothing upstream has run the contract's rule, and the URL
41
+ // below is built by concatenation, so a value carrying a path or userinfo
42
+ // would choose WHAT is fetched rather than merely where from.
43
+ //
44
+ // The document's own version is read BEFORE any other member below, which is the
45
+ // rule the contract states for every consumer of this document and not only for
46
+ // the face that reads an endpoint out of it. A layout this reader cannot classify
47
+ // does not get to supply a terms digest that a request signature then covers.
48
+ if (!isBareDomain(exchange)) {
49
+ throw invalidHost(exchange, "not a bare domain");
50
+ }
51
+ if (allow && !allow(exchange)) {
52
+ throw new ExchangeNotPermitted(`exchange ${exchange} not permitted by policy`);
53
+ }
54
+ const url = `${scheme}://${exchange}${WellKnownPath}`;
55
+ const body = await fetchStrict(fetchFn, url);
56
+ let doc;
57
+ try {
58
+ doc = JSON.parse(body);
59
+ }
60
+ catch (err) {
61
+ throw new DirectoryUnavailable(`manifest decode ${url}`, { cause: err });
62
+ }
63
+ // The document version gate, before any other member is read — the contract's own
64
+ // ordering, and the same call the sibling endpoint face makes. A document that is
65
+ // not an object carries no `ver`, so it is refused here as an absent one rather
66
+ // than through a guard of its own: a body of bare `null` is a body with no
67
+ // version, whatever else it is, and giving it a separate answer is what made the
68
+ // three languages disagree about it.
69
+ //
70
+ // The refusal wears THIS seam's verdict, never the endpoint seam's
71
+ // ManifestVersionRefused. The two vocabularies are disjoint on purpose: one
72
+ // answers whether an endpoint may be dialled, this one whether a document can be
73
+ // read for what a registration owes.
74
+ const versionRefusal = manifestVersionRefusal(doc?.ver);
75
+ if (versionRefusal !== undefined) {
76
+ throw new ManifestUnusable(versionRefusal);
77
+ }
78
+ if (!describesExchange(doc)) {
79
+ throw new ManifestNotExchange(`host=${exchange}`);
80
+ }
81
+ const digest = doc.terms_digest;
82
+ const out = {
83
+ termsDigest: typeof digest === "string" ? digest : undefined,
84
+ schema: null,
85
+ verdict: "not_published",
86
+ };
87
+ // Compiled from the bytes AS SERVED. Every cap the registration-schema rules
88
+ // state is defined over those bytes, so the member is sliced out of the body
89
+ // rather than re-serialised from the parsed value: JSON.parse discards
90
+ // whitespace and reformats numbers, and a document that minifies under the
91
+ // cap while exceeding it on the wire would be refused by an Exchange and
92
+ // accepted here — the two-privately-chosen-limits failure the contract's
93
+ // numbers exist to prevent.
94
+ const raw = rawMember(body, "account_registration", "data_schema");
95
+ if (raw !== undefined) {
96
+ const compiled = compileRegistrationSchema(raw);
97
+ out.schema = compiled.schema;
98
+ out.verdict = compiled.verdict;
99
+ }
100
+ return out;
101
+ },
102
+ };
103
+ }
104
+ /** proto-JSON renders an enum as its name or its number, so both are accepted. An
105
+ * absent or unrecognised role is not an Exchange. */
106
+ function describesExchange(doc) {
107
+ const role = doc.role;
108
+ return role === "ROLE_EXCHANGE" || role === 2;
109
+ }
110
+ /** rawMember returns the EXACT served text of a nested object member, or
111
+ * `undefined` when any step of the path is absent.
112
+ *
113
+ * It exists because JavaScript has no equivalent of a raw-message slice: the only
114
+ * way to hand a nested member to a rule defined over the bytes AS SERVED is to
115
+ * find its extent in the body. `body` has already parsed as JSON when this runs,
116
+ * so the scan below is over known-well-formed input and needs no error path — it
117
+ * returns `undefined` for anything it cannot walk, and the caller reads that as
118
+ * "no schema published", which is the safe direction.
119
+ */
120
+ function rawMember(body, ...path) {
121
+ let start = 0;
122
+ for (let step = 0; step < path.length; step++) {
123
+ const found = memberExtent(body, start, path[step]);
124
+ if (found === undefined)
125
+ return undefined;
126
+ if (step === path.length - 1)
127
+ return body.slice(found[0], found[1]);
128
+ start = found[0];
129
+ }
130
+ return undefined;
131
+ }
132
+ /** memberExtent finds `key` in the object beginning at `from` and returns the
133
+ * [start, end) of its VALUE. */
134
+ function memberExtent(body, from, key) {
135
+ let i = skipWhitespace(body, from);
136
+ if (body[i] !== "{")
137
+ return undefined;
138
+ i = skipWhitespace(body, i + 1);
139
+ if (body[i] === "}")
140
+ return undefined;
141
+ for (;;) {
142
+ if (body[i] !== '"')
143
+ return undefined;
144
+ const keyEnd = skipString(body, i);
145
+ const name = JSON.parse(body.slice(i, keyEnd));
146
+ i = skipWhitespace(body, keyEnd);
147
+ if (body[i] !== ":")
148
+ return undefined;
149
+ const valueStart = skipWhitespace(body, i + 1);
150
+ const valueEnd = skipValue(body, valueStart);
151
+ if (valueEnd === undefined)
152
+ return undefined;
153
+ if (name === key)
154
+ return [valueStart, valueEnd];
155
+ i = skipWhitespace(body, valueEnd);
156
+ if (body[i] === ",") {
157
+ i = skipWhitespace(body, i + 1);
158
+ continue;
159
+ }
160
+ return undefined;
161
+ }
162
+ }
163
+ /** RFC 8259 whitespace, and nothing wider — the same four bytes the blank-schema
164
+ * rule counts. */
165
+ function skipWhitespace(body, i) {
166
+ while (i < body.length) {
167
+ const c = body[i];
168
+ if (c !== " " && c !== "\t" && c !== "\r" && c !== "\n")
169
+ return i;
170
+ i++;
171
+ }
172
+ return i;
173
+ }
174
+ /** skipString returns the index just past a JSON string starting at `i`. */
175
+ function skipString(body, i) {
176
+ i++; // the opening quote
177
+ while (i < body.length) {
178
+ const c = body[i];
179
+ if (c === "\\") {
180
+ i += 2;
181
+ continue;
182
+ }
183
+ if (c === '"')
184
+ return i + 1;
185
+ i++;
186
+ }
187
+ return i;
188
+ }
189
+ /** skipValue returns the index just past the JSON value starting at `i`, or
190
+ * `undefined` when the value does not terminate. */
191
+ function skipValue(body, i) {
192
+ const c = body[i];
193
+ if (c === '"')
194
+ return skipString(body, i);
195
+ if (c === "{" || c === "[") {
196
+ const close = c === "{" ? "}" : "]";
197
+ let depth = 0;
198
+ while (i < body.length) {
199
+ const ch = body[i];
200
+ if (ch === '"') {
201
+ i = skipString(body, i);
202
+ continue;
203
+ }
204
+ if (ch === "{" || ch === "[")
205
+ depth++;
206
+ else if (ch === "}" || ch === "]") {
207
+ depth--;
208
+ if (depth === 0) {
209
+ return body[i] === close ? i + 1 : undefined;
210
+ }
211
+ }
212
+ i++;
213
+ }
214
+ return undefined;
215
+ }
216
+ // A number, or one of the three literals: everything up to the next structural
217
+ // character. The body has already parsed, so whatever lies here is well formed.
218
+ const end = /[\s,}\]]/.exec(body.slice(i));
219
+ return end === null ? body.length : i + end.index;
220
+ }
@@ -0,0 +1,28 @@
1
+ /** The scheme allowlist the SSRF guard enforces: deny-by-default, only http and
2
+ * https may be dialed (mirrors the Go oracle's allowedScheme). Blocks
3
+ * file/data/gopher/dict/ftp/ldap and every other exotic scheme that would let a
4
+ * caller-supplied URL reach the local filesystem or unintended protocols. The
5
+ * comparison is case-insensitive (HTTPS ≡ https). */
6
+ export declare function allowedScheme(scheme: string): boolean;
7
+ /** The shared redirect-depth cap. Deny-by-default and IDENTICAL across the three
8
+ * SDKs (Go maxWBARedirects / httpx max_redirects): the guarded transport follows at
9
+ * most this many redirect hops and refuses the next, so no SDK inherits its HTTP
10
+ * library's looser default (undici/fetch follow ~20). The real transport enforces
11
+ * it via undici's `interceptors.redirect({ maxRedirections })`; the corpus pins the
12
+ * predicate below. */
13
+ export declare const MAX_REDIRECTS = 5;
14
+ /** Report whether a chain of `hops` redirects must be refused — the shared
15
+ * redirect-depth policy (refused iff it EXCEEDS MAX_REDIRECTS). Corpus-tested
16
+ * against the Go oracle's redirectChainRefused so the three SDKs agree on the
17
+ * boundary. */
18
+ export declare function redirectChainRefused(hops: number): boolean;
19
+ /** Reports whether `ip` (a numeric IPv4 or IPv6 literal — never a hostname) is
20
+ * a reserved / non-public address the SSRF guard must refuse to dial. It first
21
+ * unwraps v4-mapped and NAT64 forms and re-checks the embedded v4, so an IPv6
22
+ * literal embedding a private v4 cannot slip past a v6-only test. An
23
+ * unparseable input is treated as blocked (fail-closed: never dial what we
24
+ * cannot classify). */
25
+ export declare function blockedAddress(ip: string): boolean;
26
+ /** Parse an IPv4 or IPv6 literal into its network-order bytes (4 or 16), or
27
+ * `undefined` when it is not a bare numeric address. */
28
+ export declare function parseAddr(ip: string): Uint8Array | undefined;
@@ -0,0 +1,235 @@
1
+ // The SSRF address guard for the fetching resolvers' DEFAULT transport. Ports
2
+ // sdk/go/helpers/wbakeyresolver.go (ssrfBlockedPrefixes / ssrfBlocked) 1:1.
3
+ //
4
+ // A WBA/well-known resolver fetches a directory from a caller-supplied
5
+ // Signature-Agent host BEFORE the ed25519 signature check, so an unguarded
6
+ // default is a pre-auth SSRF lever against internal networks. blockedAddress is
7
+ // the reserved / non-public address predicate the guarded default transport
8
+ // applies to EVERY resolved address before connecting. It replaces the
9
+ // IsPrivate/IsLinkLocal heuristic, which misses CGNAT (100.64.0.0/10),
10
+ // 0.0.0.0/8, the TEST-NETs, benchmarking, protocol-assignment, and other
11
+ // reserved ranges. IPv4-mapped (::ffff:a.b.c.d) and NAT64 (64:ff9b::a.b.c.d)
12
+ // forms are unwrapped and their embedded v4 re-checked, so an IPv6 literal
13
+ // cannot smuggle a private v4 past a v6-form-only test.
14
+ /** The scheme allowlist the SSRF guard enforces: deny-by-default, only http and
15
+ * https may be dialed (mirrors the Go oracle's allowedScheme). Blocks
16
+ * file/data/gopher/dict/ftp/ldap and every other exotic scheme that would let a
17
+ * caller-supplied URL reach the local filesystem or unintended protocols. The
18
+ * comparison is case-insensitive (HTTPS ≡ https). */
19
+ export function allowedScheme(scheme) {
20
+ const s = scheme.toLowerCase();
21
+ return s === "http" || s === "https";
22
+ }
23
+ /** The shared redirect-depth cap. Deny-by-default and IDENTICAL across the three
24
+ * SDKs (Go maxWBARedirects / httpx max_redirects): the guarded transport follows at
25
+ * most this many redirect hops and refuses the next, so no SDK inherits its HTTP
26
+ * library's looser default (undici/fetch follow ~20). The real transport enforces
27
+ * it via undici's `interceptors.redirect({ maxRedirections })`; the corpus pins the
28
+ * predicate below. */
29
+ export const MAX_REDIRECTS = 5;
30
+ /** Report whether a chain of `hops` redirects must be refused — the shared
31
+ * redirect-depth policy (refused iff it EXCEEDS MAX_REDIRECTS). Corpus-tested
32
+ * against the Go oracle's redirectChainRefused so the three SDKs agree on the
33
+ * boundary. */
34
+ export function redirectChainRefused(hops) {
35
+ return hops > MAX_REDIRECTS;
36
+ }
37
+ // The reserved / non-public prefix set the guard rejects — the EXACT set the Go
38
+ // oracle rejects (ssrfBlockedPrefixes), split v4 / v6.
39
+ const BLOCKED_CIDRS = [
40
+ // IPv4
41
+ "0.0.0.0/8",
42
+ "10.0.0.0/8",
43
+ "100.64.0.0/10",
44
+ "127.0.0.0/8",
45
+ "169.254.0.0/16",
46
+ "172.16.0.0/12",
47
+ "192.0.0.0/24",
48
+ "192.0.2.0/24",
49
+ "192.88.99.0/24",
50
+ "192.168.0.0/16",
51
+ "198.18.0.0/15",
52
+ "198.51.100.0/24",
53
+ "203.0.113.0/24",
54
+ "224.0.0.0/4",
55
+ "240.0.0.0/4",
56
+ "255.255.255.255/32",
57
+ // IPv6
58
+ "::/128",
59
+ "::1/128",
60
+ // IPv4-compatible IPv6 (::a.b.c.d) — deprecated; block the whole ::/96 so an
61
+ // embedded v4 cannot smuggle a reserved address through a v6 literal.
62
+ "::/96",
63
+ "100::/64",
64
+ "2001:db8::/32",
65
+ "2001::/23",
66
+ // 6to4 (RFC 3056) — block wholesale; a 2002:V4ADDR:: literal embeds an
67
+ // arbitrary v4 in bits 16..48 and must never be dialed.
68
+ "2002::/16",
69
+ "fc00::/7",
70
+ "fe80::/10",
71
+ "ff00::/8",
72
+ // NAT64 (the /96 well-known prefix's embedded v4 is also re-checked below)
73
+ "64:ff9b::/96",
74
+ "64:ff9b:1::/48",
75
+ ];
76
+ // The well-known NAT64 prefix (RFC 6052). An address inside it embeds an IPv4
77
+ // in its trailing 32 bits, which must be re-checked for reserved-ness.
78
+ const NAT64_WELL_KNOWN = parseCidr("64:ff9b::/96");
79
+ const BLOCKED_PARSED = BLOCKED_CIDRS.map(parseCidr);
80
+ /** Reports whether `ip` (a numeric IPv4 or IPv6 literal — never a hostname) is
81
+ * a reserved / non-public address the SSRF guard must refuse to dial. It first
82
+ * unwraps v4-mapped and NAT64 forms and re-checks the embedded v4, so an IPv6
83
+ * literal embedding a private v4 cannot slip past a v6-only test. An
84
+ * unparseable input is treated as blocked (fail-closed: never dial what we
85
+ * cannot classify). */
86
+ export function blockedAddress(ip) {
87
+ const addr = parseAddr(ip);
88
+ if (!addr)
89
+ return true;
90
+ return blockedBytes(addr);
91
+ }
92
+ function blockedBytes(addr) {
93
+ // Unwrap an IPv4-mapped IPv6 (::ffff:a.b.c.d) to its 4-byte form so the v4
94
+ // prefix table applies. Then, for any remaining 16-byte address, re-check a
95
+ // NAT64-embedded v4 before the prefix scan.
96
+ const unmapped = unmapV4(addr);
97
+ if (unmapped.length === 16) {
98
+ const embedded = nat64EmbeddedV4(unmapped);
99
+ if (embedded && blockedBytes(embedded))
100
+ return true;
101
+ }
102
+ for (const cidr of BLOCKED_PARSED) {
103
+ if (cidr.bytes.length === unmapped.length && contains(cidr, unmapped))
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+ /** Extract the trailing 32 bits of a 64:ff9b::/96 address as a 4-byte v4, or
109
+ * `undefined` when `addr` is not in the well-known NAT64 prefix. */
110
+ function nat64EmbeddedV4(addr) {
111
+ if (!contains(NAT64_WELL_KNOWN, addr))
112
+ return undefined;
113
+ return addr.slice(12, 16);
114
+ }
115
+ /** Unwrap an IPv4-mapped IPv6 address (::ffff:a.b.c.d) to its 4-byte v4 form;
116
+ * pass any other address through unchanged. */
117
+ function unmapV4(addr) {
118
+ if (addr.length !== 16)
119
+ return addr;
120
+ for (let i = 0; i < 10; i++)
121
+ if (addr[i] !== 0)
122
+ return addr;
123
+ if (addr[10] !== 0xff || addr[11] !== 0xff)
124
+ return addr;
125
+ return addr.slice(12, 16);
126
+ }
127
+ /** Whether `addr` (same byte-length as the CIDR) falls inside `cidr` — a masked
128
+ * byte comparison over the prefix length. */
129
+ function contains(cidr, addr) {
130
+ if (addr.length !== cidr.bytes.length)
131
+ return false;
132
+ let bits = cidr.bits;
133
+ for (let i = 0; i < cidr.bytes.length; i++) {
134
+ if (bits <= 0)
135
+ break;
136
+ const take = bits >= 8 ? 8 : bits;
137
+ const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
138
+ if ((addr[i] & mask) !== (cidr.bytes[i] & mask))
139
+ return false;
140
+ bits -= 8;
141
+ }
142
+ return true;
143
+ }
144
+ function parseCidr(cidr) {
145
+ const slash = cidr.lastIndexOf("/");
146
+ const addrPart = cidr.slice(0, slash);
147
+ const bits = Number(cidr.slice(slash + 1));
148
+ const bytes = parseAddr(addrPart);
149
+ if (!bytes)
150
+ throw new Error(`ssrf: unparseable CIDR ${cidr}`);
151
+ return { bytes, bits };
152
+ }
153
+ /** Parse an IPv4 or IPv6 literal into its network-order bytes (4 or 16), or
154
+ * `undefined` when it is not a bare numeric address. */
155
+ export function parseAddr(ip) {
156
+ if (ip.includes(":"))
157
+ return parseV6(ip);
158
+ return parseV4(ip);
159
+ }
160
+ function parseV4(ip) {
161
+ const parts = ip.split(".");
162
+ if (parts.length !== 4)
163
+ return undefined;
164
+ const out = new Uint8Array(4);
165
+ for (let i = 0; i < 4; i++) {
166
+ const p = parts[i];
167
+ if (!/^\d{1,3}$/.test(p))
168
+ return undefined;
169
+ const n = Number(p);
170
+ if (n > 255)
171
+ return undefined;
172
+ out[i] = n;
173
+ }
174
+ return out;
175
+ }
176
+ function parseV6(ip) {
177
+ // Strip a zone id (fe80::1%eth0) — irrelevant to the prefix check.
178
+ const zone = ip.indexOf("%");
179
+ if (zone >= 0)
180
+ ip = ip.slice(0, zone);
181
+ const halves = ip.split("::");
182
+ if (halves.length > 2)
183
+ return undefined;
184
+ const head = halves[0] === "" ? [] : halves[0].split(":");
185
+ const tailStr = halves.length === 2 ? halves[1] : undefined;
186
+ const tail = tailStr === undefined || tailStr === "" ? [] : tailStr.split(":");
187
+ const out = new Uint8Array(16);
188
+ let idx = 0;
189
+ const writeGroups = (groups, start) => {
190
+ let pos = start;
191
+ for (let g = 0; g < groups.length; g++) {
192
+ const grp = groups[g];
193
+ // A trailing IPv4 in the last group (e.g. ::ffff:1.2.3.4 or 64:ff9b::a.b.c.d).
194
+ if (grp.includes(".")) {
195
+ if (g !== groups.length - 1)
196
+ return false;
197
+ const v4 = parseV4(grp);
198
+ if (!v4)
199
+ return false;
200
+ if (pos + 4 > 16)
201
+ return false;
202
+ out.set(v4, pos);
203
+ pos += 4;
204
+ continue;
205
+ }
206
+ if (!/^[0-9a-fA-F]{1,4}$/.test(grp))
207
+ return false;
208
+ if (pos + 2 > 16)
209
+ return false;
210
+ const val = parseInt(grp, 16);
211
+ out[pos] = (val >> 8) & 0xff;
212
+ out[pos + 1] = val & 0xff;
213
+ pos += 2;
214
+ }
215
+ return true;
216
+ };
217
+ // Count the bytes each side occupies (an embedded v4 counts as 4, not 2).
218
+ const byteLen = (groups) => groups.reduce((n, g) => n + (g.includes(".") ? 4 : 2), 0);
219
+ if (halves.length === 1) {
220
+ // No "::" — must be a full address.
221
+ if (byteLen(head) !== 16)
222
+ return undefined;
223
+ return writeGroups(head, 0) ? out : undefined;
224
+ }
225
+ const headBytes = byteLen(head);
226
+ const tailBytes = byteLen(tail);
227
+ if (headBytes + tailBytes > 16)
228
+ return undefined;
229
+ if (!writeGroups(head, 0))
230
+ return undefined;
231
+ idx = 16 - tailBytes;
232
+ if (!writeGroups(tail, idx))
233
+ return undefined;
234
+ return out;
235
+ }
@@ -0,0 +1,7 @@
1
+ /** The static key face. */
2
+ export interface StaticKeyResolver {
3
+ resolve(keyid: string): Promise<Uint8Array | undefined>;
4
+ put(keyid: string, pub: Uint8Array): void;
5
+ }
6
+ /** Return a resolver seeded with `keys` (copied). */
7
+ export declare function createStaticKeyResolver(keys?: Record<string, Uint8Array>): StaticKeyResolver;
@@ -0,0 +1,16 @@
1
+ // The static named key resolver face — a thin in-memory map satisfying the
2
+ // RequestKeyResolver-shaped `resolve(keyid) -> Promise<Uint8Array | undefined>` plus a
3
+ // `put` for dynamic/test seeding. Mirrors the Go NewStaticKeyResolver: a plain
4
+ // unknown key is `undefined` (the fall-through miss), never a thrown error.
5
+ /** Return a resolver seeded with `keys` (copied). */
6
+ export function createStaticKeyResolver(keys = {}) {
7
+ const map = new Map(Object.entries(keys));
8
+ return {
9
+ resolve(keyid) {
10
+ return Promise.resolve(map.get(keyid));
11
+ },
12
+ put(keyid, pub) {
13
+ map.set(keyid, pub);
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,134 @@
1
+ import { WBAFileSchema } from "../../../gen/ts/wire/schemas.ts";
2
+ import { type FetchLike } from "./http.ts";
3
+ /** The single public well-known path a WBA identity directory is served at (Web
4
+ * Bot Auth; the identity half of the identity/commercial split — the commercial
5
+ * overlay stays in /.well-known/fora.json). The one shared copy across the whole SDK. */
6
+ export declare const WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
7
+ /** Build the full WBA identity-directory URL from a scheme and an already-joined
8
+ * host: `${scheme}://${host}` + {@link WBA_DIRECTORY_PATH}. An empty scheme
9
+ * defaults to https. A PURE string function — the host arrives ALREADY-JOINED (any
10
+ * port-join / IPv6 bracketing is the caller's concern), there is NO env read and NO
11
+ * scheme-in-host detection (those stay consumer glue). It mirrors the sdk/go
12
+ * WBADirectoryURL oracle byte-for-byte, locked by the tri-replayed
13
+ * wba-url-vectors.json corpus. */
14
+ export declare function wbaDirectoryURL(scheme: string, host: string): string;
15
+ type WBAFile = ReturnType<typeof WBAFileSchema.parse>;
16
+ /** Options for the WBA resolver. Zero values are safe defaults; tests inject the
17
+ * clock (`now`), the poll timer (`after`), and the armed/cycle seams. */
18
+ export interface WBAKeyResolverOptions {
19
+ scheme?: string;
20
+ ttlMs?: number;
21
+ pollIntervalMs?: number;
22
+ /** Throttle for the unknown-thumbprint force-refresh, per directory host (≤0 →
23
+ * 5000). The resolver runs BEFORE the ed25519 check and the host is the
24
+ * caller-supplied Signature-Agent, so this caps the outbound directory GETs an
25
+ * unauthenticated caller can drive by presenting unknown thumbprints. The
26
+ * TTL-cache refresh path is NOT gated by it. */
27
+ syncDebounceMs?: number;
28
+ /** When set, `resolve` fails closed with RevocationUnevaluated for a key whose
29
+ * directory declares a revocation_url but no snapshot has been fetched
30
+ * (unreachable or not host-anchored) — i.e. revocation could not be evaluated.
31
+ * Default false keeps the best-effort behavior (a declared-but-unreachable
32
+ * revocation channel does not block resolution). Set it where a revoked key
33
+ * must never resolve even if the revocation channel is down. */
34
+ requireRevocation?: boolean;
35
+ now?: () => number;
36
+ after?: (ms: number) => Promise<void>;
37
+ onPollArmed?: () => void;
38
+ onPollCycle?: () => void;
39
+ fetch?: FetchLike;
40
+ }
41
+ /** The WBA key face. `resolve` returns the raw Ed25519 public key, `undefined`
42
+ * for an unknown/absent thumbprint (fall-through), and throws KeyRevoked /
43
+ * KeyExpired / DirectoryUnavailable for the distinct fail-closed verdicts. */
44
+ export interface WBAKeyResolver {
45
+ resolve(thumbprint: string, directory: string): Promise<Uint8Array | undefined>;
46
+ run(signal: AbortSignal): Promise<void>;
47
+ /** Whether `keyId` (a thumbprint) is in ANY host's fetched revocation snapshot,
48
+ * INDEPENDENT of WBA directory membership. `resolve` gates a key only when the
49
+ * directory lists it (removal is not revocation), so a key resolved from another
50
+ * source — e.g. a static bootstrap file — is invisible to that path; `revoked`
51
+ * is the fail-closed hook a composite consults to reject a broker-revoked,
52
+ * directory-absent thumbprint. False when no snapshot has been fetched. */
53
+ revoked(keyId: string): boolean;
54
+ }
55
+ /** Construct a WBA resolver with defaults applied. */
56
+ export declare function createWBAKeyResolver(opts?: WBAKeyResolverOptions): WBAKeyResolver;
57
+ /** First window-active, well-formed Ed25519 key (raw 32 bytes) from `directory`,
58
+ * or `null`. Selects an identity's signing key BY DOCUMENT ORDER when its thumbprint
59
+ * is not known ahead of time — complementing WBAKeyResolver, which matches a KNOWN
60
+ * thumbprint. Iterates the directory's keys in document order and returns the FIRST
61
+ * key that passes ALL of: window-active ([not_before, not_after) half-open covers
62
+ * `now` (epoch-ms), both bounds RFC 3339-parseable — a missing/unparseable bound
63
+ * makes the key inactive); `kty === "OKP"` and `crv === "Ed25519"` matched
64
+ * CASE-INSENSITIVELY (a deliberate lenient SDK convention: RFC 7517/8037 specify the
65
+ * exact-case "OKP" / "Ed25519", but all three SDKs accept any case IDENTICALLY so a
66
+ * case-varying directory resolves the SAME key everywhere); and a present `x` that
67
+ * base64url-decodes to exactly 32 bytes. Any key failing any check is skipped and
68
+ * iteration continues.
69
+ *
70
+ * The result is the first window-active key in document order — this SDK's
71
+ * deterministic tie-break, NOT a normative "current" key: the protocol permits
72
+ * several simultaneously-active keys during overlap rotation and defines no "first".
73
+ *
74
+ * `maxScan` is an OPTIONAL document-order bound. `undefined` (the default) scans the
75
+ * WHOLE directory — unbounded, so a valid key at any position is reachable; a silent
76
+ * cap would make a high-position key indistinguishable from "no active key" (a
77
+ * DoS-by-directory-padding footgun). A defined bound caps the scan at
78
+ * `Math.max(0, maxScan)` keys (0 or negative scans none); when a positive bound is
79
+ * exhausted while more keys remain, the exhaustion is logged. Returns `null` when no
80
+ * examined key qualifies (or `directory` is null/undefined). Byte-parity with the
81
+ * Go `ActiveEd25519Key` / Python `active_ed25519_key` oracles.
82
+ *
83
+ * REVOCATION: this selector screens ONLY validity windows and key well-formedness —
84
+ * it does NOT consult any revocation channel. A key that was emergency-revoked but
85
+ * is still window-active in a (possibly CDN-cached) directory WILL be selected. A
86
+ * caller on a VERIFICATION path MUST NOT trust the result until it has screened the
87
+ * selected key's RFC 7638 thumbprint against the resolver's revoked-thumbprint set
88
+ * (`WBAKeyResolver.revoked` / a revocation snapshot); otherwise adopting this
89
+ * selector defeats emergency revocation. Prefer {@link activeEd25519KeyScreened},
90
+ * which folds that screen into selection. This bare form is for non-verification
91
+ * callers only. */
92
+ export declare function activeEd25519Key(directory: WBAFile, now: number, maxScan?: number): Uint8Array | null;
93
+ /** Like {@link activeEd25519Key}, but ALSO returns the selected key's expiry.
94
+ * Runs the IDENTICAL document-order selection and returns `{ key, notAfter }` for
95
+ * the FIRST qualifying key — the raw 32 bytes plus the SAME `not_after` the window
96
+ * check parsed, as epoch-ms (the module's time convention). A downstream caller
97
+ * (e.g. an offer-key cache) clamps its cache TTL to `min(now + ttl, notAfter)` so
98
+ * a cached key never outlives its validity window. `notAfter` is guaranteed
99
+ * finite — selection required it (a key with a missing/unparseable bound is
100
+ * inactive and skipped). Returns `null` when no examined key qualifies.
101
+ * Byte-parity with the Go `ActiveEd25519KeyWithExpiry` / Python
102
+ * `active_ed25519_key_with_expiry` oracles.
103
+ *
104
+ * REVOCATION: like {@link activeEd25519Key}, this bare form does NOT consult
105
+ * revocation — it can return a window-active-but-revoked key. A VERIFICATION path
106
+ * MUST screen the result, or use the revocation-aware
107
+ * {@link activeEd25519KeyWithExpiryScreened} instead. */
108
+ export declare function activeEd25519KeyWithExpiry(directory: WBAFile, now: number, maxScan?: number): {
109
+ key: Uint8Array;
110
+ notAfter: number;
111
+ } | null;
112
+ /** {@link activeEd25519Key} made REVOCATION-AWARE. Runs the same document-order
113
+ * window + well-formedness selection but ALSO skips any key whose RFC 7638
114
+ * thumbprint `revoked` reports true, so a window-active-but-revoked key is never
115
+ * returned. It is the selector a VERIFICATION path adopts — folding the revoked-set
116
+ * screen the bare {@link activeEd25519Key} leaves to the caller into selection
117
+ * itself, so an emergency-revoked key still listed in a CDN-cached directory is
118
+ * passed over for the next active, non-revoked key. `revoked` is REQUIRED: pass a
119
+ * predicate over the resolver's revoked-thumbprint set (e.g. `WBAKeyResolver.revoked`)
120
+ * or, for a caller with no revocation channel, an explicit `() => false` to make the
121
+ * waiver visible. It is ASYNC because screening computes each candidate's RFC 7638
122
+ * thumbprint (the SAME `crypto.subtle` primitive `WBAKeyResolver.resolve` keys on).
123
+ * Returns `null` when no examined, non-revoked key qualifies. */
124
+ export declare function activeEd25519KeyScreened(directory: WBAFile, now: number, revoked: (thumbprint: string) => boolean, maxScan?: number): Promise<Uint8Array | null>;
125
+ /** {@link activeEd25519KeyWithExpiry} made REVOCATION-AWARE (see
126
+ * {@link activeEd25519KeyScreened}): the same selection, plus a skip of any key whose
127
+ * RFC 7638 thumbprint `revoked` reports true, returned with the selected key's
128
+ * `notAfter` for cache-TTL clamping. `revoked` is REQUIRED; ASYNC for the same
129
+ * thumbprint reason. Returns `null` when no examined, non-revoked key qualifies. */
130
+ export declare function activeEd25519KeyWithExpiryScreened(directory: WBAFile, now: number, revoked: (thumbprint: string) => boolean, maxScan?: number): Promise<{
131
+ key: Uint8Array;
132
+ notAfter: number;
133
+ } | null>;
134
+ export {};