@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
package/README.md
CHANGED
|
@@ -15,11 +15,13 @@ Runs on **Node.js 20.19+**, **Bun**, **Deno**, **Cloudflare Workers**, **Next.js
|
|
|
15
15
|
- **Web Standards Core**: standard `crypto.subtle`, `Uint8Array`, `fetch`, `Request`, and `Response`. The one runtime dependency is [`re2js`](https://www.npmjs.com/package/re2js), so flag regex rules run in linear time.
|
|
16
16
|
- **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@query` (when the URL has one), `@authority`, and `content-digest` (POST/PUT/PATCH and any request with a body) using Ed25519 signatures (`Signature-Input`, `Signature`).
|
|
17
17
|
- **RFC 9530 (Digest Fields)**: Computes standard `Content-Digest: sha-256=:<base64>:` headers for payload integrity.
|
|
18
|
+
- **OAuth 2.0 Client Credentials**: Calls to Daywalker Auth carry a short-lived, DPoP-bound (RFC 9449) access token from `POST /api/v1/oauth/token`, refreshed automatically with rotating refresh tokens. The API key is only the client credential for that grant and is never sent anywhere else.
|
|
18
19
|
- **Class-Based Client Architecture**: Instantiable `Daywalker` client supporting dependency injection, with a lazy default singleton for plug-and-play simplicity.
|
|
19
|
-
- **Resilience & Rate Limiting**: 5s per-attempt timeout, exponential backoff with jitter, `Retry-After` honored up to 60s. Network errors are retried only for idempotent requests (or with an `idempotencyKey`), and redirects are never followed, so
|
|
20
|
+
- **Resilience & Rate Limiting**: 5s per-attempt timeout, exponential backoff with jitter, `Retry-After` honored up to 60s. Network errors are retried only for idempotent requests (or with an `idempotencyKey`), and redirects are never followed, so credentials and access tokens can't leak to another host.
|
|
20
21
|
- **Deterministic Type-Safe Results**: Discriminated union returns (`Result<T, E>`) for clean handling without try/catch boilerplate.
|
|
21
22
|
- **Security-Hardened Next.js Middleware**: Built-in Server Action probe mitigation (CVE-2025-55182 defense), normalized paths (no `%2e%2e` / `//` bypasses), open-redirect-safe error routing, and fail-closed validation (503 when Daywalker is unreachable).
|
|
22
|
-
- **Zero-Egress Inbound Verification**: In-memory caching (
|
|
23
|
+
- **Zero-Egress Inbound Verification**: In-memory caching (60s by default, never more than 300s) and offline verification of Daywalker access tokens, including the DPoP proof of sender-constrained tokens.
|
|
24
|
+
- **Tamper-Resistant by Design**: client state lives in true private fields, config and prototypes are frozen, WebCrypto is captured at load, and every input is bounded and validated. See [Security model](#security-model).
|
|
23
25
|
- **Decoupled Push Architecture CLI**: `npx daywalker init --json` registers keys directly via `POST /api/v1/keys/register` with zero runtime reverse-HTTP calls.
|
|
24
26
|
|
|
25
27
|
---
|
|
@@ -55,21 +57,97 @@ DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --do
|
|
|
55
57
|
|
|
56
58
|
The private key is never printed unless you pass `--print-key`.
|
|
57
59
|
|
|
60
|
+
### Or: ephemeral keys (nothing stored anywhere)
|
|
61
|
+
|
|
62
|
+
With `ephemeralKey`, each process generates its signing key in memory as a **non-extractable** key. It registers that key with a lifetime and replaces it in place before it expires. There's no private key in env vars, files or a store, and nothing to rotate by hand:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const client = new Daywalker({
|
|
66
|
+
ephemeralKey: { ttlSeconds: 21_600, instanceId: process.env.HOSTNAME }, // or DAYWALKER_EPHEMERAL_KEY=true
|
|
67
|
+
});
|
|
68
|
+
process.on("SIGTERM", async () => {
|
|
69
|
+
await client.close(); // revokes the key (and the OAuth sessions it started)
|
|
70
|
+
process.exit(0);
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Lifetime** (`ttlSeconds`, env `DAYWALKER_KEY_TTL_SECONDS`): 1 hour to 30 days, default 6 hours. When a quarter of it is left, a new key is registered in the same slot. Requests keep using the old key until the new one is ready, and Daywalker keeps the old key valid for 10 more minutes. The next OAuth session starts with the new key.
|
|
75
|
+
- **Slot** (`instanceId`, env `DAYWALKER_INSTANCE_ID`): one key per (domain, slot). Registering in an occupied slot retires the previous key. Default: a random slot per process.
|
|
76
|
+
- **Limit:** Daywalker allows 20 live keys per domain. Give long-lived instances a stable `instanceId` so a restart replaces its own key, call `close()` on shutdown, and use a shared `keyStore` instead on serverless platforms with many short-lived instances. When the domain is full, the error says so: list keys with `npx daywalker keys` and revoke unused ones with `npx daywalker revoke <keyId>`.
|
|
77
|
+
|
|
78
|
+
### Or: provision the key automatically
|
|
79
|
+
|
|
80
|
+
Instead of running `init` and copying `DAYWALKER_SIGNING_KEY` into every environment, give the client a **key store**. On first use it loads the key from the store, or generates one, registers it for the service's domain (`POST /api/v1/keys/register`, with `DAYWALKER_API_KEY`) and saves it:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { Daywalker } from "@shelbyb/daywalker-sdk";
|
|
84
|
+
import { fileKeyStore } from "@shelbyb/daywalker-sdk/keystore/file";
|
|
85
|
+
|
|
86
|
+
const client = new Daywalker({
|
|
87
|
+
keyStore: fileKeyStore(), // .daywalker/signing-key.json (0600, in a 0700 directory)
|
|
88
|
+
});
|
|
89
|
+
await client.provisionSigningKey(); // optional: provision at startup and fail fast, instead of on the first request
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
All you need is `DAYWALKER_API_KEY` and a domain. The domain is detected from `VERCEL_PROJECT_PRODUCTION_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `APP_URL` and similar; set `DAYWALKER_DOMAIN` elsewhere.
|
|
93
|
+
|
|
94
|
+
| Store | Import | Use it for |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `fileKeyStore({ path? })` | `@shelbyb/daywalker-sdk/keystore/file` | VMs and containers with a persistent volume (Node.js). Atomic create-if-absent; refuses symlinks and files others can read |
|
|
97
|
+
| `memoryKeyStore()` | `@shelbyb/daywalker-sdk/keystore` | Tests and single long-lived processes: every new process registers a new key |
|
|
98
|
+
| `envKeyStore(env?)` | `@shelbyb/daywalker-sdk/keystore` | Read-only: the key `daywalker init` wrote to the environment |
|
|
99
|
+
| your own | — | Serverless and several instances: any shared store with an atomic create-if-absent (Redis `SET key value NX`, Vercel / Cloudflare KV, a secrets manager) |
|
|
100
|
+
|
|
101
|
+
A store is `{ load(), saveIfAbsent?(record) }`. `saveIfAbsent` must be atomic: when several instances start at once, exactly one save wins and the others load the winner's key. Each of them may already have registered its own key; the losers revoke theirs right away (`DELETE /api/v1/keys/{keyId}`). Stored keys don't expire; every key the SDK registers gets its own slot (`dw-<keyId>`), so registering one never retires a key another deployment is still using.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const redisKeyStore = (redis) => ({
|
|
105
|
+
load: async () => JSON.parse((await redis.get("daywalker:signing-key")) ?? "null"),
|
|
106
|
+
saveIfAbsent: async (record) => (await redis.set("daywalker:signing-key", JSON.stringify(record), { NX: true })) === "OK",
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Safeguards:
|
|
111
|
+
- Only keys this process generated **and registered** are ever saved, so a key planted in the store is never registered with your API key.
|
|
112
|
+
- A stored key is checked against its `keyId` (RFC 7638 thumbprint) and domain on every load, and imported non-extractable.
|
|
113
|
+
- `selfCheck()` / `npx daywalker check --offline` only read the store; they never provision.
|
|
114
|
+
|
|
115
|
+
Treat the store like any secret: it holds the private key. `DAYWALKER_SIGNING_KEY` and `signer` take precedence over `keyStore`.
|
|
116
|
+
|
|
117
|
+
### Or: keep the key in a KMS (no private key in the app at all)
|
|
118
|
+
|
|
119
|
+
Pass a `signer` and the SDK never sees the private key. Every RFC 9421 signature is made by your KMS or HSM. `sign` receives the signature base and must return the raw 64-byte Ed25519 signature:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const client = new Daywalker({
|
|
123
|
+
signer: {
|
|
124
|
+
keyId: "kms-ed25519-2026-10", // the id you registered the key under
|
|
125
|
+
publicJwk, // optional but recommended: the SDK checks the signer against it once before any request
|
|
126
|
+
sign: async (data) => yourKms.signEd25519(KMS_KEY_ID, data), // must resolve to the raw 64-byte signature (Uint8Array)
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Your KMS must support Ed25519 (pure EdDSA over the message, not a pre-hashed variant). Register the KMS public key once with `registerPublicKey({ apiKey, domain, publicJwk })`. The signer is captured at construction, so changing the object later has no effect.
|
|
132
|
+
|
|
58
133
|
---
|
|
59
134
|
|
|
60
135
|
## Environment
|
|
61
136
|
|
|
62
137
|
| Variable | Required | Purpose |
|
|
63
138
|
|---|---|---|
|
|
64
|
-
| `DAYWALKER_API_KEY` | ✅ | The service's
|
|
139
|
+
| `DAYWALKER_API_KEY` | ✅ | The service's OAuth client credential (`srv_live_…`). It is sent only to `POST /api/v1/oauth/token` (client credentials grant, together with the signing key) and to `POST /api/v1/keys/register` by `daywalker init` / `rotate`. Every other call uses an access token. See [Authentication](#authentication-oauth-20-client-credentials) |
|
|
65
140
|
| `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against (e.g. `sonalyze`). One API key can cover several services: a comma-separated list (`gigpulse,devpulse`) is accepted and the first is the default. See [One API key, several services](#one-api-key-several-services) |
|
|
66
|
-
| `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM) |
|
|
141
|
+
| `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM). Not needed with a `keyStore` (auto-provisioning) or a `signer` (KMS); see [Setup](#setup-once-per-service) |
|
|
67
142
|
| `DAYWALKER_KEY_ID` | optional | RFC 7638 JWK thumbprint derived during key generation |
|
|
68
143
|
| `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
|
|
69
|
-
| `DAYWALKER_DOMAIN` |
|
|
144
|
+
| `DAYWALKER_DOMAIN` | when not detected | The service's public domain: the key is registered for it. Detected from your platform's URL variables when unset |
|
|
145
|
+
| `DAYWALKER_EPHEMERAL_KEY` | optional | `true` for [ephemeral signing keys](#or-ephemeral-keys-nothing-stored-anywhere), with `DAYWALKER_KEY_TTL_SECONDS` and `DAYWALKER_INSTANCE_ID` |
|
|
70
146
|
| `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
|
|
147
|
+
| `DAYWALKER_JWKS` | optional | `auto` verifies access tokens offline against Daywalker's JWKS, fetched from `${DAYWALKER_AUTH_URL}/.well-known/jwks.json` and cached, so signing-key rotation needs no redeploy. Default `off`. See [Zero-Egress Offline Verification](#zero-egress-offline-verification) |
|
|
71
148
|
| `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev`; must be `https://` (except localhost) |
|
|
72
149
|
| `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWTs (default `https://auth.daywalker.dev`) |
|
|
150
|
+
| `DAYWALKER_PUBLIC_ORIGIN` | behind a proxy | The origin clients use to reach this service (e.g. `https://api.example.com`), for checking DPoP proofs. Defaults to the request URL's origin |
|
|
73
151
|
|
|
74
152
|
---
|
|
75
153
|
|
|
@@ -90,7 +168,7 @@ const client = new Daywalker({
|
|
|
90
168
|
|
|
91
169
|
// Option 2: Default lazy singleton (reads process.env automatically)
|
|
92
170
|
const result = await daywalker.validateToken(incomingToken);
|
|
93
|
-
// Service health check of this service's own
|
|
171
|
+
// Service health check of this service's own OAuth client credentials: daywalker.validateServiceCredentials()
|
|
94
172
|
|
|
95
173
|
if (result.ok) {
|
|
96
174
|
console.log("Validated tenant:", result.data.tenantId);
|
|
@@ -101,6 +179,28 @@ if (result.ok) {
|
|
|
101
179
|
}
|
|
102
180
|
```
|
|
103
181
|
|
|
182
|
+
### Authentication (OAuth 2.0 Client Credentials)
|
|
183
|
+
|
|
184
|
+
The SDK authenticates to Daywalker Auth with the OAuth 2.0 client credentials grant ([RFC 6749 §4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)) described in the [Daywalker API docs](https://auth.daywalker.dev/docs). You don't call it yourself: the first request that needs it runs the flow.
|
|
185
|
+
|
|
186
|
+
1. **Client credentials grant.** `POST /api/v1/oauth/token` with `grant_type=client_credentials`, authenticated by the API key (`Authorization: Bearer srv_live_…`) **and** an RFC 9421 signature from the registered Ed25519 signing key, from an allowlisted domain or IP. This is the only request that carries the API key.
|
|
187
|
+
2. **DPoP-bound tokens.** Every token request carries a DPoP proof ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) from a key the client generates in memory. Daywalker answers with `token_type: "DPoP"`: a 5 to 15 minute EdDSA access token bound to that key (`cnf.jkt`), plus a single-use refresh token (30 day session).
|
|
188
|
+
3. **Calls use the access token.** `validateToken()` and `client.fetch()` send `Authorization: DPoP <access_token>` plus a fresh DPoP proof with `ath` (bearer or unbound tokens are refused with `401 DPOP_REQUIRED`).
|
|
189
|
+
4. **Refresh.** Within 30 seconds of expiry, or on `401 ACCESS_TOKEN_EXPIRED` (retried once), the client trades the refresh token for a new pair (`grant_type=refresh_token`, no API key, same DPoP key). When the session has ended (`invalid_grant`), it runs the client credentials grant again.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const client = new Daywalker({
|
|
193
|
+
apiKey: process.env.DAYWALKER_API_KEY, // client credential, used only at the token endpoint
|
|
194
|
+
signingKey: process.env.DAYWALKER_SIGNING_KEY, // signs the grant (RFC 9421)
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await client.validateToken(incomingToken); // Authorization: DPoP <access token>, obtained on demand
|
|
198
|
+
await client.accessToken(); // the current access token, refreshed as needed
|
|
199
|
+
await client.validateServiceCredentials(); // health check: can this service still get a token for its service?
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
If the grant itself fails (revoked API key, unregistered signing key, allowlist mismatch), `validateToken()` returns `503 SERVICE_UNAVAILABLE` with the token endpoint's code in the message, never a `401` for the caller's token; a missing `DAYWALKER_API_KEY` is `500 CONFIGURATION_ERROR`.
|
|
203
|
+
|
|
104
204
|
### Type-Safe Verification & Error Handling
|
|
105
205
|
|
|
106
206
|
```ts
|
|
@@ -120,11 +220,11 @@ try {
|
|
|
120
220
|
### RFC 9421 Signed Requests with Automatic Backoff
|
|
121
221
|
|
|
122
222
|
```ts
|
|
123
|
-
// Outbound request automatically
|
|
124
|
-
// and attaches RFC 9421 signature headers. Retries 429 rate limits automatically.
|
|
223
|
+
// Outbound request automatically adds `Authorization: DPoP <access token>` with a DPoP proof,
|
|
224
|
+
// computes Content-Digest (RFC 9530) and attaches RFC 9421 signature headers. Retries 429 rate limits automatically.
|
|
125
225
|
const res = await client.fetch("/api/v1/tokens/validate", {
|
|
126
226
|
method: "POST",
|
|
127
|
-
body: { token: "user_token"
|
|
227
|
+
body: { token: "user_token" },
|
|
128
228
|
idempotencyKey: crypto.randomUUID(), // optional: lets a POST be retried after a network error
|
|
129
229
|
});
|
|
130
230
|
```
|
|
@@ -149,7 +249,7 @@ export const config = {
|
|
|
149
249
|
};
|
|
150
250
|
```
|
|
151
251
|
|
|
152
|
-
Each request must carry `Authorization: Bearer <token>` or `x-api-token
|
|
252
|
+
Each request must carry `Authorization: Bearer <token>`, `Authorization: DPoP <token>` with its `DPoP` proof header, or `x-api-token`. An `Authorization` header with any other scheme, or a credentials header sent twice, is refused rather than skipped. Without credentials, API routes get `401` and pages redirect to `errorPath` (without an error message in the URL). Paths are decoded and normalized before any check, so `/_next/static/../admin` is authenticated. Only `/_next/static/`, `/_next/image`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` and the health paths skip auth by default. File extensions say nothing about whether a route is public, so they no longer skip auth; opt in with `staticFileExtensions: STATIC_FILE_EXTENSIONS` if you rely on that. When Daywalker is unreachable the middleware answers `503`, and `500` for a configuration error. To gate the app on this service's own OAuth client credentials instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
|
|
153
253
|
|
|
154
254
|
### Route Handler Wrapper
|
|
155
255
|
|
|
@@ -185,16 +285,15 @@ app.get("/api/me", (c) => {
|
|
|
185
285
|
|
|
186
286
|
```ts
|
|
187
287
|
import express from "express";
|
|
188
|
-
import { daywalkerAuth
|
|
288
|
+
import { daywalkerAuth } from "@shelbyb/daywalker-sdk/express";
|
|
189
289
|
|
|
190
290
|
const app = express();
|
|
191
|
-
app.use(daywalkerKeys()); // serves GET /.well-known/daywalker-keys.json
|
|
192
291
|
app.use("/api", daywalkerAuth({ serviceSlug: "sonalyze" }));
|
|
193
292
|
|
|
194
293
|
app.get("/api/me", (req, res) => res.json({ auth: req.auth }));
|
|
195
294
|
```
|
|
196
295
|
|
|
197
|
-
`daywalkerAuth` reads
|
|
296
|
+
`daywalkerAuth` reads credentials the same way as the Next middleware, answers `401` when there are none (or they are malformed), and otherwise returns the same status codes as the other middleware (`429` with `Retry-After`, `503` when Daywalker is unreachable). On success the validated payload is on `req.auth`. It also works with plain `http.createServer` handlers.
|
|
198
297
|
|
|
199
298
|
---
|
|
200
299
|
|
|
@@ -213,7 +312,7 @@ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service devpulse --domain de
|
|
|
213
312
|
|
|
214
313
|
`init` and `rotate` take no service: the API key decides which services your credentials cover (`--service` is optional and only sets the audience for offline verification, `DAYWALKER_SERVICE_SLUG`).
|
|
215
314
|
|
|
216
|
-
**Client credentials flow (the JWT lists every service).** Your backend authenticates with its client credentials (API key + registered signing key) and gets a short-lived access token (5 to 15 minutes) plus a rotating refresh token. No request names a service; the token lists them:
|
|
315
|
+
**Client credentials flow (the JWT lists every service).** Your backend authenticates with its client credentials (API key + registered signing key, see [Authentication](#authentication-oauth-20-client-credentials)) and gets a short-lived access token (5 to 15 minutes) plus a rotating refresh token. No request names a service; the token lists them:
|
|
217
316
|
|
|
218
317
|
```ts
|
|
219
318
|
const tokens = await client.getTokens();
|
|
@@ -278,17 +377,46 @@ const client = new Daywalker({
|
|
|
278
377
|
const result = await client.validateToken(incomingJwt);
|
|
279
378
|
```
|
|
280
379
|
|
|
281
|
-
|
|
380
|
+
### Surviving Daywalker key rotation (`jwks: "auto"`)
|
|
381
|
+
|
|
382
|
+
A static `DAYWALKER_PUBLIC_KEY` stops verifying the moment Daywalker rotates its signing key. Set `DAYWALKER_JWKS=auto` (or `jwks: "auto"`) and the client verifies against Daywalker's published key set instead:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
const client = new Daywalker({ serviceSlug: "myservice", jwks: "auto" });
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
- The set is fetched from `${authUrl}/.well-known/jwks.json` and nowhere else: a `jwks_uri` in a token response or in the set itself is never followed. The fetch sends no credentials, follows no redirects, bypasses HTTP caches, is capped at 1 MiB and times out after `timeoutMs`.
|
|
389
|
+
- Only Ed25519 signing keys for EdDSA with a `kid` are kept (`kty: OKP`, `crv: Ed25519`, `alg` absent or `EdDSA`, `use` absent or `sig`, no private part), at most 16. Everything else in the set is ignored.
|
|
390
|
+
- The set is cached in the client for 10 minutes (`JWKS_CACHE_TTL_MS`). A token naming a `kid` the set doesn't have (a rotation) triggers a refetch, but fetches are never closer than 30 seconds apart (`JWKS_REFETCH_COOLDOWN_MS`), whatever triggers them, so tokens with made-up `kid`s can't make the client hammer Daywalker. Concurrent callers share one fetch.
|
|
391
|
+
- A failed refresh (outage, redirect, non-JSON, no usable key) keeps the last good set.
|
|
392
|
+
- Fail closed: while no set has ever been obtained, the static `daywalkerPublicKey` is used if one is configured; otherwise `offlineOnly` validation returns `503 SERVICE_UNAVAILABLE` and normal validation goes online (`POST /api/v1/tokens/validate`).
|
|
393
|
+
|
|
394
|
+
Without `jwks: "auto"`, offline verification uses `daywalkerPublicKey` exactly as before.
|
|
395
|
+
|
|
396
|
+
Tokens must be RFC 9068 access tokens (`typ: at+jwt`, `alg: EdDSA`) with a numeric `exp`, a `jti`, and an `aud` containing your service slug, so `serviceSlug` must be configured. A token that lists `services` must list yours too. A DPoP-bound token (`cnf.jkt`) is only accepted with `Authorization: DPoP` and a valid proof: right method and URL, signed by the bound key, fresh, carrying the token's hash, and never used before. A copied token alone is useless. With several instances, pass a shared `dpopReplayStore` (like `replayStore` for signatures). `result.data.services` is every service the lease covers (see [One API key, several services](#one-api-key-several-services)). Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
|
|
282
397
|
|
|
283
398
|
---
|
|
284
399
|
|
|
400
|
+
## Service License & Config Bundle
|
|
401
|
+
|
|
402
|
+
Daywalker issues each service a license key with the client credentials grant: derived per service, signing key and one-hour window, never returned for a revoked or suspended service.
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
const { licenseKey, expiresAt } = await client.getLicense(); // cached until a minute before expiry
|
|
406
|
+
const bundle = await client.fetchBundle(); // { dataKeySalt, features, issuedAt, expiresAt, ... }
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
`fetchBundle()` downloads a bundle sealed (AES-256-GCM) under the license key and opens it locally, so only a client that got the license from Daywalker can read it. Derive app secrets from `licenseKey` + `bundle.dataKeySalt` (HKDF) so removing the SDK breaks the app. Errors are `DaywalkerAuthError`s: `SERVICE_REVOKED`, `SERVICE_SUSPENDED`, `DOMAIN_REVOKED`, `DOMAIN_MISMATCH`, `LICENSE_UNAVAILABLE`, `BUNDLE_UNREADABLE`. Never log the key.
|
|
410
|
+
|
|
285
411
|
## CLI Reference
|
|
286
412
|
|
|
287
413
|
| Command | Description |
|
|
288
414
|
|---|---|
|
|
289
415
|
| `npx daywalker init --service <slug[,slug…]> [--domain <host>] [--api-key-stdin] [--skip-register] [--force] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
|
|
290
|
-
| `npx daywalker check` | Validate configuration
|
|
291
|
-
| `npx daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local
|
|
416
|
+
| `npx daywalker check [--offline]` | Validate configuration and signature readiness, then run the OAuth client credentials grant to confirm the credentials work and cover the configured service(s) (`--offline` skips that) |
|
|
417
|
+
| `npx daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local`. The previous key stays registered until you revoke it, so running deployments keep working |
|
|
418
|
+
| `npx daywalker keys [--domain <host>] [--json]` | List the domain's signing keys: status, slot, expiry, last use, and which one is current or previous |
|
|
419
|
+
| `npx daywalker revoke <keyId> [--force]` | Revoke a key now; Daywalker also ends the OAuth sessions it started. Refuses the current `DAYWALKER_KEY_ID` unless `--force` |
|
|
292
420
|
|
|
293
421
|
---
|
|
294
422
|
|
|
@@ -296,12 +424,14 @@ Tokens must have a numeric `exp`, a `jti`, and an `aud` (or `serviceSlug`) conta
|
|
|
296
424
|
|
|
297
425
|
| Endpoint | SDK usage | Notes |
|
|
298
426
|
|---|---|---|
|
|
299
|
-
| `POST /api/v1/tokens/validate` | `client.validateToken()` |
|
|
300
|
-
| `POST /api/v1/keys/register` | `npx daywalker init` / `rotate` | Bearer
|
|
301
|
-
| `
|
|
427
|
+
| `POST /api/v1/tokens/validate` | `client.validateToken()` | `Authorization: DPoP <access_token>` + `DPoP` proof; body `{ token }`; signed with `Signature-Input`, `Signature`, `Content-Digest`. Errors: `400` (`DIGEST_MISMATCH`/missing fields), `401` (`INVALID_SIGNATURE`, `DPOP_REQUIRED`, `INVALID_DPOP_PROOF`, `DPOP_REPLAY`, `ACCESS_TOKEN_EXPIRED`, invalid token), `403` (`SERVICE_NOT_GRANTED`, allowlist), `429`. Response includes `valid`, `tokenId`, `tokenName`, `userId`, `stage`, `rateLimit`, `rateLimitHourly` and `X-RateLimit-*` / `X-RateLimit-Hourly-*` headers |
|
|
428
|
+
| `POST /api/v1/keys/register` | `registerPublicKey()`, `npx daywalker init` / `rotate`, `keyStore` / `ephemeralKey` provisioning | `Authorization: Bearer <API key>` (bootstraps the signing key the client credentials grant needs); body `{ keyId, domain, publicKey, serviceSlug?, instanceId?, ttlSeconds?, expiresAt? }`. `200` re-registration (idempotent), `201` new; `409 KEY_LIMIT_REACHED` (20 live keys per domain) or `KEY_ID_CONFLICT`; `403 domain_not_allowed`. The SDK always passes an `instanceId`, because without one Daywalker retires every other slot-less key of the domain after 10 minutes |
|
|
429
|
+
| `GET /api/v1/keys?domain=` | `listSigningKeys()`, `npx daywalker keys` | The domain's keys with `status`, `instanceId`, `expiresAt`, `lastUsedAt` |
|
|
430
|
+
| `DELETE /api/v1/keys/{keyId}` | `revokeSigningKey()`, `client.close()`, `npx daywalker revoke` | Revokes now and ends the OAuth sessions started with the key |
|
|
431
|
+
| `POST /api/v1/oauth/token` | `client.getTokens({ scope?, expiresIn? })`, `client.refreshTokens()`, `client.accessToken()` (called for you by `validateToken()` / `fetch()`) | `client_credentials` (API key as Bearer + signed request, mandatory DPoP) and `refresh_token` grants return `{ accessToken, refreshToken, expiresAt, services, … }`; tokens are sender-constrained via DPoP (RFC 9449), refresh tokens rotate, and reuse revokes the session |
|
|
302
432
|
| `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
|
|
303
433
|
|
|
304
|
-
Inbound tokens may be supplied as `x-api-token: srv_live_
|
|
434
|
+
Inbound tokens may be supplied as `x-api-token: srv_live_...`, `Authorization: Bearer <token>`, or `Authorization: DPoP <access token>` with a `DPoP` proof. An online answer must name your service: a token validated for a sibling service your credentials also cover is refused with `403 SERVICE_NOT_GRANTED`.
|
|
305
435
|
|
|
306
436
|
---
|
|
307
437
|
|
|
@@ -311,7 +441,7 @@ Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
|
|
|
311
441
|
- `Content-Digest: sha-256=:<base64-encoded-hash>:` (POST/PUT/PATCH, and any request with a body)
|
|
312
442
|
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`. `"@query"` is added after `"@path"` when the URL has a query string; `"content-digest"` is left out only for bodyless GET/HEAD/DELETE/OPTIONS requests.
|
|
313
443
|
- `Signature: sig1=:<base64-encoded-signature>:`
|
|
314
|
-
- `Authorization: Bearer <DAYWALKER_API_KEY>`
|
|
444
|
+
- `Authorization: DPoP <access_token>` and `DPoP: <proof JWT>` (`htm`, `htu`, `iat`, `jti`, `ath`). Only the client credentials grant on `/api/v1/oauth/token` sends `Authorization: Bearer <DAYWALKER_API_KEY>` instead.
|
|
315
445
|
|
|
316
446
|
A fresh random `nonce` is generated for every attempt (including retries), so identical requests in the same second still produce unique signatures and never trip replay protection. `validateToken()` also shares one in-flight request between concurrent callers for the same token, and briefly caches failures (`negativeCacheTtlMs`, default 3000ms; 429s and network errors are never cached).
|
|
317
447
|
|
|
@@ -319,6 +449,22 @@ Verification reads the covered components from `Signature-Input`, requires `cont
|
|
|
319
449
|
|
|
320
450
|
---
|
|
321
451
|
|
|
452
|
+
## Security Model
|
|
453
|
+
|
|
454
|
+
This SDK checks that callers hold a valid Daywalker credential, and that the service it runs in holds valid OAuth client credentials. It is built to resist tampering by **other code in the same process** (a compromised dependency, an injected script, a confused AI agent editing at runtime):
|
|
455
|
+
|
|
456
|
+
- **No reachable internals.** Caches, tokens, the DPoP key and the config are ES `#private` fields, so `client._cache` doesn't exist to be poisoned. `Daywalker.prototype`, the class and each instance are frozen, so `validateToken` can't be swapped by assignment, including on the shared `daywalker` client.
|
|
457
|
+
- **Frozen, secret-free config.** `client.config` is frozen and shows the API key and private keys only as `"[configured]"`. The options object is read once, so changing it later (say, `authUrl` or `fetch`) has no effect.
|
|
458
|
+
- **Captured primitives.** WebCrypto (`subtle.verify`, `sign`, `digest`, …) is captured when the SDK loads, so replacing `crypto.subtle.verify` later can't make forged tokens verify. The Next adapter builds its own responses and ignores any `NextResponse` planted on `globalThis`.
|
|
459
|
+
- **Sender-constrained tokens.** The SDK's own access tokens are DPoP-bound to a non-extractable key generated in memory, and only the token endpoint ever sees the API key. Incoming DPoP-bound tokens need a valid proof.
|
|
460
|
+
- **Bounded, validated input.** Tokens (≤ 8 KiB, token characters only), headers, DPoP proofs, JWTs and response bodies (≤ 1 MiB) are size-checked and validated before use. Nothing is followed through a redirect, cached by an HTTP cache (`cache: "no-store"`), or trusted from an answer of the wrong shape (fail closed).
|
|
461
|
+
|
|
462
|
+
**What no client-side SDK can do:** stop the operator of the server it runs on. Whoever controls a deployment can edit `node_modules`, patch the code, or delete the middleware, and obfuscation only slows that down. So treat this package as the *enforcement point*, and put the *decision* on Daywalker's side, where it can't be removed:
|
|
463
|
+
|
|
464
|
+
1. Make the service depend on what only a valid grant provides. Read limits and features from the verified access token (`service_limits`, `service_features`) rather than from local config, so code with the SDK ripped out has nothing to run on.
|
|
465
|
+
2. Revoke on Daywalker. Revoking the API key, signing key or allowlist entry ends the client credentials session (and every refresh) at the next token request, within 15 minutes at most.
|
|
466
|
+
3. Keep the signing key and API key in a secret manager, run `npx daywalker check` in CI and at startup, and watch Daywalker's usage analytics for clients that stop calling in.
|
|
467
|
+
|
|
322
468
|
## DevPulse SDKs (one package)
|
|
323
469
|
|
|
324
470
|
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.
|
package/dist/cli.js
CHANGED
|
@@ -3,27 +3,35 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createDaywalker } from "./client.js";
|
|
5
5
|
import { parseServiceSlugs, publicHostname } from "./config.js";
|
|
6
|
-
import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets } from "./crypto/index.js";
|
|
6
|
+
import { generateSigningKey, keySlotFor, listSigningKeys, loadSigningKey, registerPublicKey, redactSecrets, revokeSigningKey } from "./crypto/index.js";
|
|
7
7
|
const HELP = `daywalker: connect a service to Daywalker Auth (RFC 9421 / Push Architecture)
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
daywalker init [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--skip-register] [--json|--export] [--print-key]
|
|
11
11
|
daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--skip-register] [--json|--export] [--print-key]
|
|
12
|
-
daywalker check
|
|
12
|
+
daywalker check [--offline]
|
|
13
|
+
daywalker keys [--domain <host>] [--auth-url <url>] [--api-key-stdin] [--json]
|
|
14
|
+
daywalker revoke <keyId> [--domain <host>] [--auth-url <url>] [--api-key-stdin] [--force]
|
|
13
15
|
|
|
14
16
|
init Generates an Ed25519 keypair, registers the public key directly with Daywalker Auth
|
|
15
17
|
(POST /api/v1/keys/register), and writes DAYWALKER_* settings to .env.local (or outputs JSON with --json).
|
|
16
18
|
Nothing is written if registration fails. --force replaces an existing key (kept as
|
|
17
19
|
DAYWALKER_SIGNING_KEY_PREVIOUS for rollback). No public ingress or /.well-known route handlers are required.
|
|
18
20
|
rotate Generates a new Ed25519 key, registers it with Daywalker Auth, and keeps the old one
|
|
19
|
-
in .env.local as DAYWALKER_SIGNING_KEY_PREVIOUS during deployment transition.
|
|
21
|
+
in .env.local as DAYWALKER_SIGNING_KEY_PREVIOUS during deployment transition. The old key stays
|
|
22
|
+
registered until you revoke it (\`daywalker revoke <keyId>\`) once the new key is deployed everywhere.
|
|
20
23
|
Nothing is written, and the exit code is non-zero, unless registration succeeds.
|
|
21
|
-
check Validates configuration and
|
|
24
|
+
check Validates configuration and signature readiness, then runs the OAuth client credentials grant
|
|
25
|
+
against Daywalker Auth to confirm the credentials work and cover the configured service(s).
|
|
26
|
+
keys Lists the signing keys registered for the domain (status, slot, expiry, last use).
|
|
27
|
+
revoke Revokes a signing key now; Daywalker also ends the OAuth sessions it started. Refuses the key in
|
|
28
|
+
DAYWALKER_KEY_ID (the one this service signs with) unless --force.
|
|
22
29
|
|
|
23
30
|
Options:
|
|
24
31
|
--api-key-stdin Read the Daywalker API key from stdin (e.g. \`op read ... | daywalker init --api-key-stdin\`).
|
|
25
32
|
The key is otherwise taken from DAYWALKER_API_KEY in the environment or .env.local.
|
|
26
|
-
--api-key <key>
|
|
33
|
+
(--api-key <key> is refused: it would expose the key in shell history and process listings.)
|
|
34
|
+
--offline check only: skip the live OAuth client credentials check against Daywalker Auth.
|
|
27
35
|
--skip-register Do not register the public key (alias: --no-register); register it yourself before deploying.
|
|
28
36
|
--json, --export Output configuration as JSON for piping into Vercel, DigitalOcean, Fly.io, AWS SSM, etc.
|
|
29
37
|
The JSON includes the private key (DAYWALKER_SIGNING_KEY); nothing is written to .env.local.
|
|
@@ -59,8 +67,6 @@ async function readStdin() {
|
|
|
59
67
|
return Buffer.concat(chunks).toString("utf8").trim();
|
|
60
68
|
}
|
|
61
69
|
async function resolveApiKey(opts, existing) {
|
|
62
|
-
if (typeof opts["api-key"] === "string")
|
|
63
|
-
return opts["api-key"].trim() || null;
|
|
64
70
|
if (opts["api-key-stdin"]) {
|
|
65
71
|
const fromStdin = await readStdin();
|
|
66
72
|
if (!fromStdin)
|
|
@@ -69,6 +75,13 @@ async function resolveApiKey(opts, existing) {
|
|
|
69
75
|
}
|
|
70
76
|
return String(existing.get("DAYWALKER_API_KEY") ?? process.env.DAYWALKER_API_KEY ?? "").trim() || null;
|
|
71
77
|
}
|
|
78
|
+
/** Why a key API call failed, with what to do when the domain is full. */
|
|
79
|
+
function keyApiFailure(what, result) {
|
|
80
|
+
const hint = result.code === "KEY_LIMIT_REACHED"
|
|
81
|
+
? "\n The domain has 20 live keys: list them with `daywalker keys` and revoke unused ones with `daywalker revoke <keyId>`."
|
|
82
|
+
: "";
|
|
83
|
+
return `${what} failed (${result.status || "network"}${result.code ? ` ${result.code}` : ""}): ${result.error}${hint}`;
|
|
84
|
+
}
|
|
72
85
|
function skipRegister(opts) {
|
|
73
86
|
return Boolean(opts["skip-register"] || opts["no-register"]);
|
|
74
87
|
}
|
|
@@ -211,11 +224,13 @@ async function init(opts) {
|
|
|
211
224
|
if (apiKey && activeKey && !skipRegister(opts)) {
|
|
212
225
|
if (!isJson)
|
|
213
226
|
console.log(`• Registering public key (${activeKey.publicJwk.kid}) with Daywalker Auth...`);
|
|
227
|
+
// Each key gets its own slot, so registering it never retires a key a running deployment still uses.
|
|
214
228
|
const regResult = await registerPublicKey({
|
|
215
229
|
authUrl,
|
|
216
230
|
apiKey,
|
|
217
231
|
domain,
|
|
218
232
|
publicJwk: activeKey.publicJwk,
|
|
233
|
+
instanceId: keySlotFor(activeKey.publicJwk.kid),
|
|
219
234
|
});
|
|
220
235
|
if (regResult.ok) {
|
|
221
236
|
registered = true;
|
|
@@ -223,7 +238,7 @@ async function init(opts) {
|
|
|
223
238
|
console.log(`✔ Public key registered with Daywalker Auth (Push Architecture)`);
|
|
224
239
|
}
|
|
225
240
|
else {
|
|
226
|
-
fail(
|
|
241
|
+
fail(`${keyApiFailure("Key registration", regResult)}\n` +
|
|
227
242
|
` Nothing was written. Fix the problem and re-run, or pass --skip-register to write the key without registering it.`);
|
|
228
243
|
}
|
|
229
244
|
}
|
|
@@ -254,8 +269,12 @@ async function init(opts) {
|
|
|
254
269
|
console.log(` • Key ID (RFC 7638 thumbprint): ${kid}`);
|
|
255
270
|
if (registered)
|
|
256
271
|
console.log(` • Zero-ingress push architecture: public key registered directly`);
|
|
257
|
-
if (previousKey)
|
|
258
|
-
|
|
272
|
+
if (previousKey) {
|
|
273
|
+
const previousKid = (await loadSigningKey(previousKey).catch(() => null))?.publicJwk.kid;
|
|
274
|
+
console.log(` • Previous key kept as DAYWALKER_SIGNING_KEY_PREVIOUS for rollback; it stays registered.`);
|
|
275
|
+
if (previousKid)
|
|
276
|
+
console.log(` Once the new key is deployed everywhere: npx daywalker revoke ${previousKid}`);
|
|
277
|
+
}
|
|
259
278
|
if (opts["print-key"] && signingKeyStr)
|
|
260
279
|
console.log(`\nDAYWALKER_SIGNING_KEY=${signingKeyStr}`);
|
|
261
280
|
}
|
|
@@ -285,9 +304,10 @@ async function rotate(opts) {
|
|
|
285
304
|
apiKey,
|
|
286
305
|
domain: rotateDomain,
|
|
287
306
|
publicJwk: key.publicJwk,
|
|
307
|
+
instanceId: keySlotFor(key.publicJwk.kid),
|
|
288
308
|
});
|
|
289
309
|
if (!regResult.ok) {
|
|
290
|
-
fail(
|
|
310
|
+
fail(`${keyApiFailure("Key registration", regResult)}\n Nothing was written; the current key is still active.`);
|
|
291
311
|
}
|
|
292
312
|
if (!isJson)
|
|
293
313
|
console.log(`✔ New public key registered with Daywalker Auth`);
|
|
@@ -305,14 +325,27 @@ async function rotate(opts) {
|
|
|
305
325
|
}
|
|
306
326
|
writeEnvFile(envFile, envValues, { DAYWALKER_SIGNING_KEY_PREVIOUS: PREVIOUS_KEY_COMMENT });
|
|
307
327
|
console.log(`✔ New key ${key.publicJwk.kid} written; previous key preserved as DAYWALKER_SIGNING_KEY_PREVIOUS.`);
|
|
308
|
-
|
|
328
|
+
const previousKid = (await loadSigningKey(current).catch(() => null))?.publicJwk.kid;
|
|
329
|
+
console.log(` The previous key stays registered, so running deployments keep working.`);
|
|
330
|
+
if (previousKid)
|
|
331
|
+
console.log(` Once the new key is deployed everywhere: npx daywalker revoke ${previousKid}, then remove DAYWALKER_SIGNING_KEY_PREVIOUS.`);
|
|
309
332
|
if (opts["print-key"])
|
|
310
333
|
console.log(`\nDAYWALKER_SIGNING_KEY=${key.privateKeyPkcs8Base64}`);
|
|
311
334
|
}
|
|
312
|
-
async function check() {
|
|
335
|
+
async function check(opts) {
|
|
313
336
|
const env = { ...process.env, ...Object.fromEntries(readEnvFile(path.join(process.cwd(), ".env.local"))) };
|
|
314
337
|
const client = createDaywalker({ env });
|
|
315
338
|
const result = await client.selfCheck();
|
|
339
|
+
if (result.ok && !opts.offline) {
|
|
340
|
+
const live = await client.validateServiceCredentials();
|
|
341
|
+
if (!live.ok) {
|
|
342
|
+
result.ok = false;
|
|
343
|
+
result.problems.push(`OAuth client credentials check failed (${live.status} ${live.error}): ${live.message}`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
console.log(`✔ OAuth client credentials grant succeeded (services: ${(live.data.services ?? []).join(", ")})`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
316
349
|
if (result.ok) {
|
|
317
350
|
console.log(`✔ Daywalker configuration looks good`);
|
|
318
351
|
if (result.serviceSlugs.length > 1)
|
|
@@ -334,11 +367,58 @@ async function check() {
|
|
|
334
367
|
console.error(redactSecrets(` - ${p}`));
|
|
335
368
|
process.exit(1);
|
|
336
369
|
}
|
|
370
|
+
/** Domain, API key and auth URL for the key commands, from flags, .env.local or the environment. */
|
|
371
|
+
async function keyCommandContext(opts) {
|
|
372
|
+
const existing = readEnvFile(path.join(process.cwd(), ".env.local"));
|
|
373
|
+
const apiKey = await resolveApiKey(opts, existing);
|
|
374
|
+
if (!apiKey)
|
|
375
|
+
fail("Set DAYWALKER_API_KEY (or pipe it to --api-key-stdin)");
|
|
376
|
+
const rawDomain = String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? "");
|
|
377
|
+
const domain = publicHostname(rawDomain);
|
|
378
|
+
const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
379
|
+
const currentKid = existing.get("DAYWALKER_KEY_ID") ?? process.env.DAYWALKER_KEY_ID ?? null;
|
|
380
|
+
const previous = existing.get("DAYWALKER_SIGNING_KEY_PREVIOUS") ?? process.env.DAYWALKER_SIGNING_KEY_PREVIOUS;
|
|
381
|
+
const previousKid = previous ? ((await loadSigningKey(previous).catch(() => null))?.publicJwk.kid ?? null) : null;
|
|
382
|
+
return { apiKey, domain, rawDomain, authUrl, currentKid, previousKid };
|
|
383
|
+
}
|
|
384
|
+
async function keys(opts) {
|
|
385
|
+
const ctx = await keyCommandContext(opts);
|
|
386
|
+
if (!ctx.domain)
|
|
387
|
+
fail(`--domain (${ctx.rawDomain || "not set"}) must be a public hostname (or set DAYWALKER_DOMAIN)`);
|
|
388
|
+
const result = await listSigningKeys({ authUrl: ctx.authUrl, apiKey: ctx.apiKey, domain: ctx.domain });
|
|
389
|
+
if (!result.ok)
|
|
390
|
+
fail(keyApiFailure("Listing keys", result));
|
|
391
|
+
const list = result.keys ?? [];
|
|
392
|
+
if (opts.json) {
|
|
393
|
+
console.log(JSON.stringify(list, null, 2));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const when = (t) => (t === null ? "-" : new Date(t * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"));
|
|
397
|
+
console.log(`Signing keys for ${ctx.domain} (${list.filter((k) => k.status !== "REVOKED").length} live of 20 allowed):`);
|
|
398
|
+
for (const k of list) {
|
|
399
|
+
const tag = k.keyId === ctx.currentKid ? " (current)" : k.keyId === ctx.previousKid ? " (previous)" : "";
|
|
400
|
+
console.log(` ${k.keyId}${tag} ${k.status} slot=${k.instanceId ?? "-"} expires=${when(k.expiresAt)} lastUsed=${when(k.lastUsedAt)}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async function revoke(keyId, opts) {
|
|
404
|
+
if (!keyId)
|
|
405
|
+
fail("Usage: daywalker revoke <keyId>");
|
|
406
|
+
const ctx = await keyCommandContext(opts);
|
|
407
|
+
if (keyId === ctx.currentKid && !opts.force) {
|
|
408
|
+
fail(`${keyId} is DAYWALKER_KEY_ID, the key this service signs with: revoking it stops this service from authenticating. Pass --force if that is what you want.`);
|
|
409
|
+
}
|
|
410
|
+
const result = await revokeSigningKey({ authUrl: ctx.authUrl, apiKey: ctx.apiKey, keyId });
|
|
411
|
+
if (!result.ok)
|
|
412
|
+
fail(keyApiFailure(`Revoking ${keyId}`, result));
|
|
413
|
+
console.log(`✔ Revoked signing key ${keyId}; Daywalker ended the OAuth sessions it started.`);
|
|
414
|
+
if (keyId === ctx.previousKid)
|
|
415
|
+
console.log(" You can now remove DAYWALKER_SIGNING_KEY_PREVIOUS from .env.local.");
|
|
416
|
+
}
|
|
337
417
|
async function main() {
|
|
338
418
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
339
419
|
const parsedArgs = args(rest);
|
|
340
|
-
if (
|
|
341
|
-
|
|
420
|
+
if (parsedArgs["api-key"] !== undefined) {
|
|
421
|
+
fail("--api-key is not supported: it exposes the key in shell history and process listings. Use DAYWALKER_API_KEY in the environment or .env.local, or pipe it to --api-key-stdin.");
|
|
342
422
|
}
|
|
343
423
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
344
424
|
console.log(HELP);
|
|
@@ -351,7 +431,13 @@ async function main() {
|
|
|
351
431
|
await rotate(parsedArgs);
|
|
352
432
|
}
|
|
353
433
|
else if (cmd === "check") {
|
|
354
|
-
await check();
|
|
434
|
+
await check(parsedArgs);
|
|
435
|
+
}
|
|
436
|
+
else if (cmd === "keys") {
|
|
437
|
+
await keys(parsedArgs);
|
|
438
|
+
}
|
|
439
|
+
else if (cmd === "revoke") {
|
|
440
|
+
await revoke(rest[0] && !rest[0].startsWith("--") ? rest[0] : undefined, parsedArgs);
|
|
355
441
|
}
|
|
356
442
|
else {
|
|
357
443
|
fail(`Unknown command "${cmd}". Run \`daywalker --help\` for usage.`);
|