@shelbyb/daywalker-sdk 0.3.0 → 0.4.1

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 (54) hide show
  1. package/README.md +64 -23
  2. package/dist/analytics/index.d.ts +16 -2
  3. package/dist/analytics/index.js +79 -14
  4. package/dist/cli.js +133 -31
  5. package/dist/client.d.ts +29 -2
  6. package/dist/client.js +314 -62
  7. package/dist/config.d.ts +8 -2
  8. package/dist/config.js +14 -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 +42 -12
  16. package/dist/crypto/signatures.js +236 -79
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +32 -4
  20. package/dist/flags/client.js +74 -12
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +51 -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/index.d.ts +1 -1
  30. package/dist/flags/engine/index.js +1 -1
  31. package/dist/flags/engine/operators.d.ts +20 -3
  32. package/dist/flags/engine/operators.js +38 -32
  33. package/dist/flags/node.d.ts +26 -14
  34. package/dist/flags/node.js +51 -7
  35. package/dist/flags/react.d.ts +6 -4
  36. package/dist/flags/react.js +47 -17
  37. package/dist/index.d.ts +7 -6
  38. package/dist/index.js +4 -4
  39. package/dist/middleware.d.ts +18 -0
  40. package/dist/middleware.js +28 -7
  41. package/dist/next.d.ts +52 -8
  42. package/dist/next.js +151 -37
  43. package/dist/openfeature/server.d.ts +3 -0
  44. package/dist/openfeature/server.js +14 -2
  45. package/dist/openfeature/shared.d.ts +1 -1
  46. package/dist/openfeature/shared.js +22 -2
  47. package/dist/openfeature/web.js +9 -3
  48. package/dist/types.d.ts +56 -4
  49. package/dist/types.js +6 -0
  50. package/docs/analytics.md +1 -1
  51. package/docs/flags-node.md +1 -1
  52. package/docs/flags.md +2 -1
  53. package/package.json +32 -14
  54. package/assets/logo-512.png +0 -0
@@ -131,9 +131,27 @@ export async function loadSigningKey(raw) {
131
131
  };
132
132
  }
133
133
  /**
134
- * Load an Ed25519 public key from JWK, PEM, Base64 SPKI, or Base64url raw public key.
134
+ * Picks the Ed25519 key with this `kid` from a JWKS, or its only Ed25519 key (the signature check still
135
+ * has to pass against it). Several keys and no matching `kid` is an error rather than a guess.
135
136
  */
