@request-seal/core 0.2.0 → 0.3.0
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/CHANGELOG.md +34 -1
- package/README.md +10 -9
- package/dist/adapters/node-http/index.d.ts +8 -1
- package/dist/adapters/node-http/index.js +21 -5
- package/dist/constants.d.ts +4 -4
- package/dist/constants.js +2 -2
- package/dist/crypto/algorithm.d.ts +4 -0
- package/dist/crypto/algorithm.js +12 -0
- package/dist/discovery/body.d.ts +12 -2
- package/dist/discovery/body.js +33 -11
- package/dist/discovery/cache.d.ts +13 -3
- package/dist/discovery/cache.js +103 -39
- package/dist/discovery/headers.d.ts +2 -0
- package/dist/discovery/headers.js +27 -1
- package/dist/discovery/index.d.ts +9 -2
- package/dist/discovery/index.js +22 -6
- package/dist/discovery/node.d.ts +6 -2
- package/dist/discovery/node.js +124 -18
- package/dist/discovery/proof.js +2 -2
- package/dist/discovery/snapshot.js +3 -0
- package/dist/errors/parity.d.ts +29 -3
- package/dist/errors/parity.js +32 -3
- package/dist/errors/types.d.ts +4 -2
- package/dist/errors/types.js +14 -2
- package/dist/jose/nested.js +23 -1
- package/dist/message/index.js +2 -1
- package/dist/policy.d.ts +13 -1
- package/dist/policy.js +13 -1
- package/dist/quorum/evaluation.d.ts +1 -1
- package/dist/quorum/evaluation.js +18 -4
- package/dist/replay/postgres.d.ts +2 -0
- package/dist/replay/postgres.js +6 -0
- package/dist/sign.d.ts +22 -1
- package/dist/sign.js +25 -4
- package/dist/signature-fields.d.ts +5 -1
- package/dist/signature-fields.js +9 -1
- package/dist/verify-quorum.js +29 -8
- package/dist/verify.d.ts +19 -1
- package/dist/verify.js +32 -5
- package/dist/web-bot-auth/verify.js +2 -2
- package/package.json +13 -1
package/dist/discovery/cache.js
CHANGED
|
@@ -6,6 +6,15 @@ import { algorithmList, isKeySet, keySet, lookup, } from "./body.js";
|
|
|
6
6
|
import { algorithms } from "../crypto/algorithm.js";
|
|
7
7
|
import { ensure, unwrap, failure, options, clockValue, systemClock, inspectable, } from "./support.js";
|
|
8
8
|
const caches = new WeakSet();
|
|
9
|
+
const decided = new WeakMap();
|
|
10
|
+
/**
|
|
11
|
+
* Internal to the resolver bridge, not exported from the package: resolve with the
|
|
12
|
+
* cache's own clock for fetching, storing, negative caching, and entry freshness, and
|
|
13
|
+
* decide the key's freshness on `decision`.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveOnClock(cache, s, kid, input, decision) {
|
|
16
|
+
return decided.get(cache)(s, kid, input, decision);
|
|
17
|
+
}
|
|
9
18
|
/** Check the private brand of a discovery cache. */
|
|
10
19
|
export function isDiscoveryCache(v) {
|
|
11
20
|
return !!v && typeof v === "object" && caches.has(v);
|
|
@@ -49,6 +58,31 @@ export function createCache(input = {}) {
|
|
|
49
58
|
}
|
|
50
59
|
function store(e, result, clock, snapshot = false) {
|
|
51
60
|
const now = clockValue(clock);
|
|
61
|
+
if (result.ok && "notModified" in result.value && result.value.notModified === true) {
|
|
62
|
+
// RFC 9111 Section 4.3.4: the stored representation stays; the 304's
|
|
63
|
+
// own freshness directives and validator replace the stored ones. The
|
|
64
|
+
// set never claims freshness beyond its earliest still-live key.
|
|
65
|
+
const current = e.set;
|
|
66
|
+
if (!current ||
|
|
67
|
+
sourceIdentity(current.source) !== sourceIdentity(e.source))
|
|
68
|
+
return err(discoveryError("source_unavailable"));
|
|
69
|
+
const fresh = result.value;
|
|
70
|
+
const set = filter(current, e);
|
|
71
|
+
const earliest = Object.values(set.keys).reduce((bound, key) => Math.min(bound, key.expiresAt), Infinity);
|
|
72
|
+
const merged = keySet({
|
|
73
|
+
...set,
|
|
74
|
+
etag: fresh.etag,
|
|
75
|
+
fetchedAt: fresh.fetchedAt,
|
|
76
|
+
expiresAt: Math.min(fresh.expiresAt, earliest),
|
|
77
|
+
});
|
|
78
|
+
e.fetchedAtHighWater = Math.max(e.fetchedAtHighWater ?? merged.fetchedAt, merged.fetchedAt);
|
|
79
|
+
delete e.failureUntil;
|
|
80
|
+
if (merged.expiresAt > now)
|
|
81
|
+
e.set = merged;
|
|
82
|
+
else
|
|
83
|
+
delete e.set;
|
|
84
|
+
return ok(merged);
|
|
85
|
+
}
|
|
52
86
|
if (result.ok) {
|
|
53
87
|
ensure(isKeySet(result.value) &&
|
|
54
88
|
sourceIdentity(result.value.source) === sourceIdentity(e.source), "source_unavailable");
|
|
@@ -84,13 +118,36 @@ export function createCache(input = {}) {
|
|
|
84
118
|
signal: controller.signal,
|
|
85
119
|
}), "invalid_options");
|
|
86
120
|
const generation = e.generation;
|
|
121
|
+
// A stored validator makes the revalidation conditional: an unchanged
|
|
122
|
+
// source answers 304 and its key set stays put. A CIMD document is not
|
|
123
|
+
// the key set itself — its 304 says nothing about the nested JWKS it
|
|
124
|
+
// names — so CIMD sources always revalidate unconditionally.
|
|
125
|
+
let validators;
|
|
126
|
+
const stored = e.set;
|
|
127
|
+
if (stored && stored.etag !== null && e.source.type !== "cimd") {
|
|
128
|
+
const now = clockValue(clock);
|
|
129
|
+
// A 304 keeps the stored keys, so it can only renew a set whose
|
|
130
|
+
// every key is still inside its own expiry; any dead key forces a
|
|
131
|
+
// full fetch.
|
|
132
|
+
const alive = Object.keys(stored.keys).length > 0 &&
|
|
133
|
+
Object.values(stored.keys).every((key) => now < key.expiresAt);
|
|
134
|
+
if (alive)
|
|
135
|
+
validators = Object.freeze({
|
|
136
|
+
etag: stored.etag,
|
|
137
|
+
lifetime: Math.max(stored.expiresAt - stored.fetchedAt, 0),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
87
140
|
flight = {
|
|
88
141
|
controller,
|
|
89
142
|
waiters: 0,
|
|
90
143
|
work: Promise.resolve().then(async () => {
|
|
91
144
|
let result;
|
|
92
145
|
try {
|
|
93
|
-
const r = await run(c, () => fetcher(e.source, {
|
|
146
|
+
const r = await run(c, () => fetcher(e.source, {
|
|
147
|
+
clock,
|
|
148
|
+
context: c,
|
|
149
|
+
...(validators ? { validators } : {}),
|
|
150
|
+
}));
|
|
94
151
|
result =
|
|
95
152
|
!r.ok && r.error.kind !== "discovery"
|
|
96
153
|
? err(discoveryError(r.error.reason === "deadline_exceeded"
|
|
@@ -136,6 +193,48 @@ export function createCache(input = {}) {
|
|
|
136
193
|
}
|
|
137
194
|
}
|
|
138
195
|
}
|
|
196
|
+
async function resolveWith(s, kid, input, decision) {
|
|
197
|
+
try {
|
|
198
|
+
ensure(isDiscoverySource(s), "invalid_source");
|
|
199
|
+
const a = options(input, ["algorithms", "clock", "context", "timeoutMs"]);
|
|
200
|
+
const choices = algorithmList(Object.hasOwn(a, "algorithms")
|
|
201
|
+
? a.algorithms
|
|
202
|
+
: unwrap(algorithms(), "invalid_options"));
|
|
203
|
+
ensure(keyIdValid(s, kid), "unknown_key");
|
|
204
|
+
ensure(s.keyId === "directory" || !s.revoked.includes(kid), "revoked_key");
|
|
205
|
+
// With a decision clock (the resolver bridge), the cache's own clock fetches, stores,
|
|
206
|
+
// and judges entry freshness; the decision clock judges only the key returned.
|
|
207
|
+
const selectedClock = decision === undefined && Object.hasOwn(a, "clock") ? a.clock : clockFn;
|
|
208
|
+
const now = clockValue(selectedClock);
|
|
209
|
+
const decisionNow = decision === undefined ? now : clockValue(decision);
|
|
210
|
+
ensure(!Object.hasOwn(a, "context") || isContext(a.context), "invalid_options");
|
|
211
|
+
let context = a.context;
|
|
212
|
+
if (Object.hasOwn(a, "timeoutMs")) {
|
|
213
|
+
ensure(Number.isSafeInteger(a.timeoutMs) &&
|
|
214
|
+
a.timeoutMs >= 1 &&
|
|
215
|
+
a.timeoutMs <= 300000, "invalid_options");
|
|
216
|
+
context = unwrap(withDeadline({
|
|
217
|
+
timeoutMs: Math.min(a.timeoutMs, s.timeoutMs),
|
|
218
|
+
...(context ? { parent: context } : {}),
|
|
219
|
+
}), "invalid_options");
|
|
220
|
+
}
|
|
221
|
+
ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
|
|
222
|
+
const e = enroll(s);
|
|
223
|
+
ensure(e.source.keyId === "directory" ||
|
|
224
|
+
(!e.removed.has(kid) && !e.source.revoked.includes(kid)), "revoked_key");
|
|
225
|
+
if (e.set && now < e.set.expiresAt)
|
|
226
|
+
return await lookup(e.set, kid, choices, decisionNow);
|
|
227
|
+
ensure(e.failureUntil === undefined || now >= e.failureUntil, "source_unavailable");
|
|
228
|
+
const fetched = await load(e, selectedClock, context);
|
|
229
|
+
if (!fetched.ok)
|
|
230
|
+
return fetched;
|
|
231
|
+
ensure(e.source.keyId === "directory" || !e.removed.has(kid), "revoked_key");
|
|
232
|
+
return await lookup(filter(fetched.value, e), kid, choices, decision === undefined ? clockValue(selectedClock) : clockValue(decision), fetched.value.expiresAt === fetched.value.fetchedAt);
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
return failure(e, "source_unavailable");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
139
238
|
const out = {
|
|
140
239
|
async refresh(s, snapshot) {
|
|
141
240
|
try {
|
|
@@ -151,44 +250,8 @@ export function createCache(input = {}) {
|
|
|
151
250
|
return failure(e, "source_unavailable");
|
|
152
251
|
}
|
|
153
252
|
},
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
ensure(isDiscoverySource(s), "invalid_source");
|
|
157
|
-
const a = options(input, ["algorithms", "clock", "context", "timeoutMs"]);
|
|
158
|
-
const choices = algorithmList(Object.hasOwn(a, "algorithms")
|
|
159
|
-
? a.algorithms
|
|
160
|
-
: unwrap(algorithms(), "invalid_options"));
|
|
161
|
-
ensure(keyIdValid(s, kid), "unknown_key");
|
|
162
|
-
ensure(s.keyId === "directory" || !s.revoked.includes(kid), "revoked_key");
|
|
163
|
-
const selectedClock = Object.hasOwn(a, "clock") ? a.clock : clockFn;
|
|
164
|
-
const now = clockValue(selectedClock);
|
|
165
|
-
ensure(!Object.hasOwn(a, "context") || isContext(a.context), "invalid_options");
|
|
166
|
-
let context = a.context;
|
|
167
|
-
if (Object.hasOwn(a, "timeoutMs")) {
|
|
168
|
-
ensure(Number.isSafeInteger(a.timeoutMs) &&
|
|
169
|
-
a.timeoutMs >= 1 &&
|
|
170
|
-
a.timeoutMs <= 300000, "invalid_options");
|
|
171
|
-
context = unwrap(withDeadline({
|
|
172
|
-
timeoutMs: Math.min(a.timeoutMs, s.timeoutMs),
|
|
173
|
-
...(context ? { parent: context } : {}),
|
|
174
|
-
}), "invalid_options");
|
|
175
|
-
}
|
|
176
|
-
ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
|
|
177
|
-
const e = enroll(s);
|
|
178
|
-
ensure(e.source.keyId === "directory" ||
|
|
179
|
-
(!e.removed.has(kid) && !e.source.revoked.includes(kid)), "revoked_key");
|
|
180
|
-
if (e.set && now < e.set.expiresAt)
|
|
181
|
-
return await lookup(e.set, kid, choices, now);
|
|
182
|
-
ensure(e.failureUntil === undefined || now >= e.failureUntil, "source_unavailable");
|
|
183
|
-
const fetched = await load(e, selectedClock, context);
|
|
184
|
-
if (!fetched.ok)
|
|
185
|
-
return fetched;
|
|
186
|
-
ensure(e.source.keyId === "directory" || !e.removed.has(kid), "revoked_key");
|
|
187
|
-
return await lookup(filter(fetched.value, e), kid, choices, clockValue(selectedClock), fetched.value.expiresAt === fetched.value.fetchedAt);
|
|
188
|
-
}
|
|
189
|
-
catch (e) {
|
|
190
|
-
return failure(e, "source_unavailable");
|
|
191
|
-
}
|
|
253
|
+
resolve(s, kid, input = {}) {
|
|
254
|
+
return resolveWith(s, kid, input);
|
|
192
255
|
},
|
|
193
256
|
remove(s, kid) {
|
|
194
257
|
try {
|
|
@@ -229,6 +292,7 @@ export function createCache(input = {}) {
|
|
|
229
292
|
},
|
|
230
293
|
};
|
|
231
294
|
caches.add(out);
|
|
295
|
+
decided.set(out, (src, kid, input, decision) => resolveWith(src, kid, input, decision));
|
|
232
296
|
return ok(inspectable(out, { kind: "DiscoveryCache" }));
|
|
233
297
|
}
|
|
234
298
|
catch (e) {
|
|
@@ -7,4 +7,6 @@ export declare function values(fs: readonly Field[], name: string): string[];
|
|
|
7
7
|
export declare function header(fs: readonly Field[], name: string): string | null;
|
|
8
8
|
export declare function httpDate(input: string | null, now: number): number | null;
|
|
9
9
|
export declare function expiry(fs: readonly Field[], source: DiscoverySource, now: number): number;
|
|
10
|
+
/** RFC 9111 Section 4.3.4: whether a response carries freshness directives of its own. */
|
|
11
|
+
export declare function hasFreshness(fs: readonly Field[]): boolean;
|
|
10
12
|
export declare function mediaType(fs: readonly Field[], type: DiscoverySource["type"]): void;
|
|
@@ -85,6 +85,25 @@ export function expiry(fs, source, now) {
|
|
|
85
85
|
.toLowerCase()
|
|
86
86
|
.split(",")
|
|
87
87
|
.map((v) => v.trim());
|
|
88
|
+
return expiryFromControls(fs, source, now, controls);
|
|
89
|
+
}
|
|
90
|
+
/** RFC 9111 Section 4.3.4: whether a response carries freshness directives of its own. */
|
|
91
|
+
export function hasFreshness(fs) {
|
|
92
|
+
if (header(fs, "expires") !== null)
|
|
93
|
+
return true;
|
|
94
|
+
const controls = values(fs, "cache-control")
|
|
95
|
+
.join(",")
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((v) => v.trim());
|
|
99
|
+
// Only freshness-changing names count: max-age, no-cache, no-store. A
|
|
100
|
+
// directive like `public` leaves the stored lifetime in force.
|
|
101
|
+
return controls.some((v) => {
|
|
102
|
+
const name = v.split("=", 1)[0].trim();
|
|
103
|
+
return name === "no-store" || name === "no-cache" || name === "max-age";
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function expiryFromControls(fs, source, now, controls) {
|
|
88
107
|
const ages = controls
|
|
89
108
|
.filter((v) => v.startsWith("max-age="))
|
|
90
109
|
.map((v) => v.slice(8).replace(/^"+|"+$/g, ""));
|
|
@@ -102,8 +121,15 @@ export function expiry(fs, source, now) {
|
|
|
102
121
|
}
|
|
103
122
|
const age = header(fs, "age") ?? "0";
|
|
104
123
|
ensure(/^[0-9]{1,10}$/.test(age), "invalid_response");
|
|
124
|
+
// RFC 9111 Sections 5.2.2.3 and 5.2.2.4: no-store and no-cache also arrive
|
|
125
|
+
// qualified, as in no-cache="content-type" (and the RFC 7234 quoted form whose
|
|
126
|
+
// member can itself contain commas). Match the directive name before any "=",
|
|
127
|
+
// so every spelling still forces revalidation.
|
|
105
128
|
return (now +
|
|
106
|
-
(controls.some((v) =>
|
|
129
|
+
(controls.some((v) => {
|
|
130
|
+
const name = v.split("=", 1)[0].trim();
|
|
131
|
+
return name === "no-store" || name === "no-cache";
|
|
132
|
+
})
|
|
107
133
|
? 0
|
|
108
134
|
: Math.max(Math.min(ttl, source.maxTtl) - Math.max(Number(age), now - date, 0), 0)));
|
|
109
135
|
}
|
|
@@ -10,13 +10,20 @@ export type KeyResolver = (request: unknown) => Promise<Result<Readonly<{
|
|
|
10
10
|
declare function resolver(input: unknown, opts: unknown): Result<KeyResolver>;
|
|
11
11
|
/** Snapshot resolution and explicit discovery operations. */
|
|
12
12
|
export declare const Discovery: Readonly<{
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Create a resolver over a branded key set or caller-owned discovery cache.
|
|
15
|
+
* Options are `algorithms` and an optional `clock` (integer Unix seconds); pass the
|
|
16
|
+
* policy's freshness clock so key freshness and signature freshness agree. Without
|
|
17
|
+
* `clock`, a key set uses system time and a cache uses its own clock. With a cache,
|
|
18
|
+
* `clock` decides only the returned key's freshness; the cache keeps its own clock for
|
|
19
|
+
* fetching and storing.
|
|
20
|
+
*/
|
|
14
21
|
resolver: typeof resolver & {
|
|
15
22
|
/** Parse a caller-supplied discovery snapshot without fetching it. */
|
|
16
23
|
fromSnapshot: typeof fromSnapshot;
|
|
17
24
|
};
|
|
18
25
|
}>;
|
|
19
26
|
export { DiscoverySource, isDiscoverySource, directoryId } from "./source.ts";
|
|
20
|
-
export { isKeySet, isResolution, type KeySet, type Resolution, } from "./body.ts";
|
|
27
|
+
export { isKeySet, isResolution, type KeySet, type NotModified, type Resolution, } from "./body.ts";
|
|
21
28
|
export { createCache, isDiscoveryCache, type DiscoveryCache, type DiscoveryFetch, } from "./cache.ts";
|
|
22
29
|
export type { Headers } from "./snapshot.ts";
|
package/dist/discovery/index.js
CHANGED
|
@@ -2,14 +2,17 @@ import { ok } from "../result.js";
|
|
|
2
2
|
import { snapshotOptions } from "../brand.js";
|
|
3
3
|
import { isKeySet, algorithmList } from "./body.js";
|
|
4
4
|
import { isDiscoverySource } from "./source.js";
|
|
5
|
-
import { isDiscoveryCache } from "./cache.js";
|
|
5
|
+
import { isDiscoveryCache, resolveOnClock } from "./cache.js";
|
|
6
6
|
import { fromSnapshot } from "./snapshot.js";
|
|
7
|
-
import { ensure, unwrap, failure } from "./support.js";
|
|
7
|
+
import { ensure, unwrap, failure, clockValue } from "./support.js";
|
|
8
8
|
/** Create a resolver over a branded key set or caller-owned discovery cache. */
|
|
9
9
|
function resolver(input, opts) {
|
|
10
10
|
try {
|
|
11
|
-
const a = unwrap(snapshotOptions(opts, ["algorithms"]), "invalid_options");
|
|
11
|
+
const a = unwrap(snapshotOptions(opts, ["algorithms", "clock"]), "invalid_options");
|
|
12
12
|
const allowed = algorithmList(a.algorithms);
|
|
13
|
+
// An explicit clock, usually the policy's freshness clock; system time otherwise.
|
|
14
|
+
const clock = Object.hasOwn(a, "clock") ? a.clock : undefined;
|
|
15
|
+
ensure(clock === undefined || typeof clock === "function", "invalid_options");
|
|
13
16
|
let set, cache, source;
|
|
14
17
|
if (isKeySet(input))
|
|
15
18
|
set = input;
|
|
@@ -25,9 +28,15 @@ function resolver(input, opts) {
|
|
|
25
28
|
typeof request === "object" &&
|
|
26
29
|
Object.hasOwn(request, "keyid"), "unknown_key");
|
|
27
30
|
const kid = request.keyid;
|
|
31
|
+
// A clock decides key freshness only: a throwing or invalid clock is
|
|
32
|
+
// invalid_options on both paths, and a cache still fetches and stores on its own clock.
|
|
28
33
|
const result = set
|
|
29
|
-
?
|
|
30
|
-
|
|
34
|
+
? clock === undefined
|
|
35
|
+
? await set.lookup(kid, allowed)
|
|
36
|
+
: await set.lookup(kid, allowed, clockValue(clock))
|
|
37
|
+
: clock === undefined
|
|
38
|
+
? await cache.resolve(source, kid, { algorithms: allowed })
|
|
39
|
+
: await resolveOnClock(cache, source, kid, { algorithms: allowed }, clock);
|
|
31
40
|
return result.ok
|
|
32
41
|
? ok(Object.freeze({
|
|
33
42
|
algorithm: result.value.algorithm,
|
|
@@ -51,7 +60,14 @@ const Resolver = Object.freeze(Object.assign(resolver, {
|
|
|
51
60
|
}));
|
|
52
61
|
/** Snapshot resolution and explicit discovery operations. */
|
|
53
62
|
export const Discovery = Object.freeze({
|
|
54
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Create a resolver over a branded key set or caller-owned discovery cache.
|
|
65
|
+
* Options are `algorithms` and an optional `clock` (integer Unix seconds); pass the
|
|
66
|
+
* policy's freshness clock so key freshness and signature freshness agree. Without
|
|
67
|
+
* `clock`, a key set uses system time and a cache uses its own clock. With a cache,
|
|
68
|
+
* `clock` decides only the returned key's freshness; the cache keeps its own clock for
|
|
69
|
+
* fetching and storing.
|
|
70
|
+
*/
|
|
55
71
|
resolver: Resolver
|
|
56
72
|
});
|
|
57
73
|
export { DiscoverySource, isDiscoverySource, directoryId } from "./source.js";
|
package/dist/discovery/node.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type Result } from "../result.ts";
|
|
2
|
-
import { type KeySet } from "./body.ts";
|
|
2
|
+
import { type KeySet, type NotModified } from "./body.ts";
|
|
3
3
|
export type DiscoverySource = import("./source.ts").DiscoverySource;
|
|
4
|
-
/** Fetch a discovery key set with bounded HTTPS, address, and redirect policies. */
|
|
4
|
+
/** Fetch a discovery key set with bounded HTTPS, address, and redirect policies, unconditionally. A `validators` option is accepted and ignored, so this function wires directly as a cache's fetch callback; use `fetchWithValidators` for conditional revalidation. */
|
|
5
5
|
export declare function fetch(source: unknown, input?: unknown): Promise<Result<KeySet>>;
|
|
6
|
+
/** Conditionally fetch a discovery key set (RFC 9111 Section 4.3.4): send `If-None-Match` with the stored validator, and an unchanged source answers 304 as a `NotModified` outcome instead of a key set. `storedLifetime` seconds persist when the 304 carries no freshness directives of its own. */
|
|
7
|
+
export declare function fetchWithValidators(source: unknown, etag: unknown, storedLifetime: unknown, input?: unknown): Promise<Result<KeySet | NotModified>>;
|
|
6
8
|
/** Discovery resolution with the explicit Node HTTPS transport. */
|
|
7
9
|
export declare const Discovery: Readonly<{
|
|
8
10
|
resolver: ((input: unknown, opts: unknown) => Result<import("./index.ts").KeyResolver>) & {
|
|
@@ -10,6 +12,8 @@ export declare const Discovery: Readonly<{
|
|
|
10
12
|
};
|
|
11
13
|
/** Fetch a discovery key set through the bounded Node HTTPS transport. */
|
|
12
14
|
fetch: typeof fetch;
|
|
15
|
+
/** Conditionally fetch a discovery key set (RFC 9111 Section 4.3.4). */
|
|
16
|
+
fetchWithValidators: typeof fetchWithValidators;
|
|
13
17
|
}>;
|
|
14
18
|
/** Node source creation fails closed when the OS trust store is unavailable or empty. */
|
|
15
19
|
export declare const DiscoverySource: Readonly<{
|
package/dist/discovery/node.js
CHANGED
|
@@ -3,15 +3,16 @@ import { promises as dns } from "node:dns";
|
|
|
3
3
|
import { isIP } from "node:net";
|
|
4
4
|
import * as tls from "node:tls";
|
|
5
5
|
import { connect, checkServerIdentity } from "node:tls";
|
|
6
|
-
import {
|
|
6
|
+
import { X509Certificate } from "node:crypto";
|
|
7
|
+
import { err, ok } from "../result.js";
|
|
7
8
|
import { withDeadline, isContext, run, remainingMs, } from "../context.js";
|
|
8
|
-
import { discoveryError
|
|
9
|
+
import { discoveryError } from "../errors/types.js";
|
|
9
10
|
import { encodeBase64 } from "../bytes.js";
|
|
10
11
|
import { Discovery as CoreDiscovery } from "./index.js";
|
|
11
12
|
import { sourceUrl, normalizedHost, sourceAuthority, sourceOrigin, isDiscoverySource, DiscoverySource as CoreSource, } from "./source.js";
|
|
12
13
|
import { addressAllowed } from "./address.js";
|
|
13
14
|
import { ensure, unwrap, failure, options, clockValue, systemClock, sslError, list, } from "./support.js";
|
|
14
|
-
import { fields, header, expiry, mediaType } from "./headers.js";
|
|
15
|
+
import { fields, header, expiry, hasFreshness, mediaType } from "./headers.js";
|
|
15
16
|
import { bodyDocument } from "./body.js";
|
|
16
17
|
import { fromResource } from "./snapshot.js";
|
|
17
18
|
import { decodeBody } from "./gzip.js";
|
|
@@ -42,6 +43,10 @@ async function resolve(host, context, servers) {
|
|
|
42
43
|
]);
|
|
43
44
|
if (found.some((r) => r.status === "rejected" && r.reason?.code === "ETIMEOUT"))
|
|
44
45
|
throw discoveryError("deadline_exceeded");
|
|
46
|
+
// An authoritative NXDOMAIN on every query is a configuration error:
|
|
47
|
+
// the configured domain does not exist, so retrying cannot help.
|
|
48
|
+
if (found.every((r) => r.status === "rejected" && r.reason?.code === "ENOTFOUND"))
|
|
49
|
+
throw discoveryError("domain_not_found");
|
|
45
50
|
answers = found.flatMap((r, i) => r.status === "fulfilled"
|
|
46
51
|
? r.value.map((address) => ({ address, family: i === 0 ? 4 : 6 }))
|
|
47
52
|
: []);
|
|
@@ -54,7 +59,13 @@ async function resolve(host, context, servers) {
|
|
|
54
59
|
try {
|
|
55
60
|
answers = await lookup(host, { all: true });
|
|
56
61
|
}
|
|
57
|
-
catch {
|
|
62
|
+
catch (error) {
|
|
63
|
+
// getaddrinfo's authoritative name-not-found is the same configuration
|
|
64
|
+
// error; any other resolver trouble may succeed on a later retry.
|
|
65
|
+
if (error &&
|
|
66
|
+
typeof error === "object" &&
|
|
67
|
+
error.code === "ENOTFOUND")
|
|
68
|
+
throw discoveryError("domain_not_found");
|
|
58
69
|
throw discoveryError("resolution_failed");
|
|
59
70
|
}
|
|
60
71
|
}
|
|
@@ -66,6 +77,12 @@ async function resolve(host, context, servers) {
|
|
|
66
77
|
ensure(addresses.length >= 1 && addresses.length <= 64, "resolution_failed");
|
|
67
78
|
return addresses;
|
|
68
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The OS trust store, or undefined when this runtime provides no usable one: Node before
|
|
82
|
+
* 22.15.0 (and Deno 2.5.6) lack `tls.getCACertificates`, and a host can return an empty
|
|
83
|
+
* store. Bundled roots are never a fallback (ADR 0007); callers get
|
|
84
|
+
* `discovery/transport_unavailable`, the same refusal as a denied store.
|
|
85
|
+
*/
|
|
69
86
|
function systemCertificates() {
|
|
70
87
|
if (typeof tls.getCACertificates !== "function")
|
|
71
88
|
return undefined;
|
|
@@ -77,6 +94,27 @@ function systemCertificates() {
|
|
|
77
94
|
systemCertificateError(error);
|
|
78
95
|
}
|
|
79
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Check that the peer certificate names the host. An IP literal is matched against the
|
|
99
|
+
* certificate's iPAddress names by OpenSSL (`X509Certificate.checkIP`), never against DNS
|
|
100
|
+
* names or the subject. Node 22.23.1's `tls.checkServerIdentity` IDNA-normalizes the host
|
|
101
|
+
* before testing for an IP, and `domainToASCII("::1")` is "", so every IPv6 literal fails
|
|
102
|
+
* there with ERR_TLS_CERT_ALTNAME_INVALID; DNS hosts keep `tls.checkServerIdentity`.
|
|
103
|
+
*/
|
|
104
|
+
function checkHostIdentity(host, cert) {
|
|
105
|
+
if (!isIP(host))
|
|
106
|
+
return checkServerIdentity(host, cert);
|
|
107
|
+
try {
|
|
108
|
+
if (new X509Certificate(cert.raw).checkIP(host) !== undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// A certificate OpenSSL cannot parse names no address.
|
|
113
|
+
}
|
|
114
|
+
return Object.assign(new Error("certificate does not name the IP address"), {
|
|
115
|
+
code: "ERR_TLS_CERT_ALTNAME_INVALID",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
80
118
|
function tlsSocket(ip, url, source, context) {
|
|
81
119
|
remaining(context);
|
|
82
120
|
const host = normalizedHost(url), roots = source.cacerts;
|
|
@@ -86,7 +124,7 @@ function tlsSocket(ip, url, source, context) {
|
|
|
86
124
|
encodeBase64(der) +
|
|
87
125
|
"\n-----END CERTIFICATE-----");
|
|
88
126
|
if (!ca)
|
|
89
|
-
throw
|
|
127
|
+
throw discoveryError("transport_unavailable");
|
|
90
128
|
return new Promise((done, fail) => {
|
|
91
129
|
const socket = connect(Object.assign(Object.create(null), {
|
|
92
130
|
host: ip,
|
|
@@ -96,7 +134,7 @@ function tlsSocket(ip, url, source, context) {
|
|
|
96
134
|
minVersion: "TLSv1.2",
|
|
97
135
|
ALPNProtocols: ["http/1.1"],
|
|
98
136
|
ca,
|
|
99
|
-
checkServerIdentity: (_name, cert) =>
|
|
137
|
+
checkServerIdentity: (_name, cert) => checkHostIdentity(host, cert),
|
|
100
138
|
lookup: () => {
|
|
101
139
|
throw discoveryError("address_denied");
|
|
102
140
|
},
|
|
@@ -146,7 +184,7 @@ async function connected(url, source, context, servers) {
|
|
|
146
184
|
}
|
|
147
185
|
throw last;
|
|
148
186
|
}
|
|
149
|
-
async function get(url, source, context, servers) {
|
|
187
|
+
async function get(url, source, context, servers, etag) {
|
|
150
188
|
const socket = await connected(url, source, context, servers);
|
|
151
189
|
// HTTPS receives only an already connected, peer-checked TLS socket. No proxy,
|
|
152
190
|
// pooled connection or late DNS lookup participates in this private agent.
|
|
@@ -176,6 +214,7 @@ async function get(url, source, context, servers) {
|
|
|
176
214
|
: "application/json, application/jwk-set+json",
|
|
177
215
|
"Accept-Encoding": "gzip",
|
|
178
216
|
Connection: "close",
|
|
217
|
+
...(typeof etag === "string" ? { "If-None-Match": etag } : {}),
|
|
179
218
|
},
|
|
180
219
|
lookup: () => {
|
|
181
220
|
throw discoveryError("address_denied");
|
|
@@ -252,7 +291,14 @@ async function get(url, source, context, servers) {
|
|
|
252
291
|
}
|
|
253
292
|
};
|
|
254
293
|
socket.prependListener("data", onWire);
|
|
255
|
-
|
|
294
|
+
// RFC 9110 Section 15.2: interim responses are provisional and precede
|
|
295
|
+
// the final status. Five are skipped, the reference bound; a sixth
|
|
296
|
+
// names a misbehaving publisher and rejects as a malformed response.
|
|
297
|
+
let interims = 0;
|
|
298
|
+
req.on("information", (info) => {
|
|
299
|
+
if (++interims > 5)
|
|
300
|
+
reject(discoveryError("invalid_response"));
|
|
301
|
+
});
|
|
256
302
|
req.on("upgrade", (res, upgraded) => {
|
|
257
303
|
reject(discoveryError("unexpected_status", res.statusCode));
|
|
258
304
|
upgraded.destroy();
|
|
@@ -354,8 +400,8 @@ function redirectUrl(current, location) {
|
|
|
354
400
|
throw discoveryError("redirect_denied");
|
|
355
401
|
}
|
|
356
402
|
}
|
|
357
|
-
async function resource(url, source, context, servers, hops = 0) {
|
|
358
|
-
const { status, fs, encoded } = await get(url, source, context, servers);
|
|
403
|
+
async function resource(url, source, context, servers, hops = 0, etag) {
|
|
404
|
+
const { status, fs, encoded } = await get(url, source, context, servers, etag);
|
|
359
405
|
if ([301, 302, 303, 307, 308].includes(status)) {
|
|
360
406
|
ensure(source.maxRedirects > 0, "redirect_denied");
|
|
361
407
|
ensure(hops < source.maxRedirects, "redirect_limit");
|
|
@@ -364,8 +410,15 @@ async function resource(url, source, context, servers, hops = 0) {
|
|
|
364
410
|
const target = redirectUrl(url, location);
|
|
365
411
|
ensure(source.redirectScope === "any_https" ||
|
|
366
412
|
sourceOrigin(target) === sourceOrigin(sourceUrl(source.location)), "redirect_denied");
|
|
367
|
-
|
|
413
|
+
// A validator never crosses a redirect: the target is a different
|
|
414
|
+
// resource representation.
|
|
415
|
+
return resource(target, source, context, servers, hops + 1, undefined);
|
|
368
416
|
}
|
|
417
|
+
// RFC 9111 Section 15.4.5: 304 is valid only as the answer to the
|
|
418
|
+
// conditional this client sent; an unsolicited 304 falls through to the
|
|
419
|
+
// unexpected-status rejection below.
|
|
420
|
+
if (status === 304 && typeof etag === "string")
|
|
421
|
+
return { notModified: true, fields: fs, location: url.href };
|
|
369
422
|
if (status !== 200)
|
|
370
423
|
throw discoveryError("unexpected_status", status);
|
|
371
424
|
mediaType(fs, source.type);
|
|
@@ -375,13 +428,37 @@ async function resource(url, source, context, servers, hops = 0) {
|
|
|
375
428
|
remaining(context);
|
|
376
429
|
return { fields: fs, encoded, decoded, location: url.href };
|
|
377
430
|
}
|
|
378
|
-
/** Fetch a discovery key set with bounded HTTPS, address, and redirect policies. */
|
|
431
|
+
/** Fetch a discovery key set with bounded HTTPS, address, and redirect policies, unconditionally. A `validators` option is accepted and ignored, so this function wires directly as a cache's fetch callback; use `fetchWithValidators` for conditional revalidation. */
|
|
379
432
|
export async function fetch(source, input = {}) {
|
|
433
|
+
const r = await conditional(source, input, undefined);
|
|
434
|
+
// No validator was sent, so a 304 cannot occur; keep the total anyway.
|
|
435
|
+
if (r.ok && "notModified" in r.value && r.value.notModified === true)
|
|
436
|
+
return err(discoveryError("invalid_response"));
|
|
437
|
+
return r;
|
|
438
|
+
}
|
|
439
|
+
/** Conditionally fetch a discovery key set (RFC 9111 Section 4.3.4): send `If-None-Match` with the stored validator, and an unchanged source answers 304 as a `NotModified` outcome instead of a key set. `storedLifetime` seconds persist when the 304 carries no freshness directives of its own. */
|
|
440
|
+
export async function fetchWithValidators(source, etag, storedLifetime, input = {}) {
|
|
441
|
+
try {
|
|
442
|
+
ensure(typeof etag === "string" &&
|
|
443
|
+
etag.length >= 1 &&
|
|
444
|
+
etag.length <= 512 &&
|
|
445
|
+
Number.isSafeInteger(storedLifetime) &&
|
|
446
|
+
storedLifetime >= 0, "invalid_options");
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
return failure(e);
|
|
450
|
+
}
|
|
451
|
+
return conditional(source, input, {
|
|
452
|
+
etag: etag,
|
|
453
|
+
lifetime: storedLifetime,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
async function conditional(source, input, validator) {
|
|
380
457
|
try {
|
|
381
458
|
ensure(isDiscoverySource(source), "invalid_source");
|
|
382
459
|
if (source.cacerts === "os" && !systemCertificates())
|
|
383
|
-
return err(
|
|
384
|
-
const a = options(input, ["timeoutMs", "clock", "context", "dnsServers"]);
|
|
460
|
+
return err(discoveryError("transport_unavailable"));
|
|
461
|
+
const a = options(input, ["timeoutMs", "clock", "context", "dnsServers", "validators"]);
|
|
385
462
|
const timeout = Object.hasOwn(a, "timeoutMs")
|
|
386
463
|
? a.timeoutMs
|
|
387
464
|
: source.timeoutMs;
|
|
@@ -418,15 +495,38 @@ export async function fetch(source, input = {}) {
|
|
|
418
495
|
}), "invalid_options");
|
|
419
496
|
const result = await run(context, async () => {
|
|
420
497
|
try {
|
|
421
|
-
const outer = await resource(sourceUrl(source.location), source, context, servers);
|
|
498
|
+
const outer = await resource(sourceUrl(source.location), source, context, servers, 0, validator?.etag);
|
|
422
499
|
const now = clockValue(clock);
|
|
500
|
+
if ("notModified" in outer) {
|
|
501
|
+
// RFC 9111 Sections 3.2 and 4.3.4: a 304's own directives
|
|
502
|
+
// recalculate freshness; without them the stored representation's
|
|
503
|
+
// lifetime persists. A different strong validator identifies a
|
|
504
|
+
// different representation, so it cannot renew this one.
|
|
505
|
+
const updated = header(outer.fields, "etag");
|
|
506
|
+
ensure(updated === null || updated.length <= 512, "invalid_response");
|
|
507
|
+
ensure(updated === null || updated === validator.etag, "invalid_response");
|
|
508
|
+
const fresh = Object.freeze({
|
|
509
|
+
notModified: true,
|
|
510
|
+
etag: validator.etag,
|
|
511
|
+
fetchedAt: now,
|
|
512
|
+
expiresAt: hasFreshness(outer.fields)
|
|
513
|
+
? expiry(outer.fields, source, now)
|
|
514
|
+
: now + validator.lifetime,
|
|
515
|
+
});
|
|
516
|
+
return ok(fresh);
|
|
517
|
+
}
|
|
423
518
|
if (source.type === "cimd") {
|
|
424
519
|
const doc = bodyDocument(outer.decoded, source);
|
|
425
520
|
ensure(doc.client_id === source.location, "client_id_mismatch");
|
|
426
521
|
ensure(!(Object.hasOwn(doc, "jwks") && Object.hasOwn(doc, "jwks_uri")), "ambiguous_key_source");
|
|
427
522
|
if (Object.hasOwn(doc, "jwks_uri")) {
|
|
428
523
|
const url = sourceUrl(doc.jwks_uri);
|
|
429
|
-
ensure(
|
|
524
|
+
ensure(
|
|
525
|
+
// Compare against the origin that served this document, not the
|
|
526
|
+
// configured one: a permissive redirect scope can legitimately
|
|
527
|
+
// move the document to a mirror, and its jwks_uri names that
|
|
528
|
+
// mirror.
|
|
529
|
+
sourceOrigin(url) === sourceOrigin(sourceUrl(outer.location)), "redirect_denied");
|
|
430
530
|
// The subresource has JWKS media/body ceilings, original CIMD provenance.
|
|
431
531
|
const nestedSource = unwrap(DiscoverySource.create({
|
|
432
532
|
...source,
|
|
@@ -434,6 +534,10 @@ export async function fetch(source, input = {}) {
|
|
|
434
534
|
location: url.href,
|
|
435
535
|
}), "invalid_source");
|
|
436
536
|
const nested = await resource(url, nestedSource, context, servers);
|
|
537
|
+
// The nested subresource is always fetched unconditionally, so a
|
|
538
|
+
// 304 cannot occur here; keep the rejection total anyway.
|
|
539
|
+
if ("notModified" in nested)
|
|
540
|
+
throw discoveryError("unexpected_status");
|
|
437
541
|
const result = await fromResource(source, nested.decoded, nested.encoded, now, nested.fields, nested.location, expiry(outer.fields, source, now), true, context);
|
|
438
542
|
remaining(context);
|
|
439
543
|
return result;
|
|
@@ -458,7 +562,9 @@ export async function fetch(source, input = {}) {
|
|
|
458
562
|
/** Discovery resolution with the explicit Node HTTPS transport. */
|
|
459
563
|
export const Discovery = Object.freeze({ ...CoreDiscovery,
|
|
460
564
|
/** Fetch a discovery key set through the bounded Node HTTPS transport. */
|
|
461
|
-
fetch
|
|
565
|
+
fetch,
|
|
566
|
+
/** Conditionally fetch a discovery key set (RFC 9111 Section 4.3.4). */
|
|
567
|
+
fetchWithValidators });
|
|
462
568
|
/** Node source creation fails closed when the OS trust store is unavailable or empty. */
|
|
463
569
|
export const DiscoverySource = Object.freeze({
|
|
464
570
|
...CoreSource,
|
|
@@ -467,7 +573,7 @@ export const DiscoverySource = Object.freeze({
|
|
|
467
573
|
try {
|
|
468
574
|
const result = CoreSource.create(input);
|
|
469
575
|
if (result.ok && result.value.cacerts === "os" && !systemCertificates())
|
|
470
|
-
return err(
|
|
576
|
+
return err(discoveryError("transport_unavailable"));
|
|
471
577
|
return result;
|
|
472
578
|
}
|
|
473
579
|
catch (error) {
|
package/dist/discovery/proof.js
CHANGED
|
@@ -4,7 +4,7 @@ import { Message, Body, TransportFacts, } from "../message/index.js";
|
|
|
4
4
|
import { SignatureBase } from "../signature-base/index.js";
|
|
5
5
|
import { Crypto } from "../crypto/index.js";
|
|
6
6
|
import { digest } from "../crypto/subtle.js";
|
|
7
|
-
import { algorithms } from "../crypto/algorithm.js";
|
|
7
|
+
import { algorithms, joseId } from "../crypto/algorithm.js";
|
|
8
8
|
import { encodeBase64 } from "../bytes.js";
|
|
9
9
|
import { sourceAuthority, sourceUrl, thumbprintId } from "./source.js";
|
|
10
10
|
import { ensure, unwrap } from "./support.js";
|
|
@@ -145,7 +145,7 @@ export async function possessionProof(source, keys, location, fs, encoded, now,
|
|
|
145
145
|
for (const algorithm of unwrap(algorithms(), "directory_signature_invalid")) {
|
|
146
146
|
ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
|
|
147
147
|
if ((entry.assertedAlgorithm === null ||
|
|
148
|
-
entry.assertedAlgorithm === algorithm) &&
|
|
148
|
+
entry.assertedAlgorithm === joseId(algorithm)) &&
|
|
149
149
|
entry.key.bind(algorithm).ok &&
|
|
150
150
|
(!p.has("alg") || textParam(p, "alg") === algorithm)) {
|
|
151
151
|
if ((await Crypto.verify(algorithm, entry.key, sig.value.value, base)).ok) {
|
|
@@ -57,6 +57,8 @@ export async function fromResource(source, decoded, encoded, now, fs, location,
|
|
|
57
57
|
}
|
|
58
58
|
const bodyRevision = await revision(decoded);
|
|
59
59
|
ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
|
|
60
|
+
const storedEtag = header(fs, "etag");
|
|
61
|
+
ensure(storedEtag === null || storedEtag.length <= 512, "invalid_response");
|
|
60
62
|
return ok(keySet({
|
|
61
63
|
source,
|
|
62
64
|
origin: sourceOrigin(sourceUrl(location)),
|
|
@@ -65,6 +67,7 @@ export async function fromResource(source, decoded, encoded, now, fs, location,
|
|
|
65
67
|
expiresAt,
|
|
66
68
|
revision: bodyRevision,
|
|
67
69
|
proof: proven.proof,
|
|
70
|
+
etag: storedEtag,
|
|
68
71
|
}));
|
|
69
72
|
}
|
|
70
73
|
catch (e) {
|