@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.
- package/README.md +64 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -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 +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -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/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -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 +52 -8
- package/dist/next.js +151 -37
- 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 +56 -4
- 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 +32 -14
- package/assets/logo-512.png +0 -0
package/dist/crypto/keys.js
CHANGED
|
@@ -131,9 +131,27 @@ export async function loadSigningKey(raw) {
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
16
|
-
|
|
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).
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
14
|
-
if (!
|
|
15
|
-
return "/";
|
|
16
|
-
|
|
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(
|
|
20
|
-
return parsed.pathname
|
|
20
|
+
const parsed = new URL(t, "http://localhost");
|
|
21
|
+
return { path: parsed.pathname, query: parsed.search || "?" };
|
|
21
22
|
}
|
|
22
23
|
catch {
|
|
23
|
-
|
|
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
|
-
/**
|
|
35
|
-
|
|
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).
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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="${
|
|
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
|
-
*
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
181
|
-
if (
|
|
182
|
-
return { valid: false, error:
|
|
289
|
+
const parsed = parseSignatureParams(signatureParams);
|
|
290
|
+
if (typeof parsed === "string") {
|
|
291
|
+
return { valid: false, error: parsed };
|
|
183
292
|
}
|
|
184
|
-
const
|
|
293
|
+
const components = parsed.components;
|
|
185
294
|
const seenComponents = new Set();
|
|
186
|
-
for (const c of
|
|
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
|
|
203
|
-
if (
|
|
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
|
|
213
|
-
|
|
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 &&
|
|
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
|
|
229
|
-
|
|
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 (
|
|
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
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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 {};
|