@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.
- package/README.md +168 -22
- package/dist/cli.js +102 -16
- package/dist/client.d.ts +81 -50
- package/dist/client.js +1015 -201
- package/dist/config.d.ts +12 -1
- package/dist/config.js +34 -2
- package/dist/crypto/digest.js +3 -2
- package/dist/crypto/dpop.d.ts +38 -1
- package/dist/crypto/dpop.js +124 -6
- package/dist/crypto/encoding.js +2 -0
- package/dist/crypto/index.d.ts +6 -4
- package/dist/crypto/index.js +4 -3
- package/dist/crypto/key-api.d.ts +69 -0
- package/dist/crypto/key-api.js +217 -0
- package/dist/crypto/keys.d.ts +5 -0
- package/dist/crypto/keys.js +25 -15
- package/dist/crypto/signatures.d.ts +13 -24
- package/dist/crypto/signatures.js +41 -84
- package/dist/crypto/webcrypto.d.ts +39 -0
- package/dist/crypto/webcrypto.js +26 -0
- package/dist/express.d.ts +2 -1
- package/dist/express.js +12 -32
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/keystore/file.d.ts +14 -0
- package/dist/keystore/file.js +77 -0
- package/dist/keystore/index.d.ts +18 -0
- package/dist/keystore/index.js +71 -0
- package/dist/middleware.d.ts +27 -3
- package/dist/middleware.js +75 -22
- package/dist/next.d.ts +5 -3
- package/dist/next.js +38 -42
- package/dist/types.d.ts +129 -4
- package/package.json +11 -1
- package/dist/signing.d.ts +0 -3
- package/dist/signing.js +0 -2
|
@@ -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
|
+
}
|
package/dist/crypto/keys.d.ts
CHANGED
|
@@ -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>;
|
package/dist/crypto/keys.js
CHANGED
|
@@ -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
|
|
25
|
+
const keyPair = (await webcrypto.generateKey("Ed25519", true, [
|
|
25
26
|
"sign",
|
|
26
27
|
"verify",
|
|
27
28
|
]));
|
|
28
|
-
const jwk = (await
|
|
29
|
-
const pkcs8Buffer = await
|
|
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
|
|
62
|
-
const jwk = (await
|
|
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
|
|
73
|
-
const pkcs8Buffer = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
240
|
-
const jwk = (await
|
|
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
|
|
100
|
-
* Requires a numeric `exp
|
|
101
|
-
* a verifier without an expected slug rejects every
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
422
|
-
* Requires a numeric `exp
|
|
423
|
-
* a verifier without an expected slug rejects every
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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"
|
|
463
|
+
if (header.alg !== "EdDSA") {
|
|
451
464
|
return { valid: false, error: `Unsupported JWT alg: ${header.alg}` };
|
|
452
465
|
}
|
|
453
|
-
//
|
|
454
|
-
if (header.typ !==
|
|
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
|
|
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
|
|
503
|
+
return { valid: false, error: "JWT has no aud claim" };
|
|
497
504
|
}
|
|
498
|
-
|
|
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
|
-
}
|