@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,506 @@
1
+ // The WBA identity-directory key resolver + revocation poller. Ports
2
+ // sdk/go/helpers/wbakeyresolver.go 1:1: resolve a thumbprint (the RFC 9421 keyid,
3
+ // NEVER a kid) against a WBA directory, enforcing the key's [not_before,
4
+ // not_after) window and the host's revocation snapshot. The directory host that
5
+ // Go threads through ctx (Signature-Agent) is passed EXPLICITLY as the second
6
+ // resolve argument. The gen Zod schemas decode the WBA docs (thumbprint-keyed,
7
+ // need no kid); thumbprint reuses the byte-parity-pinned primitive.
8
+ //
9
+ // The monotonic revocation guard + far-future as_of clamp + revocation priming
10
+ // live in the SHARED refresh routine (refreshRevocationFor), invoked by BOTH the
11
+ // sync directory-fetch path AND the Run poller — never poller-only.
12
+ import { KeyRevocationListSchema, WBAFileSchema, } from "../../../gen/ts/wire/schemas.js";
13
+ import { decodeBase64UrlStrict } from "../src/base64url.js";
14
+ import { hostAnchored } from "../src/hosts.js";
15
+ import { thumbprint } from "../src/thumbprint.js";
16
+ import { DirectoryUnavailable, KeyExpired, KeyRevoked, RevocationUnevaluated, } from "./errors.js";
17
+ import { fetchSoft, fetchStrict, guardedFetch, } from "./http.js";
18
+ /** The single public well-known path a WBA identity directory is served at (Web
19
+ * Bot Auth; the identity half of the identity/commercial split — the commercial
20
+ * overlay stays in /.well-known/fora.json). The one shared copy across the whole SDK. */
21
+ export const WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
22
+ /** Build the full WBA identity-directory URL from a scheme and an already-joined
23
+ * host: `${scheme}://${host}` + {@link WBA_DIRECTORY_PATH}. An empty scheme
24
+ * defaults to https. A PURE string function — the host arrives ALREADY-JOINED (any
25
+ * port-join / IPv6 bracketing is the caller's concern), there is NO env read and NO
26
+ * scheme-in-host detection (those stay consumer glue). It mirrors the sdk/go
27
+ * WBADirectoryURL oracle byte-for-byte, locked by the tri-replayed
28
+ * wba-url-vectors.json corpus. */
29
+ export function wbaDirectoryURL(scheme, host) {
30
+ const s = scheme === "" ? "https" : scheme;
31
+ return `${s}://${host}${WBA_DIRECTORY_PATH}`;
32
+ }
33
+ const DEFAULT_TTL_MS = 3_600_000; // 1 hour
34
+ const DEFAULT_POLL_MS = 300_000; // 300 s
35
+ const DEFAULT_SYNC_DEBOUNCE_MS = 5_000; // unknown-thumbprint force-refresh throttle
36
+ const AS_OF_SKEW_MS = 300_000; // far-future as_of clamp ceiling
37
+ const ED25519_PUBLIC_KEY_BYTES = 32;
38
+ /** Construct a WBA resolver with defaults applied. */
39
+ export function createWBAKeyResolver(opts = {}) {
40
+ return new WBAResolverImpl(opts);
41
+ }
42
+ class WBAResolverImpl {
43
+ scheme;
44
+ ttlMs;
45
+ pollMs;
46
+ syncDebounceMs;
47
+ requireRevocation;
48
+ now;
49
+ after;
50
+ onPollArmed;
51
+ onPollCycle;
52
+ fetchFn;
53
+ dirCache = new Map();
54
+ revSnapshots = new Map();
55
+ // lastSync throttles the unknown-thumbprint force-refresh to one per debounce
56
+ // window per host (anti-amplification); inflight coalesces a concurrent burst
57
+ // of directory fetches for one host to a single in-flight GET (singleflight).
58
+ lastSync = new Map();
59
+ inflight = new Map();
60
+ constructor(opts) {
61
+ this.scheme = opts.scheme && opts.scheme !== "" ? opts.scheme : "https";
62
+ this.ttlMs = opts.ttlMs && opts.ttlMs > 0 ? opts.ttlMs : DEFAULT_TTL_MS;
63
+ this.pollMs =
64
+ opts.pollIntervalMs && opts.pollIntervalMs > 0
65
+ ? opts.pollIntervalMs
66
+ : DEFAULT_POLL_MS;
67
+ this.syncDebounceMs =
68
+ opts.syncDebounceMs && opts.syncDebounceMs > 0
69
+ ? opts.syncDebounceMs
70
+ : DEFAULT_SYNC_DEBOUNCE_MS;
71
+ this.requireRevocation = opts.requireRevocation ?? false;
72
+ this.now = opts.now ?? Date.now;
73
+ this.after =
74
+ opts.after ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
75
+ this.onPollArmed = opts.onPollArmed;
76
+ this.onPollCycle = opts.onPollCycle;
77
+ // The WBA directory host comes from the request-supplied Signature-Agent and
78
+ // is fetched pre-auth, so the default is SSRF-guarded (matches the Go oracle).
79
+ this.fetchFn = opts.fetch ?? guardedFetch;
80
+ }
81
+ async resolve(keyID, directory) {
82
+ if (directory === "" || keyID === "")
83
+ return undefined;
84
+ const parsed = directoryBase(directory, this.scheme);
85
+ // A malformed Signature-Agent cannot name a directory: fall-through
86
+ // (undefined), NOT a fail-closed DirectoryUnavailable halt.
87
+ if (!parsed)
88
+ return undefined;
89
+ const { base, host } = parsed;
90
+ let file = await this.wbaFile(base, host);
91
+ let key = await keyByThumbprint(file, keyID);
92
+ if (!key) {
93
+ // The self-heal force-refresh below bypasses the TTL cache — the lever an
94
+ // unauthenticated caller pulls once per unknown thumbprint. Gate it to one
95
+ // fetch per debounce window per host: outside the window the thumbprint is
96
+ // reported unknown WITHOUT a fetch (removal-vs-rotation self-heal still
97
+ // works — the first unknown lookup in each window refetches).
98
+ if (!this.beginSync(host))
99
+ return undefined;
100
+ file = await this.syncRefresh(base, host);
101
+ key = await keyByThumbprint(file, keyID);
102
+ if (!key)
103
+ return undefined; // removal is fall-through, never revocation
104
+ }
105
+ if (this.isRevoked(host, keyID))
106
+ throw new KeyRevoked(`keyid=${keyID}`);
107
+ // Fail closed on unevaluated revocation: the directory advertises a
108
+ // revocation channel but we hold no snapshot for it, so we cannot assert the
109
+ // key is un-revoked. Only enforced when the caller opted into
110
+ // requireRevocation — a present-but-empty snapshot (revocation evaluated,
111
+ // nothing revoked) is DISTINCT from an absent one and passes.
112
+ if (this.requireRevocation &&
113
+ file.revocation_url &&
114
+ !this.revocationSnapshotPresent(host)) {
115
+ throw new RevocationUnevaluated(`keyid=${keyID} host=${host}`);
116
+ }
117
+ if (!keyActiveAt(key, this.now()))
118
+ throw new KeyExpired(`keyid=${keyID}`);
119
+ return publicKeyOf(key);
120
+ }
121
+ async run(signal) {
122
+ while (!signal.aborted) {
123
+ const timer = this.after(this.jitteredInterval());
124
+ notify(this.onPollArmed);
125
+ await Promise.race([timer, whenAborted(signal)]);
126
+ if (signal.aborted)
127
+ return;
128
+ await this.refreshAllRevocations();
129
+ notify(this.onPollCycle);
130
+ }
131
+ }
132
+ async wbaFile(base, host) {
133
+ const entry = this.dirCache.get(host);
134
+ if (entry && this.now() < entry.exp)
135
+ return entry.file;
136
+ return this.syncRefresh(base, host);
137
+ }
138
+ // beginSync reports whether an unknown-thumbprint force-refresh for host may
139
+ // proceed now, recording the attempt when it does — at most one per debounce
140
+ // window per host, so N unknown thumbprints for one host drive ONE fetch, not N.
141
+ beginSync(host) {
142
+ const now = this.now();
143
+ const last = this.lastSync.get(host);
144
+ if (last !== undefined && now - last < this.syncDebounceMs)
145
+ return false;
146
+ this.lastSync.set(host, now);
147
+ return true;
148
+ }
149
+ // syncRefresh force-fetches host's directory, bypassing the TTL cache. A
150
+ // concurrent burst for the same host coalesces to ONE in-flight GET: the first
151
+ // caller records the promise in `inflight` (synchronously, before any await),
152
+ // so peers awaiting the same host share it rather than each issuing a fetch.
153
+ syncRefresh(base, host) {
154
+ const existing = this.inflight.get(host);
155
+ if (existing)
156
+ return existing;
157
+ const pending = this.doRefresh(base, host).finally(() => {
158
+ this.inflight.delete(host);
159
+ });
160
+ this.inflight.set(host, pending);
161
+ return pending;
162
+ }
163
+ async doRefresh(base, host) {
164
+ const file = await this.fetchDirectory(base);
165
+ this.dirCache.set(host, { file, exp: this.now() + this.ttlMs });
166
+ await this.refreshRevocationFor(host, file);
167
+ return file;
168
+ }
169
+ async fetchDirectory(base) {
170
+ const body = await fetchStrict(this.fetchFn, base + WBA_DIRECTORY_PATH);
171
+ try {
172
+ return WBAFileSchema.parse(JSON.parse(body));
173
+ }
174
+ catch (err) {
175
+ throw new DirectoryUnavailable("wba directory decode", { cause: err });
176
+ }
177
+ }
178
+ isRevoked(host, thumbprintKey) {
179
+ return this.revSnapshots.get(host)?.thumbprints.has(thumbprintKey) ?? false;
180
+ }
181
+ // revocationSnapshotPresent reports whether a revocation snapshot has ever been
182
+ // fetched for host. DISTINCT from "the snapshot is empty": an empty snapshot
183
+ // means revocation WAS evaluated and nothing is revoked, whereas an absent
184
+ // snapshot means revocation was never evaluated (revocation_url unreachable /
185
+ // not host-anchored / not yet polled).
186
+ revocationSnapshotPresent(host) {
187
+ return this.revSnapshots.has(host);
188
+ }
189
+ revoked(keyId) {
190
+ if (keyId === "")
191
+ return false;
192
+ for (const set of this.revSnapshots.values()) {
193
+ if (set.thumbprints.has(keyId))
194
+ return true;
195
+ }
196
+ return false;
197
+ }
198
+ // Best-effort: a missing/cross-host/failed/undecodable revocation_url leaves the
199
+ // prior snapshot in place. The monotonic guard + as_of clamp live here so BOTH
200
+ // the sync path and the poller apply them identically.
201
+ async refreshRevocationFor(host, file) {
202
+ const revURL = file.revocation_url;
203
+ if (!revURL || !wbaHostAnchored(host, revURL))
204
+ return;
205
+ const body = await fetchSoft(this.fetchFn, revURL);
206
+ if (body === undefined)
207
+ return;
208
+ let list;
209
+ try {
210
+ list = KeyRevocationListSchema.parse(JSON.parse(body));
211
+ }
212
+ catch {
213
+ return;
214
+ }
215
+ this.applyRevocation(host, list);
216
+ }
217
+ applyRevocation(host, list) {
218
+ let asOf = list.as_of ? Date.parse(list.as_of) : 0;
219
+ if (Number.isNaN(asOf))
220
+ asOf = 0;
221
+ const ceiling = this.now() + AS_OF_SKEW_MS;
222
+ if (asOf > ceiling)
223
+ asOf = ceiling; // clamp a far-future baseline (first-poll integrity)
224
+ const next = { thumbprints: new Set(list.revoked ?? []), asOf };
225
+ const prev = this.revSnapshots.get(host);
226
+ // Monotonic guard: a snapshot whose as_of is not STRICTLY newer than the one
227
+ // held is a rollback and is ignored — a revoked thumbprint is never silently
228
+ // un-revoked. The first seed is always accepted.
229
+ if (prev !== undefined && asOf <= prev.asOf)
230
+ return;
231
+ this.revSnapshots.set(host, next);
232
+ }
233
+ async refreshAllRevocations() {
234
+ const entries = [...this.dirCache.entries()];
235
+ for (const [host, entry] of entries) {
236
+ await this.refreshRevocationFor(host, entry.file);
237
+ }
238
+ }
239
+ jitteredInterval() {
240
+ const delta = Math.floor(this.pollMs / 10);
241
+ if (delta <= 0)
242
+ return this.pollMs;
243
+ return this.pollMs + Math.floor(Math.random() * (2 * delta + 1)) - delta;
244
+ }
245
+ }
246
+ function notify(hook) {
247
+ if (hook)
248
+ hook();
249
+ }
250
+ function whenAborted(signal) {
251
+ if (signal.aborted)
252
+ return Promise.resolve();
253
+ return new Promise((resolve) => {
254
+ signal.addEventListener("abort", () => resolve(), { once: true });
255
+ });
256
+ }
257
+ /** Normalize a Signature-Agent value (bare host, host:port, or full URL) into a
258
+ * scheme://host base and its host key, or `undefined` when it names no host. */
259
+ function directoryBase(ref, scheme) {
260
+ const withScheme = ref.includes("://") ? ref : `${scheme}://${ref}`;
261
+ let url;
262
+ try {
263
+ url = new URL(withScheme);
264
+ }
265
+ catch {
266
+ return undefined;
267
+ }
268
+ if (url.host === "")
269
+ return undefined;
270
+ return { base: `${url.protocol}//${url.host}`, host: url.host };
271
+ }
272
+ /** Whether `candidate` is anchored to `anchor` — the same host and port, or a
273
+ * subdomain of that host on that port. An SSRF guard: a cross-host
274
+ * revocation_url is skipped, and the key stays valid.
275
+ *
276
+ * The predicate itself is the shared hostAnchored, which is the ONE place the
277
+ * rule is written; this wrapper exists for the two things that are local to WBA.
278
+ * It answers a bool rather than throwing, because a directory that names an
279
+ * unparseable revocation_url is simply not anchored and its caller logs a skip.
280
+ * And it requires an ABSOLUTE reference: the shared predicate reads a schemeless
281
+ * value as https, which is right for an exchange domain and wrong here, where the
282
+ * value is a URL a directory published rather than a domain it named.
283
+ *
284
+ * This used to be a private near-namesake that compared `URL.host`. It agreed on
285
+ * ordinary hosts and diverged on the ones that matter: a default port folded away
286
+ * at parse time cannot borrow a scheme from the other side, so an anchor of
287
+ * "a.example:80" and a candidate of "http://a.example:80" reached two different
288
+ * answers, and a directory that spelled its port out stopped anchoring its own
289
+ * revocation URL. A skipped revocation poll leaves a revoked key resolving. */
290
+ function wbaHostAnchored(anchor, candidate) {
291
+ if (!candidate.includes("://"))
292
+ return false;
293
+ try {
294
+ return hostAnchored(anchor, candidate);
295
+ }
296
+ catch {
297
+ return false;
298
+ }
299
+ }
300
+ /** The key in `file` whose RFC 7638 thumbprint equals `keyID` (locally computed),
301
+ * or `undefined`. Keys with an undecodable `x` are skipped. */
302
+ async function keyByThumbprint(file, keyID) {
303
+ for (const key of file.keys ?? []) {
304
+ const pub = publicKeyOfSafe(key);
305
+ if (!pub)
306
+ continue;
307
+ if ((await thumbprint(pub)) === keyID)
308
+ return key;
309
+ }
310
+ return undefined;
311
+ }
312
+ /** Decode `key`'s Ed25519 public key, or `undefined` on any field/length fault. */
313
+ function publicKeyOfSafe(key) {
314
+ // kty/crv are matched CASE-INSENSITIVELY — a deliberate lenient SDK convention
315
+ // (RFC 7517/8037 specify the exact-case "OKP" / "Ed25519"); the three SDKs accept
316
+ // any case identically so a case-varying directory resolves the SAME key.
317
+ if (key.kty.toUpperCase() !== "OKP" || key.crv.toLowerCase() !== "ed25519")
318
+ return undefined;
319
+ // JWK OKP `x` is UNPADDED base64url (RFC 8037); reject padding / the standard
320
+ // alphabet so this matches Go's base64.RawURLEncoding and the tri-language
321
+ // selector picks the SAME key on a malformed-`x` directory.
322
+ const raw = decodeBase64UrlStrict(key.x);
323
+ if (!raw || raw.length !== ED25519_PUBLIC_KEY_BYTES)
324
+ return undefined;
325
+ return raw;
326
+ }
327
+ /** Decode `key`'s public key; throws only for a key that already matched by
328
+ * thumbprint (so the decode is known-good). */
329
+ function publicKeyOf(key) {
330
+ const raw = publicKeyOfSafe(key);
331
+ if (!raw)
332
+ throw new DirectoryUnavailable("wba jwk decode");
333
+ return raw;
334
+ }
335
+ /** First window-active, well-formed Ed25519 key (raw 32 bytes) from `directory`,
336
+ * or `null`. Selects an identity's signing key BY DOCUMENT ORDER when its thumbprint
337
+ * is not known ahead of time — complementing WBAKeyResolver, which matches a KNOWN
338
+ * thumbprint. Iterates the directory's keys in document order and returns the FIRST
339
+ * key that passes ALL of: window-active ([not_before, not_after) half-open covers
340
+ * `now` (epoch-ms), both bounds RFC 3339-parseable — a missing/unparseable bound
341
+ * makes the key inactive); `kty === "OKP"` and `crv === "Ed25519"` matched
342
+ * CASE-INSENSITIVELY (a deliberate lenient SDK convention: RFC 7517/8037 specify the
343
+ * exact-case "OKP" / "Ed25519", but all three SDKs accept any case IDENTICALLY so a
344
+ * case-varying directory resolves the SAME key everywhere); and a present `x` that
345
+ * base64url-decodes to exactly 32 bytes. Any key failing any check is skipped and
346
+ * iteration continues.
347
+ *
348
+ * The result is the first window-active key in document order — this SDK's
349
+ * deterministic tie-break, NOT a normative "current" key: the protocol permits
350
+ * several simultaneously-active keys during overlap rotation and defines no "first".
351
+ *
352
+ * `maxScan` is an OPTIONAL document-order bound. `undefined` (the default) scans the
353
+ * WHOLE directory — unbounded, so a valid key at any position is reachable; a silent
354
+ * cap would make a high-position key indistinguishable from "no active key" (a
355
+ * DoS-by-directory-padding footgun). A defined bound caps the scan at
356
+ * `Math.max(0, maxScan)` keys (0 or negative scans none); when a positive bound is
357
+ * exhausted while more keys remain, the exhaustion is logged. Returns `null` when no
358
+ * examined key qualifies (or `directory` is null/undefined). Byte-parity with the
359
+ * Go `ActiveEd25519Key` / Python `active_ed25519_key` oracles.
360
+ *
361
+ * REVOCATION: this selector screens ONLY validity windows and key well-formedness —
362
+ * it does NOT consult any revocation channel. A key that was emergency-revoked but
363
+ * is still window-active in a (possibly CDN-cached) directory WILL be selected. A
364
+ * caller on a VERIFICATION path MUST NOT trust the result until it has screened the
365
+ * selected key's RFC 7638 thumbprint against the resolver's revoked-thumbprint set
366
+ * (`WBAKeyResolver.revoked` / a revocation snapshot); otherwise adopting this
367
+ * selector defeats emergency revocation. Prefer {@link activeEd25519KeyScreened},
368
+ * which folds that screen into selection. This bare form is for non-verification
369
+ * callers only. */
370
+ export function activeEd25519Key(directory, now, maxScan) {
371
+ return selectActiveEd25519Key(directory, now, maxScan)?.key ?? null;
372
+ }
373
+ /** Like {@link activeEd25519Key}, but ALSO returns the selected key's expiry.
374
+ * Runs the IDENTICAL document-order selection and returns `{ key, notAfter }` for
375
+ * the FIRST qualifying key — the raw 32 bytes plus the SAME `not_after` the window
376
+ * check parsed, as epoch-ms (the module's time convention). A downstream caller
377
+ * (e.g. an offer-key cache) clamps its cache TTL to `min(now + ttl, notAfter)` so
378
+ * a cached key never outlives its validity window. `notAfter` is guaranteed
379
+ * finite — selection required it (a key with a missing/unparseable bound is
380
+ * inactive and skipped). Returns `null` when no examined key qualifies.
381
+ * Byte-parity with the Go `ActiveEd25519KeyWithExpiry` / Python
382
+ * `active_ed25519_key_with_expiry` oracles.
383
+ *
384
+ * REVOCATION: like {@link activeEd25519Key}, this bare form does NOT consult
385
+ * revocation — it can return a window-active-but-revoked key. A VERIFICATION path
386
+ * MUST screen the result, or use the revocation-aware
387
+ * {@link activeEd25519KeyWithExpiryScreened} instead. */
388
+ export function activeEd25519KeyWithExpiry(directory, now, maxScan) {
389
+ return selectActiveEd25519Key(directory, now, maxScan);
390
+ }
391
+ /** {@link activeEd25519Key} made REVOCATION-AWARE. Runs the same document-order
392
+ * window + well-formedness selection but ALSO skips any key whose RFC 7638
393
+ * thumbprint `revoked` reports true, so a window-active-but-revoked key is never
394
+ * returned. It is the selector a VERIFICATION path adopts — folding the revoked-set
395
+ * screen the bare {@link activeEd25519Key} leaves to the caller into selection
396
+ * itself, so an emergency-revoked key still listed in a CDN-cached directory is
397
+ * passed over for the next active, non-revoked key. `revoked` is REQUIRED: pass a
398
+ * predicate over the resolver's revoked-thumbprint set (e.g. `WBAKeyResolver.revoked`)
399
+ * or, for a caller with no revocation channel, an explicit `() => false` to make the
400
+ * waiver visible. It is ASYNC because screening computes each candidate's RFC 7638
401
+ * thumbprint (the SAME `crypto.subtle` primitive `WBAKeyResolver.resolve` keys on).
402
+ * Returns `null` when no examined, non-revoked key qualifies. */
403
+ export async function activeEd25519KeyScreened(directory, now, revoked, maxScan) {
404
+ return ((await selectActiveEd25519KeyScreened(directory, now, revoked, maxScan))
405
+ ?.key ?? null);
406
+ }
407
+ /** {@link activeEd25519KeyWithExpiry} made REVOCATION-AWARE (see
408
+ * {@link activeEd25519KeyScreened}): the same selection, plus a skip of any key whose
409
+ * RFC 7638 thumbprint `revoked` reports true, returned with the selected key's
410
+ * `notAfter` for cache-TTL clamping. `revoked` is REQUIRED; ASYNC for the same
411
+ * thumbprint reason. Returns `null` when no examined, non-revoked key qualifies. */
412
+ export async function activeEd25519KeyWithExpiryScreened(directory, now, revoked, maxScan) {
413
+ return selectActiveEd25519KeyScreened(directory, now, revoked, maxScan);
414
+ }
415
+ /** Shared REVOCATION-AWARE selector behind the two screened faces: the FIRST
416
+ * window-active, well-formed, non-revoked Ed25519 key in document order (cap
417
+ * `maxScan`), as `{ key, notAfter }`, or `null`. Mirrors {@link selectActiveEd25519Key}
418
+ * with the added thumbprint-revocation skip; async because the thumbprint is. */
419
+ async function selectActiveEd25519KeyScreened(directory, now, revoked, maxScan) {
420
+ const { scanned, keys } = scanWindow(directory, maxScan);
421
+ for (const key of scanned) {
422
+ if (!keyActiveAt(key, now))
423
+ continue;
424
+ const raw = publicKeyOfSafe(key);
425
+ if (!raw)
426
+ continue;
427
+ // Revocation screen: skip a window-active key whose thumbprint is revoked, so an
428
+ // emergency-revoked key still listed in a CDN-cached directory is never selected.
429
+ if (revoked(await thumbprint(raw)))
430
+ continue;
431
+ const notAfter = parseRfc3339Ms(key.not_after);
432
+ if (Number.isNaN(notAfter))
433
+ continue; // unreachable: keyActiveAt required a parseable bound
434
+ return { key: raw, notAfter };
435
+ }
436
+ logScanExhaustion(maxScan, keys.length);
437
+ return null;
438
+ }
439
+ /** Shared selector behind the two active-key faces: the FIRST window-active,
440
+ * well-formed Ed25519 key in document order (UNBOUNDED by default; `maxScan`
441
+ * optionally caps it), as `{ key, notAfter }` (notAfter epoch-ms), or `null`.
442
+ * `activeEd25519Key` drops the expiry; `activeEd25519KeyWithExpiry` returns it.
443
+ * `notAfter` reuses the SAME `Date.parse` {@link keyActiveAt} used, so the two faces
444
+ * never disagree on the selected key. */
445
+ function selectActiveEd25519Key(directory, now, maxScan) {
446
+ const { scanned, keys } = scanWindow(directory, maxScan);
447
+ for (const key of scanned) {
448
+ if (!keyActiveAt(key, now))
449
+ continue;
450
+ const raw = publicKeyOfSafe(key);
451
+ if (!raw)
452
+ continue;
453
+ // not_after is guaranteed finite: keyActiveAt above rejects any key whose
454
+ // window bounds do not parse, so the selected key always has one.
455
+ const notAfter = parseRfc3339Ms(key.not_after);
456
+ if (Number.isNaN(notAfter))
457
+ continue; // unreachable: keyActiveAt required a parseable bound
458
+ return { key: raw, notAfter };
459
+ }
460
+ logScanExhaustion(maxScan, keys.length);
461
+ return null;
462
+ }
463
+ /** Resolve the document-order scan window shared by both selectors. A null/undefined
464
+ * `directory` is guarded (empty scan, never a throw), matching Go's nil guard.
465
+ * `maxScan` undefined scans EVERY key (unbounded); a defined bound caps the scan at
466
+ * `Math.max(0, maxScan)` keys — 0 or negative scans none, matching Go's clamp-to-zero
467
+ * and Python's `keys[: max(0, n)]`. Returns the full key list too so the caller can
468
+ * detect bounded exhaustion. */
469
+ function scanWindow(directory, maxScan) {
470
+ const keys = directory?.keys ?? [];
471
+ const scanned = maxScan === undefined ? keys : keys.slice(0, Math.max(0, maxScan));
472
+ return { scanned, keys };
473
+ }
474
+ /** Bounded-scan exhaustion signal: a positive explicit bound was exhausted while the
475
+ * directory held MORE keys than the bound, so a valid key beyond the cap is
476
+ * unreachable. Warn rather than let a bounded miss masquerade as a genuine "no active
477
+ * key" — the DoS-by-padding footgun the unbounded default avoids. */
478
+ function logScanExhaustion(maxScan, totalKeys) {
479
+ if (maxScan !== undefined && maxScan > 0 && maxScan < totalKeys) {
480
+ console.warn(`active-key scan hit explicit max_scan bound without selecting a key; a valid key beyond the cap is unreachable (max_scan=${maxScan}, total_keys=${totalKeys})`);
481
+ }
482
+ }
483
+ /** Parse an RFC 3339 instant to epoch-ms, REQUIRING an explicit UTC offset
484
+ * (`Z`/`z` or `±HH:MM`). An offset-less string — which bare `Date.parse` would
485
+ * silently interpret in the host's LOCAL zone — returns `NaN`, so the key is
486
+ * treated as inactive. This keeps parity with Go's time.Parse(time.RFC3339) and
487
+ * Python's offset-required `_parse_rfc3339`, both of which reject an offset-less
488
+ * bound rather than guessing a zone. */
489
+ function parseRfc3339Ms(value) {
490
+ if (!value)
491
+ return Number.NaN;
492
+ // RFC 3339 mandates a time-offset after the time component: 'Z'/'z' or ±HH:MM.
493
+ if (!/([Zz]|[+-]\d{2}:\d{2})$/.test(value))
494
+ return Number.NaN;
495
+ return Date.parse(value);
496
+ }
497
+ /** Whether `now` (epoch-ms) is inside `key`'s [not_before, not_after) half-open
498
+ * window. A missing/unparseable/offset-less bound makes the key inactive —
499
+ * validity must be explicit. */
500
+ function keyActiveAt(key, now) {
501
+ const notBefore = parseRfc3339Ms(key.not_before);
502
+ const notAfter = parseRfc3339Ms(key.not_after);
503
+ if (Number.isNaN(notBefore) || Number.isNaN(notAfter))
504
+ return false;
505
+ return now >= notBefore && now < notAfter;
506
+ }
@@ -0,0 +1,28 @@
1
+ import { type FetchLike } from "./http.ts";
2
+ /** Options for the well-known fetching resolvers. `now` is epoch-ms; tests inject
3
+ * it for deterministic TTL expiry. `fetch` defaults to the global fetch. */
4
+ export interface WellKnownOptions {
5
+ ttlMs?: number;
6
+ now?: () => number;
7
+ fetch?: FetchLike;
8
+ /** Trust allowlist: a keyid (key resolver) or host (endpoint resolver) the
9
+ * allowlist rejects never reaches the network. */
10
+ allow?: (id: string) => boolean;
11
+ }
12
+ /** Options for the endpoint resolver; adds the URL scheme used to build
13
+ * `{scheme}://{host}/.well-known/fora.json` (tests inject "http"). */
14
+ export interface EndpointOptions extends WellKnownOptions {
15
+ scheme?: string;
16
+ }
17
+ /** The well-known JWKS key face. */
18
+ export interface WellKnownKeyResolver {
19
+ resolve(keyid: string): Promise<Uint8Array | undefined>;
20
+ }
21
+ /** The host-keyed endpoint face. */
22
+ export interface WellKnownEndpointResolver {
23
+ resolveEndpoint(host: string): Promise<string>;
24
+ }
25
+ /** Lazily fetch the JWKS at `url`, cache resolved keys with a TTL. */
26
+ export declare function createWellKnownKeyResolver(url: string, opts?: WellKnownOptions): WellKnownKeyResolver;
27
+ /** Host-keyed resolver of an Exchange domain → its self-advertised endpoint. */
28
+ export declare function createWellKnownEndpointResolver(opts?: EndpointOptions): WellKnownEndpointResolver;