@fora-protocol/sdk 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- package/sdk/ts/src/wire.js +114 -0
|
@@ -0,0 +1,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;
|