136
- export async function loadPublicKey(raw) {
137
+ function selectJwk(jwks, kid) {
138
+ const keys = (Array.isArray(jwks.keys) ? jwks.keys : []).filter((k) => !!k && typeof k === "object" && k.kty === "OKP" && k.crv === "Ed25519");
139
+ const byKid = kid ? keys.find((k) => k.kid === kid) : undefined;
140
+ if (byKid)
141
+ return byKid;
142
+ if (keys.length === 1)
143
+ return keys[0];
144
+ if (keys.length === 0)
145
+ throw new Error("JWKS contains no Ed25519 keys");
146
+ throw new Error(kid ? `No key in JWKS matches kid "${kid}"` : "JWKS has several keys and the signature names no kid");
147
+ }
148
+ /**
149
+ * Load an Ed25519 public key from JWK, JWKS (selected by `kid`), PEM, Base64 SPKI, or Base64url raw public key.
150
+ */
151
+ export async function loadPublicKey(raw, kid) {
152
+ if (typeof raw === "object" && raw && "keys" in raw) {
153
+ return loadPublicKey(selectJwk(raw, kid));
154
+ }
137
155
  if (typeof raw === "object" && "type" in raw && raw.type === "public") {
138
156
  const exported = (await globalThis.crypto.subtle.exportKey("jwk", raw));
139
157
  const kid = exported.kid || (await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: exported.x }));
@@ -170,16 +188,19 @@ export async function loadPublicKey(raw) {
170
188
  const trimmed = raw.trim();
171
189
  // Try parsing as JSON JWK or JWKS
172
190
  if (trimmed.startsWith("{")) {
191
+ let parsed;
173
192
  try {
174
- const parsed = JSON.parse(trimmed);
175
- const jwkCandidate = parsed.keys ? parsed.keys[0] : parsed;
176
- if (jwkCandidate && jwkCandidate.kty === "OKP" && jwkCandidate.crv === "Ed25519") {
177
- return loadPublicKey(jwkCandidate);
178
- }
193
+ parsed = JSON.parse(trimmed);
179
194
  }
180
195
  catch {
181
196
  // Continue
182
197
  }
198
+ if (parsed && typeof parsed === "object") {
199
+ const jwkCandidate = parsed.keys ? selectJwk(parsed, kid) : parsed;
200
+ if (jwkCandidate.kty === "OKP" && jwkCandidate.crv === "Ed25519") {
201
+ return loadPublicKey(jwkCandidate);
202
+ }
203
+ }
183
204
  }
184
205
  // Handle PEM / Base64 / Base64url
185
206
  let cleaned = trimmed
@@ -1,38 +1,57 @@
1
- import type { PublicJwk, SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult } from "../types.js";
1
+ import type { PublicJwk, PublicJwks, ReplayStore, SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult } from "../types.js";
2
2
  export declare const SIGNATURE_VERSION = "rfc9421";
3
3
  export declare const SIGNATURE_LABEL = "sig1";
4
4
  export declare const SIGNATURE_ALG = "ed25519";
5
5
  export declare const MAX_CLOCK_SKEW_SECONDS = 300;
6
6
  export declare const SAFE_IDENTIFIER_PATTERN: RegExp;
7
7
  /**
8
- * Normalizes HTTP request path per RFC 3986 to prevent path traversal & signature confusion.
8
+ * Splits a request target into its RFC 9421 "@path" value (§2.2.6; dot segments resolved and repeated
9
+ * slashes collapsed per RFC 3986, to prevent path traversal & signature confusion) and its "@query" value
10
+ * (§2.2.7; the query with its leading "?", or "?" alone when there is none). Fragments are dropped.
11
+ */
12
+ export declare function splitRequestTarget(rawTarget: string): {
13
+ path: string;
14
+ query: string;
15
+ };
16
+ /**
17
+ * Normalizes an HTTP request path per RFC 3986 into the "@path" component. Path only: the query string is
18
+ * a separate "@query" component (see `splitRequestTarget`).
9
19
  */
10
20
  export declare function normalizePath(rawPath: string): string;
11
21
  /**
12
22
  * Validates that an identifier (keyId, serviceSlug, etc.) contains only safe alphanumeric/hyphen characters.
13
23
  */
14
24
  export declare function assertSafeIdentifier(name: string, value: string): void;
15
- /** Upper bound on remembered signatures; the oldest entries are evicted past this. */
16
- export declare const REPLAY_CACHE_MAX_ENTRIES = 10000;
25
+ /**
26
+ * Upper bound on remembered signatures. Unexpired entries are never evicted: once the cache is full of them,
27
+ * new signatures are rejected (fail closed) until entries expire. Entries live 2 x maxClockSkewSeconds
28
+ * (600s by default), so the default sustains ~160 verified requests/s per process.
29
+ */
30
+ export declare const REPLAY_CACHE_MAX_ENTRIES = 100000;
17
31
  /**
18
32
  * In-memory replay cache, keyed on keyid + decoded signature bytes (so re-encodings of the
19
- * same signature collide). Insertion-ordered, so eviction drops the oldest entries first.
33
+ * same signature collide). Per process only: multi-instance deployments need a shared `ReplayStore`.
20
34
  */
21
- export declare class SignatureReplayCache {
35
+ export declare class SignatureReplayCache implements ReplayStore {
22
36
  private readonly maxEntries;
23
37
  private seen;
24
38
  constructor(maxEntries?: number);
25
39
  get size(): number;
26
40
  /** True when the key was recorded and has not expired yet (a replay). */
27
41
  has(key: string, now?: number): boolean;
28
- record(key: string, ttlMs: number, now?: number): void;
42
+ /**
43
+ * Records `key` until `now + ttlMs`. Returns false, recording nothing, when the key is already present
44
+ * or the cache is full of unexpired entries (a still-valid signature is never forgotten early).
45
+ */
46
+ record(key: string, ttlMs: number, now?: number): boolean;
29
47
  clear(): void;
30
48
  }
31
49
  export declare const globalReplayCache: SignatureReplayCache;
32
- export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, };
50
+ export type { ReplayStore, SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, };
33
51
  /**
34
52
  * Format the RFC 9421 Signature-Input parameters string.
35
53
  * Example: `("@method" "@path" "@authority" "content-digest");created=1791561600;keyid="...";alg="ed25519";nonce="..."`
54
+ * Every value is validated, so none can close its quoted string and inject parameters.
36
55
  */
37
56
  export declare function formatSignatureParams(params: {
38
57
  components?: string[];
@@ -42,7 +61,10 @@ export declare function formatSignatureParams(params: {
42
61
  nonce?: string;
43
62
  }): string;
44
63
  /**
45
- * Construct canonical RFC 9421 signature base string.
64
+ * Construct canonical RFC 9421 signature base string from the covered components, in order. They are
65
+ * `components` when given, else the list declared in `signatureParams`. "@path" and "@query" come from
66
+ * `path` (which may carry a query string); "content-digest" from `contentDigest`; any other header field
67
+ * from `additionalComponents`. Throws when a covered component has no value.
46
68
  */
47
69
  export declare function createSignatureBase(params: {
48
70
  method: string;
@@ -51,13 +73,19 @@ export declare function createSignatureBase(params: {
51
73
  contentDigest?: string | null;
52
74
  signatureParams: string;
53
75
  additionalComponents?: Record<string, string>;
76
+ components?: string[];
54
77
  }): string;
55
78
  /**
56
79
  * Sign an outgoing HTTP request according to RFC 9421 + RFC 9530.
80
+ * Covers "@method", "@path", "@query" (only when `path` has a query string), "@authority" and
81
+ * "content-digest" (whenever there is a body, and always for POST / PUT / PATCH).
57
82
  */
58
83
  export declare function signRequest(params: SignRequestParams): Promise<SignedHeaders>;
59
84
  /**
60
- * Verify an incoming RFC 9421 HTTP Message Signature.
85
+ * Verify an incoming RFC 9421 HTTP Message Signature. The signature base is built from the components the
86
+ * signature declares, which must include "@method", "@path" and "@authority", plus "@query" when the request
87
+ * has a query string and "content-digest" when it has a body or is a POST / PUT / PATCH.
88
+ * Replay protection uses `replayStore` (default: per-process memory; use a shared store across instances).
61
89
  */
62
90
  export declare function verifyRequestSignature(params: VerifySignatureParams): Promise<VerifySignatureResult>;
63
91
  /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
@@ -65,11 +93,13 @@ export declare const JWT_CLOCK_SKEW_SECONDS = 60;
65
93
  /**
66
94
  * Zero-egress offline verification for asymmetric JWT token leases.
67
95
  * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
68
- * a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set.
96
+ * a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set
97
+ * (Daywalker documents no fixed issuer, so there is no default). Segments must be canonical unpadded
98
+ * base64url, a `crit` header is rejected, and a JWKS `publicKey` is searched by the header's `kid`.
69
99
  */
70
100
  export declare function verifyJwtLease(params: {
71
101
  token: string;
72
- publicKey: string | PublicJwk | CryptoKey;
102
+ publicKey: string | PublicJwk | PublicJwks | CryptoKey;
73
103
  expectedServiceSlug?: string;
74
104
  expectedIssuer?: string;
75
105
  currentTime?: number;
@@ -1,4 +1,4 @@
1
- import { base64ToBytes, base64UrlToBytes, bytesToBase64, bytesToHex, 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
4
  import { assertSecureUrl } from "../config.js";
@@ -8,21 +8,35 @@ export const SIGNATURE_ALG = "ed25519";
8
8
  export const MAX_CLOCK_SKEW_SECONDS = 300;
9
9
  export const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z0-9_\-\.]{1,128}$/;
10
10
  /**
11
- * 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.
12
14
  */
13
- export function normalizePath(rawPath) {
14
- if (!rawPath || !rawPath.trim())
15
- return "/";
16
- let p = rawPath.trim();
17
- 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, "/"));
18
19
  try {
19
- const parsed = new URL(p, "http://localhost");
20
- return parsed.pathname + parsed.search;
20
+ const parsed = new URL(t, "http://localhost");
21
+ return { path: parsed.pathname, query: parsed.search || "?" };
21
22
  }
22
23
  catch {
23
- return p.startsWith("/") ? p : `/${p}`;
24
+ const p = t.replace(/[?#].*$/, "");
25
+ return { path: p.startsWith("/") ? p : `/${p}`, query: "?" };
24
26
  }
25
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_.-]*$/;
26
40
  /**
27
41
  * Validates that an identifier (keyId, serviceSlug, etc.) contains only safe alphanumeric/hyphen characters.
28
42
  */
@@ -31,11 +45,15 @@ export function assertSafeIdentifier(name, value) {
31
45
  throw new Error(`Invalid ${name}: must be 1-128 safe characters (alphanumeric, '_', '-', '.')`);
32
46
  }
33
47
  }
34
- /** Upper bound on remembered signatures; the oldest entries are evicted past this. */
35
- export const REPLAY_CACHE_MAX_ENTRIES = 10_000;
48
+ /**
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;
36
54
  /**
37
55
  * In-memory replay cache, keyed on keyid + decoded signature bytes (so re-encodings of the
38
- * same signature collide). Insertion-ordered, so eviction drops the oldest entries first.
56
+ * same signature collide). Per process only: multi-instance deployments need a shared `ReplayStore`.
39
57
  */
40
58
  export class SignatureReplayCache {
41
59
  maxEntries;
@@ -51,18 +69,24 @@ export class SignatureReplayCache {
51
69
  const expiresAt = this.seen.get(key);
52
70
  return expiresAt !== undefined && expiresAt > now;
53
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
+ */
54
76
  record(key, ttlMs, now = Date.now()) {
77
+ if (this.has(key, now))
78
+ return false;
55
79
  this.seen.delete(key);
56
- this.seen.set(key, now + ttlMs);
57
- if (this.seen.size <= this.maxEntries)
58
- return;
59
- for (const [k, exp] of this.seen) {
60
- if (exp <= now)
61
- this.seen.delete(k);
62
- }
63
- while (this.seen.size > this.maxEntries) {
64
- this.seen.delete(this.seen.keys().next().value);
80
+ if (this.seen.size >= this.maxEntries) {
81
+ for (const [k, exp] of this.seen) {
82
+ if (exp <= now)
83
+ this.seen.delete(k);
84
+ }
85
+ if (this.seen.size >= this.maxEntries)
86
+ return false;
65
87
  }
88
+ this.seen.set(key, now + ttlMs);
89
+ return true;
66
90
  }
67
91
  clear() {
68
92
  this.seen.clear();
@@ -72,53 +96,134 @@ export const globalReplayCache = new SignatureReplayCache();
72
96
  /**
73
97
  * Format the RFC 9421 Signature-Input parameters string.
74
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.
75
100
  */
76
101
  export function formatSignatureParams(params) {
77
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);
78
114
  const componentsStr = components.map((c) => `"${c}"`).join(" ");
79
- 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}"`;
80
116
  if (params.nonce) {
117
+ assertSafeIdentifier("nonce", params.nonce);
81
118
  out += `;nonce="${params.nonce}"`;
82
119
  }
83
120
  return out;
84
121
  }
85
122
  /**
86
- * 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.
87
159
  */
88
160
  export function createSignatureBase(params) {
89
- const method = params.method.toUpperCase();
90
- const path = normalizePath(params.path);
91
- const authority = params.authority.toLowerCase();
92
- const lines = [
93
- `"@method": ${method}`,
94
- `"@path": ${path}`,
95
- `"@authority": ${authority}`,
96
- ];
97
- if (params.contentDigest !== undefined && params.contentDigest !== null) {
98
- lines.push(`"content-digest": ${params.contentDigest}`);
99
- }
100
- if (params.additionalComponents) {
101
- for (const [k, v] of Object.entries(params.additionalComponents)) {
102
- lines.push(`"${k.toLowerCase()}": ${v}`);
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
+ ];
175
+ }
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`);
103
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}`);
104
205
  }
105
206
  lines.push(`"@signature-params": ${params.signatureParams}`);
106
207
  return lines.join("\n");
107
208
  }
108
209
  /**
109
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).
110
213
  */
111
214
  export async function signRequest(params) {
112
215
  const signingKey = await loadSigningKey(params.key);
113
216
  const created = params.created ?? Math.floor(Date.now() / 1000);
114
217
  const keyid = params.keyid ?? signingKey.publicJwk.kid;
115
218
  const alg = params.alg ?? SIGNATURE_ALG;
116
- if (keyid) {
117
- 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");
118
225
  }
119
- const hasBody = params.body !== undefined && params.body !== null;
120
- const contentDigest = hasBody ? await computeContentDigest(params.body) : null;
121
- const components = ["@method", "@path", "@authority"];
226
+ components.push("@authority");
122
227
  if (contentDigest !== null) {
123
228
  components.push("content-digest");
124
229
  }
@@ -130,11 +235,12 @@ export async function signRequest(params) {
130
235
  nonce: params.nonce,
131
236
  });
132
237
  const signatureBase = createSignatureBase({
133
- method: params.method,
238
+ method,
134
239
  path: params.path,
135
240
  authority: params.authority,
136
241
  contentDigest,
137
242
  signatureParams,
243
+ components,
138
244
  });
139
245
  const signatureBuffer = await globalThis.crypto.subtle.sign("Ed25519", signingKey.privateKey, stringToUtf8(signatureBase));
140
246
  const signatureBase64 = bytesToBase64(new Uint8Array(signatureBuffer));
@@ -151,7 +257,10 @@ export async function signRequest(params) {
151
257
  return headers;
152
258
  }
153
259
  /**
154
- * 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).
155
264
  */
156
265
  export async function verifyRequestSignature(params) {
157
266
  const getHeader = (name) => {
@@ -177,18 +286,31 @@ export async function verifyRequestSignature(params) {
177
286
  }
178
287
  const signatureParams = sigInputMatch[1].trim();
179
288
  // Validate parameters and check for component duplicates
180
- const coveredComponentsMatch = signatureParams.match(/^\(([^)]*)\)/);
181
- if (!coveredComponentsMatch) {
182
- 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 };
183
292
  }
184
- const rawComponents = coveredComponentsMatch[1].trim().split(/\s+/).filter(Boolean);
293
+ const components = parsed.components;
185
294
  const seenComponents = new Set();
186
- for (const c of rawComponents) {
295
+ for (const c of components) {
187
296
  if (seenComponents.has(c)) {
188
297
  return { valid: false, error: `Duplicate covered component in Signature-Input: ${c}` };
189
298
  }
190
299
  seenComponents.add(c);
191
300
  }
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
+ }
192
314
  // RFC 8941 byte sequence (`:base64:`), standard alphabet with padding only, so each signature
193
315
  // has exactly one accepted encoding.
194
316
  const sigMatch = signatureHeader.match(new RegExp(`(?:^|,\\s*)${SIGNATURE_LABEL}=:([A-Za-z0-9+/]*={0,2}):(?=\\s*(?:,|$))`));
@@ -199,18 +321,20 @@ export async function verifyRequestSignature(params) {
199
321
  if (bytesToBase64(signatureBytes) !== sigMatch[1]) {
200
322
  return { valid: false, error: `Signature header does not contain valid ${SIGNATURE_LABEL} byte sequence` };
201
323
  }
202
- const createdMatch = signatureParams.match(/created=(\d+)/);
203
- if (!createdMatch) {
324
+ const created = parsed.params.created;
325
+ if (typeof created !== "number") {
204
326
  return { valid: false, error: "Signature-Input missing created timestamp" };
205
327
  }
206
- const created = parseInt(createdMatch[1], 10);
207
328
  const now = params.currentTime ?? Math.floor(Date.now() / 1000);
208
329
  const maxSkew = params.maxClockSkewSeconds ?? MAX_CLOCK_SKEW_SECONDS;
209
330
  if (Math.abs(now - created) > maxSkew) {
210
331
  return { valid: false, error: `Signature timestamp expired or clock skew too large (created=${created}, now=${now})` };
211
332
  }
212
- const keyidMatch = signatureParams.match(/keyid="([^"]+)"/);
213
- const keyid = keyidMatch ? keyidMatch[1] : undefined;
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" };
336
+ }
337
+ const keyid = keyidParam || undefined;
214
338
  if (keyid) {
215
339
  try {
216
340
  assertSafeIdentifier("keyid", keyid);
@@ -220,17 +344,17 @@ export async function verifyRequestSignature(params) {
220
344
  }
221
345
  }
222
346
  // Entries must outlive the whole acceptance window (created ± maxSkew).
347
+ const replayStore = params.replayStore ?? globalReplayCache;
223
348
  const replayKey = `${keyid ?? ""}:${bytesToHex(signatureBytes)}`;
224
349
  const replayTtlMs = 2 * Math.max(maxSkew, 1) * 1000;
225
- if (params.replayCheck !== false && globalReplayCache.has(replayKey)) {
350
+ if (params.replayCheck !== false && (await replayStore.has(replayKey))) {
226
351
  return { valid: false, error: "Replay detected: request signature was already presented" };
227
352
  }
228
- const algMatch = signatureParams.match(/alg="([^"]+)"/);
229
- const alg = algMatch ? algMatch[1] : undefined;
230
- if (alg && alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa") {
353
+ const alg = parsed.params.alg;
354
+ if (alg !== undefined && (typeof alg !== "string" || (alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa"))) {
231
355
  return { valid: false, error: `Unsupported signature algorithm: ${alg}` };
232
356
  }
233
- if (signatureParams.includes('"content-digest"')) {
357
+ if (seenComponents.has("content-digest")) {
234
358
  if (!contentDigestHeader) {
235
359
  return { valid: false, error: "Signature covers content-digest but Content-Digest header is missing" };
236
360
  }
@@ -239,25 +363,41 @@ export async function verifyRequestSignature(params) {
239
363
  return { valid: false, error: `Content-Digest mismatch: expected ${computedDigest}, got ${contentDigestHeader}` };
240
364
  }
241
365
  }
242
- const signatureBase = createSignatureBase({
243
- method: params.method,
244
- path: params.path,
245
- authority: params.authority,
246
- contentDigest: signatureParams.includes('"content-digest"') ? contentDigestHeader?.trim() : null,
247
- signatureParams,
248
- });
249
366
  try {
250
- const { publicKey } = await loadPublicKey(params.publicKey);
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);
251
387
  const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, stringToUtf8(signatureBase));
252
388
  if (!isValid) {
253
389
  return { valid: false, error: "Cryptographic signature verification failed" };
254
390
  }
255
- // Record only verified signatures; re-check synchronously in case a concurrent copy won.
256
- if (params.replayCheck !== false) {
257
- if (globalReplayCache.has(replayKey)) {
258
- return { valid: false, error: "Replay detected: request signature was already presented" };
259
- }
260
- globalReplayCache.record(replayKey, replayTtlMs);
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
+ };
261
401
  }
262
402
  return { valid: true, keyid };
263
403
  }
@@ -270,7 +410,9 @@ export const JWT_CLOCK_SKEW_SECONDS = 60;
270
410
  /**
271
411
  * Zero-egress offline verification for asymmetric JWT token leases.
272
412
  * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
273
- * a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set.
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`.
274
416
  */
275
417
  export async function verifyJwtLease(params) {
276
418
  try {
@@ -279,8 +421,17 @@ export async function verifyJwtLease(params) {
279
421
  return { valid: false, error: "Token is not a valid 3-part JWT" };
280
422
  }
281
423
  const [headerB64, payloadB64, signatureB64] = parts;
282
- const headerJson = utf8ToString(base64UrlToBytes(headerB64));
283
- 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
+ }
284
435
  const header = JSON.parse(headerJson);
285
436
  const payload = JSON.parse(payloadJson);
286
437
  if (!header || typeof header !== "object" || !payload || typeof payload !== "object" || Array.isArray(payload)) {
@@ -292,9 +443,15 @@ export async function verifyJwtLease(params) {
292
443
  if (header.typ !== undefined && String(header.typ).toUpperCase() !== "JWT") {
293
444
  return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
294
445
  }
295
- const { publicKey } = await loadPublicKey(params.publicKey);
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);
296
454
  const signedData = stringToUtf8(`${headerB64}.${payloadB64}`);
297
- const signatureBytes = base64UrlToBytes(signatureB64);
298
455
  const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, signedData);
299
456
  if (!isValid) {
300
457
  return { valid: false, error: "JWT signature verification failed" };
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 {};