@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/README.md +10 -9
  3. package/dist/adapters/node-http/index.d.ts +8 -1
  4. package/dist/adapters/node-http/index.js +21 -5
  5. package/dist/constants.d.ts +4 -4
  6. package/dist/constants.js +2 -2
  7. package/dist/crypto/algorithm.d.ts +4 -0
  8. package/dist/crypto/algorithm.js +12 -0
  9. package/dist/discovery/body.d.ts +12 -2
  10. package/dist/discovery/body.js +33 -11
  11. package/dist/discovery/cache.d.ts +13 -3
  12. package/dist/discovery/cache.js +103 -39
  13. package/dist/discovery/headers.d.ts +2 -0
  14. package/dist/discovery/headers.js +27 -1
  15. package/dist/discovery/index.d.ts +9 -2
  16. package/dist/discovery/index.js +22 -6
  17. package/dist/discovery/node.d.ts +6 -2
  18. package/dist/discovery/node.js +124 -18
  19. package/dist/discovery/proof.js +2 -2
  20. package/dist/discovery/snapshot.js +3 -0
  21. package/dist/errors/parity.d.ts +29 -3
  22. package/dist/errors/parity.js +32 -3
  23. package/dist/errors/types.d.ts +4 -2
  24. package/dist/errors/types.js +14 -2
  25. package/dist/jose/nested.js +23 -1
  26. package/dist/message/index.js +2 -1
  27. package/dist/policy.d.ts +13 -1
  28. package/dist/policy.js +13 -1
  29. package/dist/quorum/evaluation.d.ts +1 -1
  30. package/dist/quorum/evaluation.js +18 -4
  31. package/dist/replay/postgres.d.ts +2 -0
  32. package/dist/replay/postgres.js +6 -0
  33. package/dist/sign.d.ts +22 -1
  34. package/dist/sign.js +25 -4
  35. package/dist/signature-fields.d.ts +5 -1
  36. package/dist/signature-fields.js +9 -1
  37. package/dist/verify-quorum.js +29 -8
  38. package/dist/verify.d.ts +19 -1
  39. package/dist/verify.js +32 -5
  40. package/dist/web-bot-auth/verify.js +2 -2
  41. package/package.json +13 -1
@@ -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, { clock, context: c }));
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
- async resolve(s, kid, input = {}) {
155
- try {
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) => v === "no-store" || v === "no-cache")
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
- /** Create a resolver over a branded key set or caller-owned discovery cache. */
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";
@@ -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
- ? await set.lookup(kid, allowed)
30
- : await cache.resolve(source, kid, { algorithms: allowed });
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
- /** Create a resolver over a branded key set or caller-owned discovery cache. */
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";
@@ -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<{
@@ -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 { err } from "../result.js";
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, custodyError } from "../errors/types.js";
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 custodyError("unsupported_format");
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) => checkServerIdentity(host, 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
- req.on("information", info => reject(discoveryError("unexpected_status", info.statusCode)));
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
- return resource(target, source, context, servers, hops + 1);
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(custodyError("unsupported_format"));
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(sourceOrigin(url) === sourceOrigin(sourceUrl(source.location)), "redirect_denied");
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(custodyError("unsupported_format"));
576
+ return err(discoveryError("transport_unavailable"));
471
577
  return result;
472
578
  }
473
579
  catch (error) {
@@ -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) {