@shelbyb/daywalker-sdk 0.2.0 → 0.4.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 (55) hide show
  1. package/README.md +38 -24
  2. package/dist/analytics/index.d.ts +26 -3
  3. package/dist/analytics/index.js +107 -23
  4. package/dist/cli.js +136 -29
  5. package/dist/client.d.ts +44 -3
  6. package/dist/client.js +372 -73
  7. package/dist/config.d.ts +14 -2
  8. package/dist/config.js +36 -0
  9. package/dist/crypto/encoding.d.ts +5 -0
  10. package/dist/crypto/encoding.js +14 -0
  11. package/dist/crypto/index.d.ts +3 -3
  12. package/dist/crypto/index.js +2 -2
  13. package/dist/crypto/keys.d.ts +3 -3
  14. package/dist/crypto/keys.js +28 -7
  15. package/dist/crypto/signatures.d.ts +54 -11
  16. package/dist/crypto/signatures.js +310 -94
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +34 -5
  20. package/dist/flags/client.js +93 -22
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +77 -6
  23. package/dist/flags/common/index.d.ts +3 -3
  24. package/dist/flags/common/index.js +3 -3
  25. package/dist/flags/common/sse.d.ts +24 -3
  26. package/dist/flags/common/sse.js +85 -15
  27. package/dist/flags/common/version.d.ts +2 -0
  28. package/dist/flags/common/version.js +2 -0
  29. package/dist/flags/engine/context.js +4 -3
  30. package/dist/flags/engine/index.d.ts +1 -1
  31. package/dist/flags/engine/index.js +1 -1
  32. package/dist/flags/engine/operators.d.ts +25 -0
  33. package/dist/flags/engine/operators.js +64 -8
  34. package/dist/flags/node.d.ts +27 -15
  35. package/dist/flags/node.js +54 -7
  36. package/dist/flags/react.d.ts +6 -4
  37. package/dist/flags/react.js +47 -17
  38. package/dist/index.d.ts +7 -6
  39. package/dist/index.js +4 -4
  40. package/dist/middleware.d.ts +18 -0
  41. package/dist/middleware.js +28 -7
  42. package/dist/next.d.ts +63 -6
  43. package/dist/next.js +182 -36
  44. package/dist/openfeature/server.d.ts +3 -0
  45. package/dist/openfeature/server.js +14 -2
  46. package/dist/openfeature/shared.d.ts +1 -1
  47. package/dist/openfeature/shared.js +22 -2
  48. package/dist/openfeature/web.js +9 -3
  49. package/dist/types.d.ts +58 -3
  50. package/dist/types.js +6 -0
  51. package/docs/analytics.md +1 -1
  52. package/docs/flags-node.md +1 -1
  53. package/docs/flags.md +2 -1
  54. package/package.json +34 -16
  55. package/assets/logo-512.png +0 -0
@@ -1,27 +1,42 @@
1
- import { base64ToBytes, base64UrlToBytes, bytesToBase64, stringToUtf8, utf8ToString, timingSafeEqualString, } from "./encoding.js";
1
+ import { base64ToBytes, base64UrlToBytesStrict, bytesToBase64, bytesToHex, stringToUtf8, utf8ToString, timingSafeEqualString, } from "./encoding.js";
2
2
  import { computeContentDigest } from "./digest.js";
3
3
  import { loadPublicKey, loadSigningKey } from "./keys.js";
4
+ import { assertSecureUrl } from "../config.js";
4
5
  export const SIGNATURE_VERSION = "rfc9421";
5
6
  export const SIGNATURE_LABEL = "sig1";
6
7
  export const SIGNATURE_ALG = "ed25519";
7
8
  export const MAX_CLOCK_SKEW_SECONDS = 300;
8
9
  export const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z0-9_\-\.]{1,128}$/;
9
10
  /**
10
- * Normalizes HTTP request path per RFC 3986 to prevent path traversal & signature confusion.
11
+ * Splits a request target into its RFC 9421 "@path" value (§2.2.6; dot segments resolved and repeated
12
+ * slashes collapsed per RFC 3986, to prevent path traversal & signature confusion) and its "@query" value
13
+ * (§2.2.7; the query with its leading "?", or "?" alone when there is none). Fragments are dropped.
11
14
  */
