@shelbyb/daywalker-sdk 0.6.0 → 0.8.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.
@@ -0,0 +1,217 @@
1
+ import { webcrypto } from "./webcrypto.js";
2
+ import { redactSecrets } from "./encoding.js";
3
+ import { API_KEY_PATTERN, SAFE_IDENTIFIER_PATTERN, SERVICE_SLUG_PATTERN, assertSafeIdentifier } from "./signatures.js";
4
+ import { assertSecureUrl, parseServiceSlugs, publicHostname } from "../config.js";
5
+ /**
6
+ * Daywalker's signing-key lifecycle API (`/api/v1/keys*`), authenticated with the API key as
7
+ * `Authorization: Bearer`. Every call goes only to `authUrl`: redirects are refused (never followed with the
8
+ * key), requests time out after 10s, nothing is cached, inputs are validated first and answers are bounded.
9
+ */
10
+ /** Per-attempt timeout for key API calls. */
11
+ const KEY_API_TIMEOUT_MS = 10_000;
12
+ /** Largest key API answer read. */
13
+ const MAX_KEY_API_RESPONSE_BYTES = 262_144;
14
+ /** Daywalker's `instanceId` format: one key slot per (domain, instanceId). */
15
+ export const INSTANCE_ID_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
16
+ /** Key lifetime bounds Daywalker accepts for `ttlSeconds` (1 hour to 30 days). */
17
+ export const MIN_KEY_TTL_SECONDS = 3_600;
18
+ export const MAX_KEY_TTL_SECONDS = 2_592_000;
19
+ /**
20
+ * The instance slot the SDK registers a key in: one slot per key (`dw-<keyId>`), so registering a key never
21
+ * demotes another one (Daywalker moves the previous key of the same slot to ROTATING, then revokes it).
22
+ */
23
+ export function keySlotFor(keyId) {
24
+ const slot = `dw-${keyId}`.slice(0, 64);
25
+ if (!INSTANCE_ID_PATTERN.test(slot))
26
+ throw new Error("keyId can't be used as an instance slot");
27
+ return slot;
28
+ }
29
+ const seconds = (v) => {
30
+ if (typeof v !== "string" && typeof v !== "number")
31
+ return null;
32
+ const t = typeof v === "number" ? v * 1000 : Date.parse(v);
33
+ return Number.isFinite(t) ? Math.floor(t / 1000) : null;
34
+ };
35
+ /** A record from Daywalker, checked and normalized, or null when it isn't one. */
36
+ function parseKeyRecord(value) {
37
+ if (!value || typeof value !== "object" || Array.isArray(value))
38
+ return null;
39
+ const v = value;
40
+ if (typeof v.keyId !== "string" || !SAFE_IDENTIFIER_PATTERN.test(v.keyId))
41
+ return null;
42
+ if (typeof v.domain !== "string" || !publicHostname(v.domain))
43
+ return null;
44
+ if (v.status !== "ACTIVE" && v.status !== "ROTATING" && v.status !== "REVOKED")
45
+ return null;
46
+ const instanceId = typeof v.instanceId === "string" && INSTANCE_ID_PATTERN.test(v.instanceId) ? v.instanceId : null;
47
+ return Object.freeze({
48
+ ...(typeof v.id === "string" ? { id: v.id } : {}),
49
+ keyId: v.keyId,
50
+ domain: v.domain.toLowerCase(),
51
+ status: v.status,
52
+ instanceId,
53
+ expiresAt: seconds(v.expiresAt),
54
+ createdAt: seconds(v.createdAt),
55
+ lastUsedAt: seconds(v.lastUsedAt),
56
+ });
57
+ }
58
+ /** Validates the shared inputs; returns an error result, or the base URL. */
59
+ function prepare(params) {
60
+ const base = (params.authUrl ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
61
+ try {
62
+ assertSecureUrl("authUrl", base);
63
+ }
64
+ catch (err) {
65
+ return { ok: false, status: 0, error: err.message };
66
+ }
67
+ if (typeof params.apiKey !== "string" || !API_KEY_PATTERN.test(params.apiKey)) {
68
+ return { ok: false, status: 400, error: "apiKey is missing or contains characters an API key can't have" };
69
+ }
70
+ return { base, doFetch: params.fetchFn ?? globalThis.fetch };
71
+ }
72
+ /** One key API call: no redirects, no cache, a timeout, a bounded JSON answer. */
73
+ async function call(doFetch, url, apiKey, init) {
74
+ try {
75
+ const res = await doFetch(url, {
76
+ method: init.method,
77
+ headers: {
78
+ ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
79
+ Accept: "application/json",
80
+ Authorization: `Bearer ${apiKey}`,
81
+ },
82
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
83
+ redirect: "manual",
84
+ cache: "no-store",
85
+ signal: AbortSignal.timeout(KEY_API_TIMEOUT_MS),
86
+ });
87
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
88
+ res.body?.cancel().catch(() => { });
89
+ return { ok: false, status: res.status, error: "Daywalker Auth answered with a redirect; refusing to follow it with the API key" };
90
+ }
91
+ const text = await res.text();
92
+ if (text.length > MAX_KEY_API_RESPONSE_BYTES)
93
+ return { ok: false, status: res.status, error: "Daywalker Auth answer is too large" };
94
+ let parsed = {};
95
+ try {
96
+ parsed = text ? JSON.parse(text) : {};
97
+ }
98
+ catch {
99
+ parsed = {};
100
+ }
101
+ const json = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
102
+ return { status: res.status, json };
103
+ }
104
+ catch (err) {
105
+ return { ok: false, status: 0, error: redactSecrets(err.message) };
106
+ }
107
+ }
108
+ function failure(status, json) {
109
+ const code = typeof json.error === "string" ? json.error.slice(0, 64) : typeof json.code === "string" ? json.code.slice(0, 64) : undefined;
110
+ const message = typeof json.message === "string" ? json.message : code ?? `HTTP ${status}`;
111
+ return { ok: false, status, code, error: redactSecrets(message.slice(0, 500)) };
112
+ }
113
+ /**
114
+ * Register public key directly with Daywalker Auth via POST /api/v1/keys/register (Push Architecture).
115
+ * Re-registering the same key is idempotent (200). `instanceId` puts the key in a slot: registering a new key
116
+ * in an occupied slot moves the old one to ROTATING for 10 minutes, then revokes it; without `instanceId`
117
+ * the same happens to every other slot-less key of the domain, so the SDK always passes one (`keySlotFor`).
118
+ * `ttlSeconds` (1 hour to 30 days) or `expiresAt` make the key expire. Errors carry Daywalker's `code`
119
+ * (`KEY_LIMIT_REACHED`: 20 live keys per domain; `KEY_ID_CONFLICT`; `domain_not_allowed`).
120
+ */
121
+ export async function registerPublicKey(params) {
122
+ const prepared = prepare(params);
123
+ if ("ok" in prepared)
124
+ return prepared;
125
+ if (!params.domain) {
126
+ return { ok: false, status: 400, error: "domain is required to register a key (set --domain or DAYWALKER_DOMAIN)" };
127
+ }
128
+ const domain = publicHostname(params.domain);
129
+ if (!domain) {
130
+ return { ok: false, status: 400, error: `domain (${params.domain}) is not a public hostname` };
131
+ }
132
+ const slugs = params.serviceSlug ? parseServiceSlugs(params.serviceSlug) : [];
133
+ if (!slugs.every((slug) => SERVICE_SLUG_PATTERN.test(slug))) {
134
+ return { ok: false, status: 400, error: "serviceSlug must be Daywalker service slug(s)" };
135
+ }
136
+ const jwk = params.publicJwk;
137
+ if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(jwk.x) || "d" in jwk) {
138
+ return { ok: false, status: 400, error: "publicJwk must be an Ed25519 public JWK (never a private key)" };
139
+ }
140
+ try {
141
+ assertSafeIdentifier("keyId", jwk.kid);
142
+ }
143
+ catch (err) {
144
+ return { ok: false, status: 400, error: err.message };
145
+ }
146
+ if (params.instanceId !== undefined && !INSTANCE_ID_PATTERN.test(params.instanceId)) {
147
+ return { ok: false, status: 400, error: "instanceId must be 1-64 of A-Z a-z 0-9 . _ -" };
148
+ }
149
+ if (params.ttlSeconds !== undefined && (!Number.isInteger(params.ttlSeconds) || params.ttlSeconds < MIN_KEY_TTL_SECONDS || params.ttlSeconds > MAX_KEY_TTL_SECONDS)) {
150
+ return { ok: false, status: 400, error: `ttlSeconds must be a whole number from ${MIN_KEY_TTL_SECONDS} to ${MAX_KEY_TTL_SECONDS}` };
151
+ }
152
+ if (params.expiresAt !== undefined && (!(params.expiresAt instanceof Date) || !(params.expiresAt.getTime() > Date.now()))) {
153
+ return { ok: false, status: 400, error: "expiresAt must be a date in the future" };
154
+ }
155
+ const res = await call(prepared.doFetch, `${prepared.base}/api/v1/keys/register`, params.apiKey, {
156
+ method: "POST",
157
+ body: {
158
+ keyId: jwk.kid,
159
+ domain,
160
+ publicKey: { kty: "OKP", crv: "Ed25519", x: jwk.x, kid: jwk.kid, use: "sig", alg: "EdDSA" },
161
+ // Lets Daywalker refuse right away when the API key doesn't cover the service(s); a comma-separated list is fine
162
+ ...(slugs.length > 0 ? { serviceSlug: slugs.join(",") } : {}),
163
+ ...(params.instanceId ? { instanceId: params.instanceId } : {}),
164
+ ...(params.ttlSeconds ? { ttlSeconds: params.ttlSeconds } : {}),
165
+ ...(params.expiresAt ? { expiresAt: params.expiresAt.toISOString() } : {}),
166
+ },
167
+ });
168
+ if ("ok" in res)
169
+ return res;
170
+ if (res.status !== 200 && res.status !== 201)
171
+ return failure(res.status, res.json);
172
+ const key = parseKeyRecord(res.json);
173
+ // An answer for another key or domain is not a registration of this one.
174
+ if (key && (key.keyId !== jwk.kid || key.domain !== domain)) {
175
+ return { ok: false, status: 502, error: "Daywalker Auth answered with a different key or domain" };
176
+ }
177
+ return { ok: true, status: res.status, ...(key ? { key } : {}) };
178
+ }
179
+ /** Lists the signing keys registered for `domain` (GET /api/v1/keys), newest first. */
180
+ export async function listSigningKeys(params) {
181
+ const prepared = prepare(params);
182
+ if ("ok" in prepared)
183
+ return prepared;
184
+ const domain = publicHostname(params.domain);
185
+ if (!domain)
186
+ return { ok: false, status: 400, error: `domain (${params.domain}) is not a public hostname` };
187
+ const res = await call(prepared.doFetch, `${prepared.base}/api/v1/keys?domain=${encodeURIComponent(domain)}`, params.apiKey, { method: "GET" });
188
+ if ("ok" in res)
189
+ return res;
190
+ if (res.status !== 200)
191
+ return failure(res.status, res.json);
192
+ if (!Array.isArray(res.json.keys))
193
+ return { ok: false, status: 502, error: "Daywalker Auth returned no key list" };
194
+ const keys = res.json.keys.map(parseKeyRecord).filter((k) => k !== null && k.domain === domain);
195
+ return { ok: true, status: 200, keys };
196
+ }
197
+ /**
198
+ * Revokes a signing key now (DELETE /api/v1/keys/{keyId}). Daywalker also ends every OAuth session that was
199
+ * authenticated with it. Revoking a key that is already revoked succeeds; an unknown key is 404 `key_not_found`.
200
+ */
201
+ export async function revokeSigningKey(params) {
202
+ const prepared = prepare(params);
203
+ if ("ok" in prepared)
204
+ return prepared;
205
+ if (typeof params.keyId !== "string" || !SAFE_IDENTIFIER_PATTERN.test(params.keyId)) {
206
+ return { ok: false, status: 400, error: "keyId must be 1-128 safe characters" };
207
+ }
208
+ const res = await call(prepared.doFetch, `${prepared.base}/api/v1/keys/${encodeURIComponent(params.keyId)}`, params.apiKey, { method: "DELETE" });
209
+ if ("ok" in res)
210
+ return res;
211
+ return res.status === 200 ? { ok: true, status: 200 } : failure(res.status, res.json);
212
+ }
213
+ /** A random instance slot for a process (`dw-i-<random>`), for keys that live only as long as it does. */
214
+ export function randomInstanceId() {
215
+ const bytes = webcrypto.getRandomValues(new Uint8Array(12));
216
+ return `dw-i-${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
217
+ }
@@ -23,3 +23,8 @@ export declare function loadPublicKey(raw: string | PublicJwk | PublicJwks | Cry
23
23
  publicKey: CryptoKey;
24
24
  publicJwk: PublicJwk;
25
25
  }>;
26
+ /**
27
+ * Imports a PKCS#8 (base64) Ed25519 private key for signing only, as a NON-extractable CryptoKey: once
28
+ * imported, nothing in the process (including this SDK) can export it again. The public JWK is derived first.
29
+ */
30
+ export declare function importSigningKey(pkcs8Base64: string): Promise<SigningKey>;
@@ -1,3 +1,4 @@
1
+ import { webcrypto } from "./webcrypto.js";
1
2
  import { base64ToBytes, base64UrlToBytes, bytesToBase64, bytesToBase64Url, } from "./encoding.js";
2
3
  import { sha256Base64Url } from "./digest.js";
3
4
  // PKCS#8 prefix for Ed25519 private keys (16 bytes)
@@ -21,12 +22,12 @@ export async function calculateJwkThumbprint(jwk) {
21
22
  * Generate a new Ed25519 keypair using WebCrypto.
22
23
  */
23
24
  export async function generateSigningKey() {
24
- const keyPair = (await globalThis.crypto.subtle.generateKey("Ed25519", true, [
25
+ const keyPair = (await webcrypto.generateKey("Ed25519", true, [
25
26
  "sign",
26
27
  "verify",
27
28
  ]));
28
- const jwk = (await globalThis.crypto.subtle.exportKey("jwk", keyPair.privateKey));
29
- const pkcs8Buffer = await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
29
+ const jwk = (await webcrypto.exportKey("jwk", keyPair.privateKey));
30
+ const pkcs8Buffer = await webcrypto.exportKey("pkcs8", keyPair.privateKey);
30
31
  const privateKeyPkcs8Base64 = bytesToBase64(new Uint8Array(pkcs8Buffer));
31
32
  const kid = await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: jwk.x });
32
33
  const publicJwk = {
@@ -58,8 +59,8 @@ export async function loadSigningKey(raw) {
58
59
  try {
59
60
  const parsed = JSON.parse(trimmed);
60
61
  if (parsed.kty === "OKP" && parsed.crv === "Ed25519" && parsed.d) {
61
- const privateKey = await globalThis.crypto.subtle.importKey("jwk", parsed, "Ed25519", true, ["sign"]);
62
- const jwk = (await globalThis.crypto.subtle.exportKey("jwk", privateKey));
62
+ const privateKey = await webcrypto.importKey("jwk", parsed, "Ed25519", true, ["sign"]);
63
+ const jwk = (await webcrypto.exportKey("jwk", privateKey));
63
64
  const kid = parsed.kid || (await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: jwk.x }));
64
65
  const publicJwk = {
65
66
  kty: "OKP",
@@ -69,8 +70,8 @@ export async function loadSigningKey(raw) {
69
70
  use: "sig",
70
71
  alg: "EdDSA",
71
72
  };
72
- const publicKey = await globalThis.crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, "Ed25519", true, ["verify"]);
73
- const pkcs8Buffer = await globalThis.crypto.subtle.exportKey("pkcs8", privateKey);
73
+ const publicKey = await webcrypto.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, "Ed25519", true, ["verify"]);
74
+ const pkcs8Buffer = await webcrypto.exportKey("pkcs8", privateKey);
74
75
  return {
75
76
  privateKey,
76
77
  publicKey,
@@ -107,12 +108,12 @@ export async function loadSigningKey(raw) {
107
108
  }
108
109
  let privateKey;
109
110
  try {
110
- privateKey = await globalThis.crypto.subtle.importKey("pkcs8", keyBytes, "Ed25519", true, ["sign"]);
111
+ privateKey = await webcrypto.importKey("pkcs8", keyBytes, "Ed25519", true, ["sign"]);
111
112
  }
112
113
  catch (err) {
113
114
  throw new Error(`Failed to import Ed25519 private key: ${err.message}`);
114
115
  }
115
- const jwk = (await globalThis.crypto.subtle.exportKey("jwk", privateKey));
116
+ const jwk = (await webcrypto.exportKey("jwk", privateKey));
116
117
  const kid = await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: jwk.x });
117
118
  const publicJwk = {
118
119
  kty: "OKP",
@@ -122,7 +123,7 @@ export async function loadSigningKey(raw) {
122
123
  use: "sig",
123
124
  alg: "EdDSA",
124
125
  };
125
- const publicKey = await globalThis.crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, "Ed25519", true, ["verify"]);
126
+ const publicKey = await webcrypto.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, "Ed25519", true, ["verify"]);
126
127
  return {
127
128
  privateKey,
128
129
  publicKey,
@@ -153,7 +154,7 @@ export async function loadPublicKey(raw, kid) {
153
154
  return loadPublicKey(selectJwk(raw, kid));
154
155
  }
155
156
  if (typeof raw === "object" && "type" in raw && raw.type === "public") {
156
- const exported = (await globalThis.crypto.subtle.exportKey("jwk", raw));
157
+ const exported = (await webcrypto.exportKey("jwk", raw));
157
158
  const kid = exported.kid || (await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: exported.x }));
158
159
  return {
159
160
  publicKey: raw,
@@ -169,7 +170,7 @@ export async function loadPublicKey(raw, kid) {
169
170
  }
170
171
  if (typeof raw === "object" && "kty" in raw && raw.kty === "OKP" && raw.crv === "Ed25519") {
171
172
  const kid = raw.kid || (await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: raw.x }));
172
- const publicKey = await globalThis.crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: raw.x }, "Ed25519", true, ["verify"]);
173
+ const publicKey = await webcrypto.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: raw.x }, "Ed25519", true, ["verify"]);
173
174
  return {
174
175
  publicKey,
175
176
  publicJwk: {
@@ -221,7 +222,7 @@ export async function loadPublicKey(raw, kid) {
221
222
  if (keyBytes.length === 32) {
222
223
  const x = bytesToBase64Url(keyBytes);
223
224
  const kid = await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x });
224
- const publicKey = await globalThis.crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x }, "Ed25519", true, ["verify"]);
225
+ const publicKey = await webcrypto.importKey("jwk", { kty: "OKP", crv: "Ed25519", x }, "Ed25519", true, ["verify"]);
225
226
  return {
226
227
  publicKey,
227
228
  publicJwk: {
@@ -236,8 +237,8 @@ export async function loadPublicKey(raw, kid) {
236
237
  }
237
238
  // Try SPKI format
238
239
  try {
239
- const publicKey = await globalThis.crypto.subtle.importKey("spki", keyBytes, "Ed25519", true, ["verify"]);
240
- const jwk = (await globalThis.crypto.subtle.exportKey("jwk", publicKey));
240
+ const publicKey = await webcrypto.importKey("spki", keyBytes, "Ed25519", true, ["verify"]);
241
+ const jwk = (await webcrypto.exportKey("jwk", publicKey));
241
242
  const kid = await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x: jwk.x });
242
243
  return {
243
244
  publicKey,
@@ -255,3 +256,12 @@ export async function loadPublicKey(raw, kid) {
255
256
  throw new Error(`Failed to import public key: ${err.message}`);
256
257
  }
257
258
  }
259
+ /**
260
+ * Imports a PKCS#8 (base64) Ed25519 private key for signing only, as a NON-extractable CryptoKey: once
261
+ * imported, nothing in the process (including this SDK) can export it again. The public JWK is derived first.
262
+ */
263
+ export async function importSigningKey(pkcs8Base64) {
264
+ const derived = await loadSigningKey(pkcs8Base64);
265
+ const privateKey = await webcrypto.importKey("pkcs8", base64ToBytes(pkcs8Base64), "Ed25519", false, ["sign"]);
266
+ return { privateKey, publicKey: derived.publicKey, publicJwk: derived.publicJwk };
267
+ }
@@ -4,6 +4,10 @@ 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
+ /** A Daywalker service slug. */
8
+ export declare const SERVICE_SLUG_PATTERN: RegExp;
9
+ /** An API key: printable, no whitespace, quotes or separators that could break out of a header. */
10
+ export declare const API_KEY_PATTERN: RegExp;
7
11
  /**
8
12
  * Splits a request target into its RFC 9421 "@path" value (§2.2.6; dot segments resolved and repeated
9
13
  * slashes collapsed per RFC 3986, to prevent path traversal & signature confusion) and its "@query" value
@@ -88,19 +92,19 @@ export declare function signRequest(params: SignRequestParams): Promise<SignedHe
88
92
  * Replay protection uses `replayStore` (default: per-process memory; use a shared store across instances).
89
93
  */
90
94
  export declare function verifyRequestSignature(params: VerifySignatureParams): Promise<VerifySignatureResult>;
91
- /**
92
- * Every service slug a (verified) JWT lease covers: its `services` claim, else its audience, else the
93
- * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
94
- */
95
+ /** Every service slug a (verified) access token covers: its `services` claim, else its audience. */
95
96
  export declare function leaseServices(payload: Record<string, unknown>): string[];
96
97
  /** Clock skew tolerated on JWT `exp` / `nbf`: 30 seconds, and never more (see `clockSkewSeconds`). */
97
98
  export declare const JWT_CLOCK_SKEW_SECONDS = 30;
99
+ /** Longest accepted JWT (characters); longer input is refused before any parsing. */
100
+ export declare const MAX_JWT_LENGTH = 16384;
98
101
  /**
99
- * Zero-egress offline verification for asymmetric JWT token leases.
100
- * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
101
- * a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set
102
- * (Daywalker documents no fixed issuer, so there is no default). Segments must be canonical unpadded
103
- * base64url, a `crit` header is rejected, and a JWKS `publicKey` is searched by the header's `kid`.
102
+ * Zero-egress offline verification of Daywalker access tokens (RFC 9068 JWTs from the OAuth token endpoint).
103
+ * Requires `typ: at+jwt`, `alg: EdDSA`, a `jti`, a numeric `exp`, and an `aud` containing `expectedServiceSlug`
104
+ * (a verifier without an expected slug rejects every token). `iss` is checked when `expectedIssuer` is set.
105
+ * Segments must be canonical unpadded base64url, a `crit` header is rejected, and a JWKS `publicKey` is
106
+ * searched by the header's `kid`. A token with `cnf.jkt` is DPoP-bound: the caller must also check its
107
+ * proof (`verifyDpopProof`), as `Daywalker.validateToken()` does.
104
108
  */
105
109
  export declare function verifyJwtLease(params: {
106
110
  token: string;
@@ -114,18 +118,3 @@ export declare function verifyJwtLease(params: {
114
118
  payload?: Record<string, unknown>;
115
119
  error?: string;
116
120
  }>;
117
- /**
118
- * Register public key directly with Daywalker Auth via POST /api/v1/keys/register (Push Architecture).
119
- */
120
- export declare function registerPublicKey(params: {
121
- authUrl?: string;
122
- apiKey: string;
123
- serviceSlug?: string;
124
- domain?: string | null;
125
- publicJwk: PublicJwk;
126
- fetchFn?: typeof fetch;
127
- }): Promise<{
128
- ok: boolean;
129
- status: number;
130
- error?: string;
131
- }>;
@@ -1,12 +1,16 @@
1
+ import { webcrypto } from "./webcrypto.js";
1
2
  import { base64ToBytes, base64UrlToBytesStrict, bytesToBase64, bytesToHex, stringToUtf8, utf8ToString, timingSafeEqualString, } from "./encoding.js";
2
3
  import { computeContentDigest } from "./digest.js";
3
4
  import { loadPublicKey, loadSigningKey } from "./keys.js";
4
- import { assertSecureUrl } from "../config.js";
5
5
  export const SIGNATURE_VERSION = "rfc9421";
6
6
  export const SIGNATURE_LABEL = "sig1";
7
7
  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
+ /** A Daywalker service slug. */
11
+ export const SERVICE_SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
12
+ /** An API key: printable, no whitespace, quotes or separators that could break out of a header. */
13
+ export const API_KEY_PATTERN = /^[A-Za-z0-9_.~+\/=-]{8,512}$/;
10
14
  /**
11
15
  * Splits a request target into its RFC 9421 "@path" value (§2.2.6; dot segments resolved and repeated
12
16
  * slashes collapsed per RFC 3986, to prevent path traversal & signature confusion) and its "@query" value
@@ -212,9 +216,12 @@ export function createSignatureBase(params) {
212
216
  * "content-digest" (whenever there is a body, and always for POST / PUT / PATCH).
213
217
  */
214
218
  export async function signRequest(params) {
215
- const signingKey = await loadSigningKey(params.key);
219
+ const signer = params.signer;
220
+ if (!signer && !params.key)
221
+ throw new Error("signRequest needs a key or a signer");
222
+ const signingKey = signer ? null : await loadSigningKey(params.key);
216
223
  const created = params.created ?? Math.floor(Date.now() / 1000);
217
- const keyid = params.keyid ?? signingKey.publicJwk.kid;
224
+ const keyid = params.keyid ?? (signer ? signer.keyId : signingKey.publicJwk.kid);
218
225
  const alg = params.alg ?? SIGNATURE_ALG;
219
226
  const method = params.method.toUpperCase();
220
227
  const body = params.body ?? (BODY_METHODS.has(method) ? "" : null);
@@ -242,7 +249,13 @@ export async function signRequest(params) {
242
249
  signatureParams,
243
250
  components,
244
251
  });
245
- const signatureBuffer = await globalThis.crypto.subtle.sign("Ed25519", signingKey.privateKey, stringToUtf8(signatureBase));
252
+ const baseBytes = stringToUtf8(signatureBase);
253
+ const signatureBuffer = signer
254
+ ? await signer.sign(baseBytes)
255
+ : await webcrypto.sign("Ed25519", signingKey.privateKey, baseBytes);
256
+ if (!(signatureBuffer instanceof ArrayBuffer || signatureBuffer instanceof Uint8Array) || signatureBuffer.byteLength !== 64) {
257
+ throw new Error("Signer must return a 64-byte Ed25519 signature");
258
+ }
246
259
  const signatureBase64 = bytesToBase64(new Uint8Array(signatureBuffer));
247
260
  const headers = {
248
261
  "Signature-Input": `${SIGNATURE_LABEL}=${signatureParams}`,
@@ -251,9 +264,6 @@ export async function signRequest(params) {
251
264
  if (contentDigest !== null) {
252
265
  headers["Content-Digest"] = contentDigest;
253
266
  }
254
- if (params.apiKey) {
255
- headers["Authorization"] = `Bearer ${params.apiKey}`;
256
- }
257
267
  return headers;
258
268
  }
259
269
  /**
@@ -384,7 +394,7 @@ export async function verifyRequestSignature(params) {
384
394
  components,
385
395
  });
386
396
  const { publicKey } = await loadPublicKey(params.publicKey, keyid);
387
- const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, stringToUtf8(signatureBase));
397
+ const isValid = await webcrypto.verify("Ed25519", publicKey, signatureBytes, stringToUtf8(signatureBase));
388
398
  if (!isValid) {
389
399
  return { valid: false, error: "Cryptographic signature verification failed" };
390
400
  }
@@ -405,27 +415,30 @@ export async function verifyRequestSignature(params) {
405
415
  return { valid: false, error: `Verification error: ${err.message}` };
406
416
  }
407
417
  }
408
- /**
409
- * Every service slug a (verified) JWT lease covers: its `services` claim, else its audience, else the
410
- * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
411
- */
418
+ /** Every service slug a (verified) access token covers: its `services` claim, else its audience. */
412
419
  export function leaseServices(payload) {
413
420
  const strings = (v) => (Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]).filter((s) => typeof s === "string" && s !== "");
414
421
  const listed = strings(payload.services);
415
- const fallback = listed.length > 0 ? listed : strings(payload.aud).length > 0 ? strings(payload.aud) : strings(payload.serviceSlug ?? payload.service);
422
+ const fallback = listed.length > 0 ? listed : strings(payload.aud);
416
423
  return Array.from(new Set(fallback.map((s) => s.toLowerCase())));
417
424
  }
418
425
  /** Clock skew tolerated on JWT `exp` / `nbf`: 30 seconds, and never more (see `clockSkewSeconds`). */
419
426
  export const JWT_CLOCK_SKEW_SECONDS = 30;
427
+ /** Longest accepted JWT (characters); longer input is refused before any parsing. */
428
+ export const MAX_JWT_LENGTH = 16_384;
420
429
  /**
421
- * Zero-egress offline verification for asymmetric JWT token leases.
422
- * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
423
- * a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set
424
- * (Daywalker documents no fixed issuer, so there is no default). Segments must be canonical unpadded
425
- * base64url, a `crit` header is rejected, and a JWKS `publicKey` is searched by the header's `kid`.
430
+ * Zero-egress offline verification of Daywalker access tokens (RFC 9068 JWTs from the OAuth token endpoint).
431
+ * Requires `typ: at+jwt`, `alg: EdDSA`, a `jti`, a numeric `exp`, and an `aud` containing `expectedServiceSlug`
432
+ * (a verifier without an expected slug rejects every token). `iss` is checked when `expectedIssuer` is set.
433
+ * Segments must be canonical unpadded base64url, a `crit` header is rejected, and a JWKS `publicKey` is
434
+ * searched by the header's `kid`. A token with `cnf.jkt` is DPoP-bound: the caller must also check its
435
+ * proof (`verifyDpopProof`), as `Daywalker.validateToken()` does.
426
436
  */
427
437
  export async function verifyJwtLease(params) {
428
438
  try {
439
+ if (typeof params.token !== "string" || params.token.length > MAX_JWT_LENGTH) {
440
+ return { valid: false, error: "Token is not a string or is too long" };
441
+ }
429
442
  const parts = params.token.trim().split(".");
430
443
  if (parts.length !== 3) {
431
444
  return { valid: false, error: "Token is not a valid 3-part JWT" };
@@ -447,11 +460,11 @@ export async function verifyJwtLease(params) {
447
460
  if (!header || typeof header !== "object" || !payload || typeof payload !== "object" || Array.isArray(payload)) {
448
461
  return { valid: false, error: "JWT header and payload must be JSON objects" };
449
462
  }
450
- if (header.alg !== "EdDSA" && header.alg !== "ed25519") {
463
+ if (header.alg !== "EdDSA") {
451
464
  return { valid: false, error: `Unsupported JWT alg: ${header.alg}` };
452
465
  }
453
- // "JWT" (older leases) or "at+jwt" (RFC 9068 access tokens); a refresh token ("rt+jwt") is never accepted here
454
- if (header.typ !== undefined && !["JWT", "AT+JWT"].includes(String(header.typ).toUpperCase())) {
466
+ // RFC 9068 §4: only access tokens; a refresh token, ID token or any other JWT under the same key is refused
467
+ if (typeof header.typ !== "string" || header.typ.toLowerCase() !== "at+jwt") {
455
468
  return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
456
469
  }
457
470
  // RFC 7515 §4.1.11: extensions we don't understand must be rejected; we implement none.
@@ -463,7 +476,7 @@ export async function verifyJwtLease(params) {
463
476
  }
464
477
  const { publicKey } = await loadPublicKey(params.publicKey, header.kid);
465
478
  const signedData = stringToUtf8(`${headerB64}.${payloadB64}`);
466
- const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, signedData);
479
+ const isValid = await webcrypto.verify("Ed25519", publicKey, signatureBytes, signedData);
467
480
  if (!isValid) {
468
481
  return { valid: false, error: "JWT signature verification failed" };
469
482
  }
@@ -485,25 +498,19 @@ export async function verifyJwtLease(params) {
485
498
  if (!expected) {
486
499
  return { valid: false, error: "JWT audience cannot be checked: no expected service slug is configured" };
487
500
  }
488
- const audiences = [];
489
- if (payload.serviceSlug !== undefined)
490
- audiences.push(String(payload.serviceSlug));
491
- if (Array.isArray(payload.aud))
492
- audiences.push(...payload.aud.map(String));
493
- else if (payload.aud !== undefined)
494
- audiences.push(String(payload.aud));
501
+ const audiences = (Array.isArray(payload.aud) ? payload.aud : payload.aud === undefined ? [] : [payload.aud]).filter((a) => typeof a === "string");
495
502
  if (audiences.length === 0) {
496
- return { valid: false, error: "JWT token lease has no aud or serviceSlug claim" };
503
+ return { valid: false, error: "JWT has no aud claim" };
497
504
  }
498
- const serviceSlugOk = payload.serviceSlug === undefined || String(payload.serviceSlug).toLowerCase() === expected;
499
- const audOk = payload.aud === undefined ||
500
- (Array.isArray(payload.aud) ? payload.aud : [payload.aud]).some((a) => String(a).toLowerCase() === expected);
501
- if (!serviceSlugOk || !audOk) {
505
+ if (!audiences.some((a) => a.toLowerCase() === expected)) {
502
506
  return {
503
507
  valid: false,
504
508
  error: `JWT audience mismatch: expected ${expected}, got ${audiences.join(", ").toLowerCase()}`,
505
509
  };
506
510
  }
511
+ if (payload.cnf !== undefined && (!payload.cnf || typeof payload.cnf !== "object" || typeof payload.cnf.jkt !== "string")) {
512
+ return { valid: false, error: "JWT cnf claim must carry a jkt thumbprint" };
513
+ }
507
514
  // A lease that lists its services must list this one (belt and braces next to `aud`).
508
515
  if (payload.services !== undefined) {
509
516
  const listed = Array.isArray(payload.services) ? payload.services : null;
@@ -523,53 +530,3 @@ export async function verifyJwtLease(params) {
523
530
  return { valid: false, error: `Failed to verify JWT lease: ${err.message}` };
524
531
  }
525
532
  }
526
- /**
527
- * Register public key directly with Daywalker Auth via POST /api/v1/keys/register (Push Architecture).
528
- */
529
- export async function registerPublicKey(params) {
530
- const authUrl = (params.authUrl ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
531
- try {
532
- assertSecureUrl("authUrl", authUrl);
533
- }
534
- catch (err) {
535
- return { ok: false, status: 0, error: err.message };
536
- }
537
- const registerUrl = `${authUrl}/api/v1/keys/register`;
538
- const doFetch = params.fetchFn ?? globalThis.fetch;
539
- if (!params.domain) {
540
- return { ok: false, status: 400, error: "domain is required to register a key (set --domain or DAYWALKER_DOMAIN)" };
541
- }
542
- try {
543
- const res = await doFetch(registerUrl, {
544
- method: "POST",
545
- headers: {
546
- "Content-Type": "application/json",
547
- Accept: "application/json",
548
- Authorization: `Bearer ${params.apiKey}`,
549
- },
550
- body: JSON.stringify({
551
- keyId: params.publicJwk.kid,
552
- domain: params.domain,
553
- publicKey: params.publicJwk,
554
- // Lets Daywalker refuse right away when the API key doesn't cover the service(s); a comma-separated list is fine
555
- ...(params.serviceSlug ? { serviceSlug: params.serviceSlug } : {}),
556
- }),
557
- });
558
- if (res.ok) {
559
- return { ok: true, status: res.status };
560
- }
561
- const data = (await res.json().catch(() => ({})));
562
- return {
563
- ok: false,
564
- status: res.status,
565
- error: data.message || data.error || `HTTP ${res.status}`,
566
- };
567
- }
568
- catch (err) {
569
- return {
570
- ok: false,
571
- status: 0,
572
- error: err.message,
573
- };
574
- }
575
- }