@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.
- package/README.md +38 -24
- package/dist/analytics/index.d.ts +26 -3
- package/dist/analytics/index.js +107 -23
- package/dist/cli.js +136 -29
- package/dist/client.d.ts +44 -3
- package/dist/client.js +372 -73
- package/dist/config.d.ts +14 -2
- package/dist/config.js +36 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +54 -11
- package/dist/crypto/signatures.js +310 -94
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +34 -5
- package/dist/flags/client.js +93 -22
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +77 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/context.js +4 -3
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +25 -0
- package/dist/flags/engine/operators.js +64 -8
- package/dist/flags/node.d.ts +27 -15
- package/dist/flags/node.js +54 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +63 -6
- package/dist/next.js +182 -36
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +58 -3
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +34 -16
- package/assets/logo-512.png +0 -0
|
@@ -1,27 +1,42 @@
|
|
|
1
|
-
import { base64ToBytes,
|
|
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
|
-
*
|
|
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
|
|
13
|
-
if (!
|
|
14
|
-
return "/";
|
|
15
|
-
|
|
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(
|
|
19
|
-
return parsed.pathname
|
|
20
|
+
const parsed = new URL(t, "http://localhost");
|
|
21
|
+
return { path: parsed.pathname, query: parsed.search || "?" };
|
|
20
22
|
}
|
|
21
23
|
catch {
|
|
22
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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="${
|
|
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
|
-
*
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
168
|
-
if (
|
|
169
|
-
return { valid: false, error:
|
|
289
|
+
const parsed = parseSignatureParams(signatureParams);
|
|
290
|
+
if (typeof parsed === "string") {
|
|
291
|
+
return { valid: false, error: parsed };
|
|
170
292
|
}
|
|
171
|
-
const
|
|
293
|
+
const components = parsed.components;
|
|
172
294
|
const seenComponents = new Set();
|
|
173
|
-
for (const c of
|
|
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
|
|
180
|
-
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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 (
|
|
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
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
39
|
-
if (
|
|
40
|
-
res.setHeader("Retry-After", String(
|
|
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(
|
|
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(
|
|
50
|
+
res.end(JSON.stringify(body));
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
51
53
|
req.auth = result.data;
|