12
- export function normalizePath(rawPath) {
13
- if (!rawPath || !rawPath.trim())
14
- return "/";
15
- let p = rawPath.trim();
16
- p = p.replace(/\/+/g, "/");
15
+ export function splitRequestTarget(rawTarget) {
16
+ if (!rawTarget || !rawTarget.trim())
17
+ return { path: "/", query: "?" };
18
+ const t = rawTarget.trim().replace(/^[^?#]*/, (p) => p.replace(/\/+/g, "/"));
17
19
  try {
18
- const parsed = new URL(p, "http://localhost");
19
- return parsed.pathname + parsed.search;
20
+ const parsed = new URL(t, "http://localhost");
21
+ return { path: parsed.pathname, query: parsed.search || "?" };
20
22
  }
21
23
  catch {
22
- return p.startsWith("/") ? p : `/${p}`;
24
+ const p = t.replace(/[?#].*$/, "");
25
+ return { path: p.startsWith("/") ? p : `/${p}`, query: "?" };
23
26
  }
24
27
  }
28
+ /**
29
+ * Normalizes an HTTP request path per RFC 3986 into the "@path" component. Path only: the query string is
30
+ * a separate "@query" component (see `splitRequestTarget`).
31
+ */
32
+ export function normalizePath(rawPath) {
33
+ return splitRequestTarget(rawPath).path;
34
+ }
35
+ /** Methods whose requests always cover "content-digest" (of the empty body when there is none). */
36
+ const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
37
+ /** Derived components this implementation can compute; header field names must be lowercase. */
38
+ const DERIVED_COMPONENTS = new Set(["@method", "@path", "@query", "@authority"]);
39
+ const COMPONENT_NAME_PATTERN = /^@?[a-z0-9][a-z0-9_.-]*$/;
25
40
  /**
26
41
  * Validates that an identifier (keyId, serviceSlug, etc.) contains only safe alphanumeric/hyphen characters.
27
42
  */
@@ -31,81 +46,184 @@ export function assertSafeIdentifier(name, value) {
31
46
  }
32
47
  }
33
48
  /**
34
- * In-memory LRU / sliding cache for nonces / signature replay protection.
49
+ * Upper bound on remembered signatures. Unexpired entries are never evicted: once the cache is full of them,
50
+ * new signatures are rejected (fail closed) until entries expire. Entries live 2 x maxClockSkewSeconds
51
+ * (600s by default), so the default sustains ~160 verified requests/s per process.
52
+ */
53
+ export const REPLAY_CACHE_MAX_ENTRIES = 100_000;
54
+ /**
55
+ * In-memory replay cache, keyed on keyid + decoded signature bytes (so re-encodings of the
56
+ * same signature collide). Per process only: multi-instance deployments need a shared `ReplayStore`.
35
57
  */
36
- class SignatureReplayCache {
58
+ export class SignatureReplayCache {
59
+ maxEntries;
37
60
  seen = new Map();
38
- ttlMs;
39
- constructor(ttlSeconds = 360) {
40
- this.ttlMs = ttlSeconds * 1000;
41
- }
42
- checkAndRecord(signatureId) {
43
- const now = Date.now();
44
- if (this.seen.size > 5000) {
45
- for (const [k, exp] of this.seen.entries()) {
61
+ constructor(maxEntries = REPLAY_CACHE_MAX_ENTRIES) {
62
+ this.maxEntries = maxEntries;
63
+ }
64
+ get size() {
65
+ return this.seen.size;
66
+ }
67
+ /** True when the key was recorded and has not expired yet (a replay). */
68
+ has(key, now = Date.now()) {
69
+ const expiresAt = this.seen.get(key);
70
+ return expiresAt !== undefined && expiresAt > now;
71
+ }
72
+ /**
73
+ * Records `key` until `now + ttlMs`. Returns false, recording nothing, when the key is already present
74
+ * or the cache is full of unexpired entries (a still-valid signature is never forgotten early).
75
+ */
76
+ record(key, ttlMs, now = Date.now()) {
77
+ if (this.has(key, now))
78
+ return false;
79
+ this.seen.delete(key);
80
+ if (this.seen.size >= this.maxEntries) {
81
+ for (const [k, exp] of this.seen) {
46
82
  if (exp <= now)
47
83
  this.seen.delete(k);
48
84
  }
85
+ if (this.seen.size >= this.maxEntries)
86
+ return false;
49
87
  }
50
- const expiresAt = this.seen.get(signatureId);
51
- if (expiresAt && expiresAt > now) {
52
- return false; // Replay detected
53
- }
54
- this.seen.set(signatureId, now + this.ttlMs);
88
+ this.seen.set(key, now + ttlMs);
55
89
  return true;
56
90
  }
91
+ clear() {
92
+ this.seen.clear();
93
+ }
57
94
  }
58
95
  export const globalReplayCache = new SignatureReplayCache();
59
96
  /**
60
97
  * Format the RFC 9421 Signature-Input parameters string.
61
98
  * Example: `("@method" "@path" "@authority" "content-digest");created=1791561600;keyid="...";alg="ed25519";nonce="..."`
99
+ * Every value is validated, so none can close its quoted string and inject parameters.
62
100
  */
63
101
  export function formatSignatureParams(params) {
64
102
  const components = params.components ?? ["@method", "@path", "@authority", "content-digest"];
103
+ for (const c of components) {
104
+ if (!COMPONENT_NAME_PATTERN.test(c) || c === "@signature-params") {
105
+ throw new Error(`Invalid covered component: ${JSON.stringify(c)}`);
106
+ }
107
+ }
108
+ if (!Number.isSafeInteger(params.created) || params.created < 0) {
109
+ throw new Error("Invalid created: must be a non-negative integer of unix seconds");
110
+ }
111
+ const alg = params.alg ?? SIGNATURE_ALG;
112
+ assertSafeIdentifier("keyid", params.keyid);
113
+ assertSafeIdentifier("alg", alg);
65
114
  const componentsStr = components.map((c) => `"${c}"`).join(" ");
66
- let out = `(${componentsStr});created=${params.created};keyid="${params.keyid}";alg="${params.alg ?? SIGNATURE_ALG}"`;
115
+ let out = `(${componentsStr});created=${params.created};keyid="${params.keyid}";alg="${alg}"`;
67
116
  if (params.nonce) {
117
+ assertSafeIdentifier("nonce", params.nonce);
68
118
  out += `;nonce="${params.nonce}"`;
69
119
  }
70
120
  return out;
71
121
  }
72
122
  /**
73
- * Construct canonical RFC 9421 signature base string.
123
+ * Parses a Signature-Input member value (RFC 8941 inner list of plain quoted component names, then
124
+ * `;key=value` parameters with integer or string values). Returns an error string when malformed.
125
+ */
126
+ function parseSignatureParams(input) {
127
+ const listMatch = input.match(/^\(([^()]*)\)(.*)$/);
128
+ if (!listMatch)
129
+ return "Signature-Input missing covered components list";
130
+ const inner = listMatch[1].trim();
131
+ const components = [];
132
+ for (const item of inner ? inner.split(/ +/) : []) {
133
+ const m = item.match(/^"([^"\\]*)"$/);
134
+ if (!m || !COMPONENT_NAME_PATTERN.test(m[1])) {
135
+ return `Unsupported covered component in Signature-Input: ${item}`;
136
+ }
137
+ components.push(m[1]);
138
+ }
139
+ const params = {};
140
+ const paramPattern = /;([a-z*][a-z0-9_.*-]*)=(\d{1,15}|"[^"\\]*")/y;
141
+ let rest = listMatch[2];
142
+ while (rest.length > 0) {
143
+ paramPattern.lastIndex = 0;
144
+ const m = paramPattern.exec(rest);
145
+ if (!m)
146
+ return "Malformed Signature-Input parameters";
147
+ if (Object.prototype.hasOwnProperty.call(params, m[1]))
148
+ return `Duplicate Signature-Input parameter: ${m[1]}`;
149
+ params[m[1]] = m[2].startsWith('"') ? m[2].slice(1, -1) : parseInt(m[2], 10);
150
+ rest = rest.slice(m[0].length);
151
+ }
152
+ return { components, params };
153
+ }
154
+ /**
155
+ * Construct canonical RFC 9421 signature base string from the covered components, in order. They are
156
+ * `components` when given, else the list declared in `signatureParams`. "@path" and "@query" come from
157
+ * `path` (which may carry a query string); "content-digest" from `contentDigest`; any other header field
158
+ * from `additionalComponents`. Throws when a covered component has no value.
74
159
  */
75
160
  export function createSignatureBase(params) {
76
- const method = params.method.toUpperCase();
77
- const path = normalizePath(params.path);
78
- const authority = params.authority.toLowerCase();
79
- const lines = [
80
- `"@method": ${method}`,
81
- `"@path": ${path}`,
82
- `"@authority": ${authority}`,
83
- ];
84
- if (params.contentDigest !== undefined && params.contentDigest !== null) {
85
- lines.push(`"content-digest": ${params.contentDigest}`);
161
+ const target = splitRequestTarget(params.path);
162
+ let components = params.components;
163
+ if (!components) {
164
+ const parsed = parseSignatureParams(params.signatureParams);
165
+ components =
166
+ typeof parsed !== "string"
167
+ ? parsed.components
168
+ : [
169
+ "@method",
170
+ "@path",
171
+ "@authority",
172
+ ...(params.contentDigest !== undefined && params.contentDigest !== null ? ["content-digest"] : []),
173
+ ...Object.keys(params.additionalComponents ?? {}),
174
+ ];
86
175
  }
87
- if (params.additionalComponents) {
88
- for (const [k, v] of Object.entries(params.additionalComponents)) {
89
- lines.push(`"${k.toLowerCase()}": ${v}`);
176
+ const additional = {};
177
+ for (const [k, v] of Object.entries(params.additionalComponents ?? {}))
178
+ additional[k.toLowerCase()] = v;
179
+ const lines = [];
180
+ for (const raw of components) {
181
+ const name = raw.toLowerCase();
182
+ let value;
183
+ if (name === "@method")
184
+ value = params.method.toUpperCase();
185
+ else if (name === "@path")
186
+ value = target.path;
187
+ else if (name === "@query")
188
+ value = target.query;
189
+ else if (name === "@authority")
190
+ value = params.authority.toLowerCase();
191
+ else if (name.startsWith("@"))
192
+ throw new Error(`Unsupported derived component: ${name}`);
193
+ else if (name === "content-digest")
194
+ value = params.contentDigest ?? additional[name];
195
+ else
196
+ value = additional[name];
197
+ if (value === undefined || value === null) {
198
+ throw new Error(`Covered component ${name} has no value`);
90
199
  }
200
+ value = value.trim();
201
+ if (/[\r\n]/.test(value)) {
202
+ throw new Error(`Covered component ${name} contains a line break`);
203
+ }
204
+ lines.push(`"${name}": ${value}`);
91
205
  }
92
206
  lines.push(`"@signature-params": ${params.signatureParams}`);
93
207
  return lines.join("\n");
94
208
  }
95
209
  /**
96
210
  * Sign an outgoing HTTP request according to RFC 9421 + RFC 9530.
211
+ * Covers "@method", "@path", "@query" (only when `path` has a query string), "@authority" and
212
+ * "content-digest" (whenever there is a body, and always for POST / PUT / PATCH).
97
213
  */
98
214
  export async function signRequest(params) {
99
215
  const signingKey = await loadSigningKey(params.key);
100
216
  const created = params.created ?? Math.floor(Date.now() / 1000);
101
217
  const keyid = params.keyid ?? signingKey.publicJwk.kid;
102
218
  const alg = params.alg ?? SIGNATURE_ALG;
103
- if (keyid) {
104
- assertSafeIdentifier("keyid", keyid);
219
+ const method = params.method.toUpperCase();
220
+ const body = params.body ?? (BODY_METHODS.has(method) ? "" : null);
221
+ const contentDigest = body !== null ? await computeContentDigest(body) : null;
222
+ const components = ["@method", "@path"];
223
+ if (splitRequestTarget(params.path).query !== "?") {
224
+ components.push("@query");
105
225
  }
106
- const hasBody = params.body !== undefined && params.body !== null;
107
- const contentDigest = hasBody ? await computeContentDigest(params.body) : null;
108
- const components = ["@method", "@path", "@authority"];
226
+ components.push("@authority");
109
227
  if (contentDigest !== null) {
110
228
  components.push("content-digest");
111
229
  }
@@ -117,11 +235,12 @@ export async function signRequest(params) {
117
235
  nonce: params.nonce,
118
236
  });
119
237
  const signatureBase = createSignatureBase({
120
- method: params.method,
238
+ method,
121
239
  path: params.path,
122
240
  authority: params.authority,
123
241
  contentDigest,
124
242
  signatureParams,
243
+ components,
125
244
  });
126
245
  const signatureBuffer = await globalThis.crypto.subtle.sign("Ed25519", signingKey.privateKey, stringToUtf8(signatureBase));
127
246
  const signatureBase64 = bytesToBase64(new Uint8Array(signatureBuffer));
@@ -138,7 +257,10 @@ export async function signRequest(params) {
138
257
  return headers;
139
258
  }
140
259
  /**
141
- * Verify an incoming RFC 9421 HTTP Message Signature.
260
+ * Verify an incoming RFC 9421 HTTP Message Signature. The signature base is built from the components the
261
+ * signature declares, which must include "@method", "@path" and "@authority", plus "@query" when the request
262
+ * has a query string and "content-digest" when it has a body or is a POST / PUT / PATCH.
263
+ * Replay protection uses `replayStore` (default: per-process memory; use a shared store across instances).
142
264
  */
143
265
  export async function verifyRequestSignature(params) {
144
266
  const getHeader = (name) => {
@@ -164,41 +286,55 @@ export async function verifyRequestSignature(params) {
164
286
  }
165
287
  const signatureParams = sigInputMatch[1].trim();
166
288
  // Validate parameters and check for component duplicates
167
- const coveredComponentsMatch = signatureParams.match(/^\(([^)]*)\)/);
168
- if (!coveredComponentsMatch) {
169
- return { valid: false, error: "Signature-Input missing covered components list" };
289
+ const parsed = parseSignatureParams(signatureParams);
290
+ if (typeof parsed === "string") {
291
+ return { valid: false, error: parsed };
170
292
  }
171
- const rawComponents = coveredComponentsMatch[1].trim().split(/\s+/).filter(Boolean);
293
+ const components = parsed.components;
172
294
  const seenComponents = new Set();
173
- for (const c of rawComponents) {
295
+ for (const c of components) {
174
296
  if (seenComponents.has(c)) {
175
297
  return { valid: false, error: `Duplicate covered component in Signature-Input: ${c}` };
176
298
  }
177
299
  seenComponents.add(c);
178
300
  }
179
- const sigMatch = signatureHeader.match(new RegExp(`(?:^|,\\s*)${SIGNATURE_LABEL}=:([A-Za-z0-9+/=_-]+):`));
180
- if (!sigMatch) {
301
+ for (const required of ["@method", "@path", "@authority"]) {
302
+ if (!seenComponents.has(required)) {
303
+ return { valid: false, error: `Signature does not cover ${required}` };
304
+ }
305
+ }
306
+ if (splitRequestTarget(params.path).query !== "?" && !seenComponents.has("@query")) {
307
+ return { valid: false, error: "Request has a query string but the signature does not cover @query" };
308
+ }
309
+ const body = params.body;
310
+ const hasBody = body !== undefined && body !== null && (typeof body === "string" ? body.length > 0 : body.byteLength > 0);
311
+ if ((hasBody || BODY_METHODS.has(params.method.toUpperCase())) && !seenComponents.has("content-digest")) {
312
+ return { valid: false, error: "Request has a body but the signature does not cover content-digest" };
313
+ }
314
+ // RFC 8941 byte sequence (`:base64:`), standard alphabet with padding only, so each signature
315
+ // has exactly one accepted encoding.
316
+ const sigMatch = signatureHeader.match(new RegExp(`(?:^|,\\s*)${SIGNATURE_LABEL}=:([A-Za-z0-9+/]*={0,2}):(?=\\s*(?:,|$))`));
317
+ if (!sigMatch || sigMatch[1].length % 4 !== 0) {
318
+ return { valid: false, error: `Signature header does not contain valid ${SIGNATURE_LABEL} byte sequence` };
319
+ }
320
+ const signatureBytes = base64ToBytes(sigMatch[1]);
321
+ if (bytesToBase64(signatureBytes) !== sigMatch[1]) {
181
322
  return { valid: false, error: `Signature header does not contain valid ${SIGNATURE_LABEL} byte sequence` };
182
323
  }
183
- const signatureBase64 = sigMatch[1].trim();
184
- const createdMatch = signatureParams.match(/created=(\d+)/);
185
- if (!createdMatch) {
324
+ const created = parsed.params.created;
325
+ if (typeof created !== "number") {
186
326
  return { valid: false, error: "Signature-Input missing created timestamp" };
187
327
  }
188
- const created = parseInt(createdMatch[1], 10);
189
328
  const now = params.currentTime ?? Math.floor(Date.now() / 1000);
190
329
  const maxSkew = params.maxClockSkewSeconds ?? MAX_CLOCK_SKEW_SECONDS;
191
330
  if (Math.abs(now - created) > maxSkew) {
192
331
  return { valid: false, error: `Signature timestamp expired or clock skew too large (created=${created}, now=${now})` };
193
332
  }
194
- if (params.replayCheck !== false) {
195
- const replayKey = `${signatureBase64}:${created}`;
196
- if (!globalReplayCache.checkAndRecord(replayKey)) {
197
- return { valid: false, error: "Replay detected: request signature was already presented" };
198
- }
333
+ const keyidParam = parsed.params.keyid;
334
+ if (keyidParam !== undefined && typeof keyidParam !== "string") {
335
+ return { valid: false, error: "Signature-Input keyid must be a string" };
199
336
  }
200
- const keyidMatch = signatureParams.match(/keyid="([^"]+)"/);
201
- const keyid = keyidMatch ? keyidMatch[1] : undefined;
337
+ const keyid = keyidParam || undefined;
202
338
  if (keyid) {
203
339
  try {
204
340
  assertSafeIdentifier("keyid", keyid);
@@ -207,12 +343,18 @@ export async function verifyRequestSignature(params) {
207
343
  return { valid: false, error: e.message };
208
344
  }
209
345
  }
210
- const algMatch = signatureParams.match(/alg="([^"]+)"/);
211
- const alg = algMatch ? algMatch[1] : undefined;
212
- if (alg && alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa") {
346
+ // Entries must outlive the whole acceptance window (created ± maxSkew).
347
+ const replayStore = params.replayStore ?? globalReplayCache;
348
+ const replayKey = `${keyid ?? ""}:${bytesToHex(signatureBytes)}`;
349
+ const replayTtlMs = 2 * Math.max(maxSkew, 1) * 1000;
350
+ if (params.replayCheck !== false && (await replayStore.has(replayKey))) {
351
+ return { valid: false, error: "Replay detected: request signature was already presented" };
352
+ }
353
+ const alg = parsed.params.alg;
354
+ if (alg !== undefined && (typeof alg !== "string" || (alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa"))) {
213
355
  return { valid: false, error: `Unsupported signature algorithm: ${alg}` };
214
356
  }
215
- if (signatureParams.includes('"content-digest"')) {
357
+ if (seenComponents.has("content-digest")) {
216
358
  if (!contentDigestHeader) {
217
359
  return { valid: false, error: "Signature covers content-digest but Content-Digest header is missing" };
218
360
  }
@@ -221,30 +363,56 @@ export async function verifyRequestSignature(params) {
221
363
  return { valid: false, error: `Content-Digest mismatch: expected ${computedDigest}, got ${contentDigestHeader}` };
222
364
  }
223
365
  }
224
- const signatureBase = createSignatureBase({
225
- method: params.method,
226
- path: params.path,
227
- authority: params.authority,
228
- contentDigest: signatureParams.includes('"content-digest"') ? contentDigestHeader?.trim() : null,
229
- signatureParams,
230
- });
231
366
  try {
232
- const { publicKey } = await loadPublicKey(params.publicKey);
233
- const signatureBytes = signatureBase64.includes("-") || signatureBase64.includes("_")
234
- ? base64UrlToBytes(signatureBase64)
235
- : base64ToBytes(signatureBase64);
367
+ const additionalComponents = {};
368
+ for (const c of components) {
369
+ if (c.startsWith("@") || c === "content-digest")
370
+ continue;
371
+ const value = getHeader(c);
372
+ if (value === undefined) {
373
+ return { valid: false, error: `Signature covers ${c} but the request has no such header` };
374
+ }
375
+ additionalComponents[c] = value;
376
+ }
377
+ const signatureBase = createSignatureBase({
378
+ method: params.method,
379
+ path: params.path,
380
+ authority: params.authority,
381
+ contentDigest: contentDigestHeader?.trim() ?? null,
382
+ signatureParams,
383
+ additionalComponents,
384
+ components,
385
+ });
386
+ const { publicKey } = await loadPublicKey(params.publicKey, keyid);
236
387
  const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, stringToUtf8(signatureBase));
237
388
  if (!isValid) {
238
389
  return { valid: false, error: "Cryptographic signature verification failed" };
239
390
  }
391
+ // Record only verified signatures. `record` is set-if-absent, so a concurrent copy that won the race,
392
+ // or a store too full to remember this signature, makes it fail closed.
393
+ if (params.replayCheck !== false && !(await replayStore.record(replayKey, replayTtlMs))) {
394
+ const full = !(await replayStore.has(replayKey));
395
+ return {
396
+ valid: false,
397
+ error: full
398
+ ? "Replay cache is full: refusing a signature that could not be remembered"
399
+ : "Replay detected: request signature was already presented",
400
+ };
401
+ }
240
402
  return { valid: true, keyid };
241
403
  }
242
404
  catch (err) {
243
405
  return { valid: false, error: `Verification error: ${err.message}` };
244
406
  }
245
407
  }
408
+ /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
409
+ export const JWT_CLOCK_SKEW_SECONDS = 60;
246
410
  /**
247
411
  * Zero-egress offline verification for asymmetric JWT token leases.
412
+ * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
413
+ * a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set
414
+ * (Daywalker documents no fixed issuer, so there is no default). Segments must be canonical unpadded
415
+ * base64url, a `crit` header is rejected, and a JWKS `publicKey` is searched by the header's `kid`.
248
416
  */
249
417
  export async function verifyJwtLease(params) {
250
418
  try {
@@ -253,35 +421,77 @@ export async function verifyJwtLease(params) {
253
421
  return { valid: false, error: "Token is not a valid 3-part JWT" };
254
422
  }
255
423
  const [headerB64, payloadB64, signatureB64] = parts;
256
- const headerJson = utf8ToString(base64UrlToBytes(headerB64));
257
- const payloadJson = utf8ToString(base64UrlToBytes(payloadB64));
424
+ let headerJson;
425
+ let payloadJson;
426
+ let signatureBytes;
427
+ try {
428
+ headerJson = utf8ToString(base64UrlToBytesStrict(headerB64));
429
+ payloadJson = utf8ToString(base64UrlToBytesStrict(payloadB64));
430
+ signatureBytes = base64UrlToBytesStrict(signatureB64);
431
+ }
432
+ catch {
433
+ return { valid: false, error: "JWT segments must be canonical unpadded base64url" };
434
+ }
258
435
  const header = JSON.parse(headerJson);
259
436
  const payload = JSON.parse(payloadJson);
437
+ if (!header || typeof header !== "object" || !payload || typeof payload !== "object" || Array.isArray(payload)) {
438
+ return { valid: false, error: "JWT header and payload must be JSON objects" };
439
+ }
260
440
  if (header.alg !== "EdDSA" && header.alg !== "ed25519") {
261
441
  return { valid: false, error: `Unsupported JWT alg: ${header.alg}` };
262
442
  }
263
- const { publicKey } = await loadPublicKey(params.publicKey);
443
+ if (header.typ !== undefined && String(header.typ).toUpperCase() !== "JWT") {
444
+ return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
445
+ }
446
+ // RFC 7515 §4.1.11: extensions we don't understand must be rejected; we implement none.
447
+ if (header.crit !== undefined) {
448
+ return { valid: false, error: "Unsupported JWT crit header" };
449
+ }
450
+ if (header.kid !== undefined && typeof header.kid !== "string") {
451
+ return { valid: false, error: "JWT kid must be a string" };
452
+ }
453
+ const { publicKey } = await loadPublicKey(params.publicKey, header.kid);
264
454
  const signedData = stringToUtf8(`${headerB64}.${payloadB64}`);
265
- const signatureBytes = base64UrlToBytes(signatureB64);
266
455
  const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, signedData);
267
456
  if (!isValid) {
268
457
  return { valid: false, error: "JWT signature verification failed" };
269
458
  }
270
459
  const now = params.currentTime ?? Math.floor(Date.now() / 1000);
271
- if (payload.exp && typeof payload.exp === "number" && payload.exp < now) {
460
+ const skew = Math.max(0, Math.min(params.clockSkewSeconds ?? JWT_CLOCK_SKEW_SECONDS, 300));
461
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
462
+ return { valid: false, error: "JWT token lease has no numeric exp claim" };
463
+ }
464
+ if (payload.exp + skew <= now) {
272
465
  return { valid: false, error: "JWT token lease has expired" };
273
466
  }
274
- if (payload.nbf && typeof payload.nbf === "number" && payload.nbf > now + 60) {
467
+ if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || payload.nbf > now + skew)) {
275
468
  return { valid: false, error: "JWT token lease not active yet" };
276
469
  }
277
- if (params.expectedServiceSlug) {
278
- const tokenSlug = (payload.serviceSlug || payload.aud || "").toLowerCase();
279
- if (tokenSlug && tokenSlug !== params.expectedServiceSlug.toLowerCase()) {
280
- return {
281
- valid: false,
282
- error: `JWT audience mismatch: expected ${params.expectedServiceSlug}, got ${tokenSlug}`,
283
- };
284
- }
470
+ const expected = params.expectedServiceSlug?.trim().toLowerCase();
471
+ if (!expected) {
472
+ return { valid: false, error: "JWT audience cannot be checked: no expected service slug is configured" };
473
+ }
474
+ const audiences = [];
475
+ if (payload.serviceSlug !== undefined)
476
+ audiences.push(String(payload.serviceSlug));
477
+ if (Array.isArray(payload.aud))
478
+ audiences.push(...payload.aud.map(String));
479
+ else if (payload.aud !== undefined)
480
+ audiences.push(String(payload.aud));
481
+ if (audiences.length === 0) {
482
+ return { valid: false, error: "JWT token lease has no aud or serviceSlug claim" };
483
+ }
484
+ const serviceSlugOk = payload.serviceSlug === undefined || String(payload.serviceSlug).toLowerCase() === expected;
485
+ const audOk = payload.aud === undefined ||
486
+ (Array.isArray(payload.aud) ? payload.aud : [payload.aud]).some((a) => String(a).toLowerCase() === expected);
487
+ if (!serviceSlugOk || !audOk) {
488
+ return {
489
+ valid: false,
490
+ error: `JWT audience mismatch: expected ${expected}, got ${audiences.join(", ").toLowerCase()}`,
491
+ };
492
+ }
493
+ if (params.expectedIssuer && payload.iss !== params.expectedIssuer) {
494
+ return { valid: false, error: `JWT issuer mismatch: expected ${params.expectedIssuer}, got ${payload.iss ?? "(none)"}` };
285
495
  }
286
496
  return { valid: true, payload };
287
497
  }
@@ -294,6 +504,12 @@ export async function verifyJwtLease(params) {
294
504
  */
295
505
  export async function registerPublicKey(params) {
296
506
  const authUrl = (params.authUrl ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
507
+ try {
508
+ assertSecureUrl("authUrl", authUrl);
509
+ }
510
+ catch (err) {
511
+ return { ok: false, status: 0, error: err.message };
512
+ }
297
513
  const registerUrl = `${authUrl}/api/v1/keys/register`;
298
514
  const doFetch = params.fetchFn ?? globalThis.fetch;
299
515
  if (!params.domain) {
package/dist/express.d.ts CHANGED
@@ -20,6 +20,7 @@ interface Res {
20
20
  */
21
21
  export declare function daywalkerAuth(options?: {
22
22
  client?: Daywalker;
23
+ serviceSlug?: string;
23
24
  }): (req: Req, res: Res, next?: (err?: unknown) => void) => Promise<void>;
24
25
  export declare function daywalkerKeys(client?: Daywalker): (req: Req, res: Res, next?: () => void) => Promise<void>;
25
26
  export {};
package/dist/express.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { daywalker } from "./client.js";
2
+ import { toAuthFailureResponse } from "./middleware.js";
2
3
  /**
3
4
  * Express / Connect / Node http middleware that validates incoming Daywalker tokens.
4
5
  */
5
6
  export function daywalkerAuth(options = {}) {
6
7
  const client = options.client ?? daywalker;
8
+ const serviceSlug = options.serviceSlug;
7
9
  return async (req, res, next) => {
8
10
  const getHeader = (name) => {
9
11
  if (typeof req.get === "function")
@@ -33,19 +35,19 @@ export function daywalkerAuth(options = {}) {
33
35
  res.end(JSON.stringify({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" }));
34
36
  return;
35
37
  }
36
- const result = await client.validateToken(token);
38
+ const result = await client.validateToken(token, { serviceSlug });
37
39
  if (!result.ok) {
38
- const status = result.status === 429 ? 429 : 401;
39
- if (result.retryAfter) {
40
- res.setHeader("Retry-After", String(result.retryAfter));
40
+ const { status, ...body } = toAuthFailureResponse(result);
41
+ if (body.retryAfter) {
42
+ res.setHeader("Retry-After", String(body.retryAfter));
41
43
  }
42
44
  if (typeof res.status === "function" && typeof res.json === "function") {
43
- res.status(status).json({ error: result.error, message: result.message, retryAfter: result.retryAfter });
45
+ res.status(status).json(body);
44
46
  return;
45
47
  }
46
48
  res.statusCode = status;
47
49
  res.setHeader("Content-Type", "application/json");
48
- res.end(JSON.stringify({ error: result.error, message: result.message, retryAfter: result.retryAfter }));
50
+ res.end(JSON.stringify(body));
49
51
  return;
50
52
  }
51
53
  req.auth = result.data;