@pithy-sh/secrets 0.1.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/LICENSE +21 -0
- package/README.md +15 -0
- package/package.json +52 -0
- package/pithy.manifest.json +48 -0
- package/src/admin/health.ts +94 -0
- package/src/admin/status.ts +462 -0
- package/src/audit/actions.ts +53 -0
- package/src/capability.ts +219 -0
- package/src/cli/audit.ts +33 -0
- package/src/cli/dispatch.ts +192 -0
- package/src/cli/partialWrite.ts +69 -0
- package/src/cli/rotationLedger.ts +98 -0
- package/src/cli/validate.ts +38 -0
- package/src/cli/writeTargets.ts +125 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/crypto/envelope.ts +188 -0
- package/src/crypto/versionedValue.ts +82 -0
- package/src/data/secretRotations.ts +49 -0
- package/src/data/statusDb.ts +29 -0
- package/src/data/systemSecrets.ts +44 -0
- package/src/data/tables.ts +16 -0
- package/src/dev/devSecretsFile.ts +167 -0
- package/src/dev/loadDevSecrets.ts +128 -0
- package/src/dev/seedDevSecrets.ts +447 -0
- package/src/env/bindings.ts +84 -0
- package/src/error/errors.ts +155 -0
- package/src/http/guards.ts +107 -0
- package/src/http/responses.ts +225 -0
- package/src/http/rotate.ts +224 -0
- package/src/http/routes.ts +300 -0
- package/src/http/schemas.ts +53 -0
- package/src/http/view.ts +74 -0
- package/src/index.ts +50 -0
- package/src/keyspace.ts +70 -0
- package/src/keyspaceWrite.ts +135 -0
- package/src/management/writeSecret.ts +120 -0
- package/src/manager/configWriter.ts +19 -0
- package/src/manager/dispatcher.ts +142 -0
- package/src/manager/managerRegistry.ts +53 -0
- package/src/manager/retryPolicy.ts +44 -0
- package/src/manager/rotationWorkflow.ts +26 -0
- package/src/manager/secretsConfigWriter.ts +61 -0
- package/src/manager/worker.ts +119 -0
- package/src/manager/wrangler.jsonc +76 -0
- package/src/manager/writeWorkflow.ts +162 -0
- package/src/migrations/0001_init.ts +53 -0
- package/src/mintValue.ts +53 -0
- package/src/provision/provisionSecrets.ts +206 -0
- package/src/provision/resolveManagerConfig.ts +175 -0
- package/src/registry.ts +453 -0
- package/src/rotation/atRestKeyRotation.ts +146 -0
- package/src/rotation/keyRotation.ts +139 -0
- package/src/rotation/rotateValue.ts +412 -0
- package/src/rotation/rotationLedger.ts +167 -0
- package/src/rotation/valueRotator.ts +76 -0
- package/src/scope.ts +120 -0
- package/src/secretsStore.ts +765 -0
- package/src/sharedSecretsStore.ts +187 -0
- package/src/store/rotationTracker.ts +189 -0
- package/src/store/systemSecretsStore.ts +223 -0
- package/src/test-utils/devEncryptionKeys.ts +30 -0
- package/src/test-utils/secretFixtures.ts +178 -0
- package/src/valueBearing.ts +42 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { zValidator } from "@hono/zod-validator";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
|
|
7
|
+
import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
|
|
8
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
9
|
+
import { pageLimit } from "@pithy-sh/core/src/data/cursor";
|
|
10
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
11
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
12
|
+
import type { VerificationStrategy } from "@pithy-sh/core/src/http/verification";
|
|
13
|
+
import type { Context, Hono } from "hono";
|
|
14
|
+
import { dueForRotation } from "../admin/health";
|
|
15
|
+
import { readSecretRotations, readSecretStatus } from "../admin/status";
|
|
16
|
+
import { type SecretsAuditAction, SecretsAuditActions } from "../audit/actions";
|
|
17
|
+
import { secretsStatusDatabase } from "../data/statusDb";
|
|
18
|
+
import { SecretNotFoundError } from "../error/errors";
|
|
19
|
+
import type { SecretRegistry, SecretRegistryEntry } from "../registry";
|
|
20
|
+
import type { SecretRotationOutcome } from "../rotation/rotateValue";
|
|
21
|
+
import { SECRETS_ROTATE_SCOPE, SECRETS_STATUS_READ_SCOPE } from "./guards";
|
|
22
|
+
import type { SecretRotateResponse, SecretRotationsResponse, SecretsStatusResponse } from "./responses";
|
|
23
|
+
import { runWorkerRotation, workerRotationDeps, workerRotationEnvironment } from "./rotate";
|
|
24
|
+
import { RotationsQuery, SecretNameParam } from "./schemas";
|
|
25
|
+
import { secretRotationOutcomeView, secretRotationView, secretStatusView } from "./view";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The secrets capability's management surface — two reads and one write, all control-plane, none of them
|
|
29
|
+
* carrying a value:
|
|
30
|
+
*
|
|
31
|
+
* GET /secrets/admin/status → every declared secret's status (control-plane: secrets:status:read)
|
|
32
|
+
* GET /secrets/admin/status/:name/rotations → one secret's rotation history (control-plane: secrets:status:read) param: SecretNameParam, query: RotationsQuery
|
|
33
|
+
* POST /secrets/admin/status/:name/rotate → replace one secret, here (control-plane: secrets:rotate) param: SecretNameParam
|
|
34
|
+
*
|
|
35
|
+
* ## The write is a rotation and nothing else, and that is what makes it possible
|
|
36
|
+
*
|
|
37
|
+
* This surface was two reads because **a management client cannot supply a value.** It holds neither the
|
|
38
|
+
* adopter's registry nor their Zod schemas, so a create or an update here would write a value against a
|
|
39
|
+
* schema it could not check (`management/writeSecret.ts` explains why the Worker cannot be that
|
|
40
|
+
* validator), and a route that writes a secret it cannot check is not a feature.
|
|
41
|
+
*
|
|
42
|
+
* A rotation supplies nothing. The successor is produced *inside* the Worker — minted from the entry's own
|
|
43
|
+
* recipe for a `local` secret, or returned by the rotator the registry entry carries for a `provider` one —
|
|
44
|
+
* so the value never crosses a boundary in either direction, and the schema that governs it is the one that
|
|
45
|
+
* produced it. That is the whole of why this write can exist where create and update still cannot, and it
|
|
46
|
+
* is a fact about rotation rather than an exception granted to it.
|
|
47
|
+
*
|
|
48
|
+
* **What a Worker can rotate is narrower than what the CLI can**, and `./rotate.ts` is where that is argued
|
|
49
|
+
* and refused: one environment's D1, its own master key, and nothing that lives in Cloudflare's Secrets
|
|
50
|
+
* Store or has to be identical across environments.
|
|
51
|
+
*
|
|
52
|
+
* ## There is no route that reads a value, and there is no scope that could grant one
|
|
53
|
+
*
|
|
54
|
+
* Not an omission to be filled in later. The whole point of storing secrets in the customer's own D1,
|
|
55
|
+
* under a master key their Worker holds, is that no third party has a path to a plaintext. A route here
|
|
56
|
+
* would be that path, and it would exist in every deployment whether or not anybody granted it. The
|
|
57
|
+
* rotation route does not weaken this: it produces a value, stores it, and answers with a shape that has no
|
|
58
|
+
* field one could sit in (`SecretRotationOutcomeView`). A rotation that cannot store its successor discards
|
|
59
|
+
* it and says so — it never hands it back rather than lose it.
|
|
60
|
+
*
|
|
61
|
+
* ## The rotation is a `POST` with no body, and CSRF has nothing to ride
|
|
62
|
+
*
|
|
63
|
+
* `control-plane` is not a cookie strategy. `requireControlPlane` verifies a detached EdDSA token in the
|
|
64
|
+
* `CONTROL_PLANE_HEADER`, signed over the request body, against a key the adopter registered — there is no
|
|
65
|
+
* ambient credential a browser attaches on its own, so a cross-site form post carries no authority. CSRF
|
|
66
|
+
* middleware belongs with `session`, and stacking it here would guard a door that has no hinge. The route
|
|
67
|
+
* takes **no body at all**: everything it needs is the `:name` it is addressed at and the environment its
|
|
68
|
+
* own verified context names, and a body it does not read is a body nobody can smuggle a value into.
|
|
69
|
+
*
|
|
70
|
+
* ## `requireAuth()` is never on these lines
|
|
71
|
+
*
|
|
72
|
+
* The seam leaves `c.var.auth` null on a control-plane call by design, so an auth gate would deny every
|
|
73
|
+
* legitimate management call permanently and no credential could fix it. `requireControlPlane`
|
|
74
|
+
* **replaces** it; it does not stack with it.
|
|
75
|
+
*
|
|
76
|
+
* Validators sit **after** the guard on every route line: an unverified caller is turned away before its
|
|
77
|
+
* request is parsed, so a malformed request can never downgrade a 403 to a 400 and tell a caller with no
|
|
78
|
+
* credential which requests were well-formed. On a surface that enumerates a project's credentials, that
|
|
79
|
+
* is a live oracle.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where the secrets management surface mounts when an adopter names nothing.
|
|
84
|
+
*
|
|
85
|
+
* Exported because two places must agree on it: the router below, and `secretsAdminRoutes` in
|
|
86
|
+
* `guards.ts`. A default living only in the registrar would let the manifest advertise `/secrets/...`
|
|
87
|
+
* while the routes mounted somewhere else, and a management client composing its calls from the
|
|
88
|
+
* manifest would 404 with nothing to diagnose.
|
|
89
|
+
*/
|
|
90
|
+
export const SECRETS_DEFAULT_BASE_PATH = "/secrets";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What every route this capability mounts declares: its path, its verification strategy, and the
|
|
94
|
+
* control-plane scope it checks.
|
|
95
|
+
*
|
|
96
|
+
* Exported so a test can assert against the declaration rather than against a middleware count.
|
|
97
|
+
* Counting middleware proves that *something* runs before the handler; it cannot prove *what*, and a
|
|
98
|
+
* bare `zValidator` satisfies a count. `routeContract.test.ts` checks this list against the routes Hono
|
|
99
|
+
* actually registered in both directions.
|
|
100
|
+
*/
|
|
101
|
+
export interface SecretsRouteDeclaration {
|
|
102
|
+
readonly method: "GET" | "POST";
|
|
103
|
+
/** The path relative to the configured `basePath`, e.g. `/admin/status`. */
|
|
104
|
+
readonly path: string;
|
|
105
|
+
readonly strategy: VerificationStrategy;
|
|
106
|
+
/** The control-plane scope this route checks. */
|
|
107
|
+
readonly scope: ControlPlaneScope;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every route, and how it is gated. */
|
|
111
|
+
export const SECRETS_ROUTES: readonly SecretsRouteDeclaration[] = [
|
|
112
|
+
{ method: "GET", path: "/admin/status", strategy: "control-plane", scope: SECRETS_STATUS_READ_SCOPE },
|
|
113
|
+
{ method: "GET", path: "/admin/status/:name/rotations", strategy: "control-plane", scope: SECRETS_STATUS_READ_SCOPE },
|
|
114
|
+
{ method: "POST", path: "/admin/status/:name/rotate", strategy: "control-plane", scope: SECRETS_ROTATE_SCOPE },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export interface SecretsRoutesOptions {
|
|
118
|
+
/**
|
|
119
|
+
* The registry to report over, read **per request** rather than captured.
|
|
120
|
+
*
|
|
121
|
+
* A function, because the set worth reporting is not the one this capability was constructed with. The
|
|
122
|
+
* combined registry — every composed capability's slice, auth's signing key and email's link key
|
|
123
|
+
* included — only exists once `compose` has run, which is after the router is built. Capturing the
|
|
124
|
+
* value here would report the adopter's own secrets and silently omit every capability's, which is
|
|
125
|
+
* most of them.
|
|
126
|
+
*/
|
|
127
|
+
registry: () => SecretRegistry;
|
|
128
|
+
/** Mount the surface somewhere other than `/secrets`. Moves the advertised paths with it. */
|
|
129
|
+
basePath?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The verified management client behind a control-plane call.
|
|
134
|
+
*
|
|
135
|
+
* `requireControlPlane()` has run on every route that calls this, so `c.var.controlPlane` is populated
|
|
136
|
+
* by the time a handler reads it. The throw is a programming-error guard rather than a runtime path:
|
|
137
|
+
* reaching it would mean a management route was mounted without its gate, which is the one mistake this
|
|
138
|
+
* file is arranged to make impossible.
|
|
139
|
+
*/
|
|
140
|
+
function caller(c: Context<PithyHonoEnv>): ControlPlaneContext {
|
|
141
|
+
const context = c.var.controlPlane;
|
|
142
|
+
if (!context) {
|
|
143
|
+
throw new InternalError({
|
|
144
|
+
message: "The secrets surface could not identify the management caller.",
|
|
145
|
+
detail: "requireControlPlane() must run before a secrets management handler reads the caller.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return context;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The registry entry this call is addressed at, or a 404.
|
|
153
|
+
*
|
|
154
|
+
* **Membership is the gate, not the string's shape.** Without it the history route would read rotation
|
|
155
|
+
* rows by arbitrary name — including the sentinel the whole-store key rotation records itself under, and
|
|
156
|
+
* any row left by a secret since removed from the registry — and the rotation route would be pointed at a
|
|
157
|
+
* name no capability has ever declared. A keyed entry is refused with the rest: a keyspace has no single
|
|
158
|
+
* value, so neither call has an answer for one.
|
|
159
|
+
*/
|
|
160
|
+
function declared(registry: SecretRegistry, name: string): SecretRegistryEntry {
|
|
161
|
+
const entry = registry[name];
|
|
162
|
+
if (!entry || entry.keyed) {
|
|
163
|
+
throw new SecretNotFoundError({
|
|
164
|
+
message: `No secret named '${name}' is declared.`,
|
|
165
|
+
action: "Read the declared names from the status listing.",
|
|
166
|
+
detail: `secret status: '${name}' is not a named entry of the composed registry`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return entry;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Record a management read. Names and counts only — there is nothing else to record, by construction. */
|
|
173
|
+
async function record(
|
|
174
|
+
c: Context<PithyHonoEnv>,
|
|
175
|
+
action: SecretsAuditAction,
|
|
176
|
+
resourceId: string | null,
|
|
177
|
+
metadata: Record<string, unknown>,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
const who = caller(c);
|
|
180
|
+
await c.var.emit({
|
|
181
|
+
action,
|
|
182
|
+
outcome: "success",
|
|
183
|
+
actorType: "control-plane",
|
|
184
|
+
actorId: who.subject,
|
|
185
|
+
resourceType: "secret",
|
|
186
|
+
resourceId,
|
|
187
|
+
requestId: c.req.header("cf-ray"),
|
|
188
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
189
|
+
userAgent: c.req.header("user-agent"),
|
|
190
|
+
metadata: { connectionId: who.connectionId, ...metadata },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Record a rotation — the one administrative act on this surface, on success and on failure alike.
|
|
196
|
+
*
|
|
197
|
+
* `severity` is the field an incident review scans, and `unrecorded` is `critical` because it is the only
|
|
198
|
+
* outcome here that leaves a system broken: a credential dead at its issuer and its successor never stored.
|
|
199
|
+
* `rollFailed` is written down rather than left to be inferred from an empty `recorded`, because *was
|
|
200
|
+
* rolled* and *may have been rolled* is the distinction the review turns on and the two are the same
|
|
201
|
+
* absence otherwise.
|
|
202
|
+
*
|
|
203
|
+
* Names, environments and flags. Nothing about a value is here, and nothing about one is available to put
|
|
204
|
+
* here — `SecretRotationOutcome` has no field that could carry one.
|
|
205
|
+
*/
|
|
206
|
+
async function recordRotation(c: Context<PithyHonoEnv>, outcome: SecretRotationOutcome): Promise<void> {
|
|
207
|
+
const who = caller(c);
|
|
208
|
+
await c.var.emit({
|
|
209
|
+
action: SecretsAuditActions.rotated,
|
|
210
|
+
outcome: outcome.status === "rotated" ? "success" : "failure",
|
|
211
|
+
severity: outcome.status === "unrecorded" ? "critical" : "warning",
|
|
212
|
+
actorType: "control-plane",
|
|
213
|
+
actorId: who.subject,
|
|
214
|
+
resourceType: "secret",
|
|
215
|
+
resourceId: outcome.name,
|
|
216
|
+
requestId: c.req.header("cf-ray"),
|
|
217
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
218
|
+
userAgent: c.req.header("user-agent"),
|
|
219
|
+
metadata: {
|
|
220
|
+
connectionId: who.connectionId,
|
|
221
|
+
name: outcome.name,
|
|
222
|
+
status: outcome.status,
|
|
223
|
+
rotation: outcome.kind,
|
|
224
|
+
rolled: outcome.rolled,
|
|
225
|
+
environments: outcome.recorded,
|
|
226
|
+
...(outcome.rollFailed === undefined ? {} : { rollFailed: outcome.rollFailed }),
|
|
227
|
+
...(outcome.stranded.length > 0 ? { stranded: outcome.stranded } : {}),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function registerSecretsRoutes(options: SecretsRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
233
|
+
const base = options.basePath ?? SECRETS_DEFAULT_BASE_PATH;
|
|
234
|
+
|
|
235
|
+
return (app) => {
|
|
236
|
+
app.get(`${base}/admin/status`, requireControlPlane(SECRETS_STATUS_READ_SCOPE), async (c) => {
|
|
237
|
+
const entries = await readSecretStatus(secretsStatusDatabase(c), options.registry());
|
|
238
|
+
// Narrowed, not filtered by shape. An entry whose row would not decode has no `status` to render, so
|
|
239
|
+
// it cannot reach `secretStatusView` — the union is what makes that a compile error rather than an
|
|
240
|
+
// `undefined` on the wire (#387).
|
|
241
|
+
const secrets = entries.flatMap((entry) => (entry.state === "readable" ? [secretStatusView(entry.status)] : []));
|
|
242
|
+
const unreadable = entries.flatMap((entry) => (entry.state === "unreadable" ? [entry.name] : []));
|
|
243
|
+
await record(c, SecretsAuditActions.statusRead, null, {
|
|
244
|
+
declared: entries.length,
|
|
245
|
+
overdue: dueForRotation(entries),
|
|
246
|
+
unreadable: unreadable.length,
|
|
247
|
+
});
|
|
248
|
+
return c.json({ secrets, unreadable } satisfies SecretsStatusResponse, 200);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
app.get(
|
|
252
|
+
`${base}/admin/status/:name/rotations`,
|
|
253
|
+
requireControlPlane(SECRETS_STATUS_READ_SCOPE),
|
|
254
|
+
zValidator("param", SecretNameParam, validationHook),
|
|
255
|
+
zValidator("query", RotationsQuery, validationHook),
|
|
256
|
+
async (c) => {
|
|
257
|
+
const { name } = c.req.valid("param");
|
|
258
|
+
declared(options.registry(), name);
|
|
259
|
+
const entries = await readSecretRotations(
|
|
260
|
+
secretsStatusDatabase(c),
|
|
261
|
+
name,
|
|
262
|
+
pageLimit(c.req.valid("query").limit),
|
|
263
|
+
);
|
|
264
|
+
const rotations = entries.flatMap((entry) =>
|
|
265
|
+
entry.state === "readable" ? [secretRotationView(entry.record)] : [],
|
|
266
|
+
);
|
|
267
|
+
const unreadable = entries.length - rotations.length;
|
|
268
|
+
await record(c, SecretsAuditActions.rotationsRead, name, { name, returned: rotations.length, unreadable });
|
|
269
|
+
return c.json({ name, rotations, unreadable } satisfies SecretRotationsResponse, 200);
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
app.post(
|
|
274
|
+
`${base}/admin/status/:name/rotate`,
|
|
275
|
+
requireControlPlane(SECRETS_ROTATE_SCOPE),
|
|
276
|
+
zValidator("param", SecretNameParam, validationHook),
|
|
277
|
+
async (c) => {
|
|
278
|
+
const { name } = c.req.valid("param");
|
|
279
|
+
const who = caller(c);
|
|
280
|
+
const entry = declared(options.registry(), name);
|
|
281
|
+
const outcome = await runWorkerRotation(await workerRotationDeps(c), {
|
|
282
|
+
name,
|
|
283
|
+
entry,
|
|
284
|
+
// From the verified context, not from a body and not from a raw binding. A caller cannot name
|
|
285
|
+
// the environment it wants to write, because a caller naming an environment would be a caller
|
|
286
|
+
// choosing which of the adopter's deployments to touch.
|
|
287
|
+
environment: workerRotationEnvironment(who.environment),
|
|
288
|
+
actor: who.subject,
|
|
289
|
+
});
|
|
290
|
+
// Audited on both outcomes, and never for a rotation that did nothing — a trail logging a
|
|
291
|
+
// rotation for a secret nothing touched is a trail nobody can read. The metadata is the same set
|
|
292
|
+
// `pithy secrets rotate` records, so one query answers the question whichever door was used.
|
|
293
|
+
if (outcome.status !== "unchanged") {
|
|
294
|
+
await recordRotation(c, outcome);
|
|
295
|
+
}
|
|
296
|
+
return c.json({ rotation: secretRotationOutcomeView(outcome) } satisfies SecretRotateResponse, 200);
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The HTTP boundary shapes for the secrets management routes. Everything a client can send is declared
|
|
9
|
+
* here and parsed on the route line, so reading a route tells you what it accepts without opening the
|
|
10
|
+
* handler. There are no bodies: this surface is read-only.
|
|
11
|
+
*
|
|
12
|
+
* ## Why `:name` carries no character pattern
|
|
13
|
+
*
|
|
14
|
+
* `defineSecretRegistry` refuses only two things in a name — empty, and the keyspace separator — so any
|
|
15
|
+
* other spelling an adopter chose is a legitimate registry entry. A charset pattern here would 400 a
|
|
16
|
+
* name the project genuinely declares, and a status read that cannot address a secret is worse than one
|
|
17
|
+
* that answers 404 for a name that does not exist.
|
|
18
|
+
*
|
|
19
|
+
* The safety comes from the shape of the check instead of from the shape of the string: the handler
|
|
20
|
+
* looks the name up in the composed registry by exact match and refuses anything it does not find, so
|
|
21
|
+
* the only names that reach a query or a response are ones the project itself declared. Routing does
|
|
22
|
+
* the rest — a keyspace member is `<keyspace>/<key>`, and a slash cannot appear in one path segment.
|
|
23
|
+
*
|
|
24
|
+
* The length bound is not the security control; it is the ordinary refusal to run a query over an
|
|
25
|
+
* unbounded string a caller chose.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** How many rotation rows one call returns. Bounded, because a verified client can still have a bug. */
|
|
29
|
+
const Limit = z.coerce
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(MAX_PAGE_SIZE)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(`How many rotation rows to return, from 1 to ${MAX_PAGE_SIZE}. Defaults to a page a screen can render.`);
|
|
36
|
+
|
|
37
|
+
export const SecretNameParam = z
|
|
38
|
+
.object({
|
|
39
|
+
name: z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(256)
|
|
43
|
+
.describe(
|
|
44
|
+
"The secret to read — the `:name` path segment, matched exactly against the composed registry. A name no capability declares is a 404, not an empty history.",
|
|
45
|
+
),
|
|
46
|
+
})
|
|
47
|
+
.describe("The `:name` path segment naming one declared secret.");
|
|
48
|
+
export type SecretNameParam = z.output<typeof SecretNameParam>;
|
|
49
|
+
|
|
50
|
+
export const RotationsQuery = z
|
|
51
|
+
.object({ limit: Limit })
|
|
52
|
+
.describe("The rotation-history query: how many attempts to return, newest first.");
|
|
53
|
+
export type RotationsQuery = z.output<typeof RotationsQuery>;
|
package/src/http/view.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SecretRotationRecord, SecretStatus } from "../admin/status";
|
|
5
|
+
import type { SecretRotationOutcome } from "../rotation/rotateValue";
|
|
6
|
+
import type { SecretRotationOutcomeView, SecretRotationView, SecretStatusView } from "./responses";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a client is shown. Nothing in this capability's management surface ever returns a raw row.
|
|
10
|
+
*
|
|
11
|
+
* The only work here is the date rendering — ms-epoch in SQLite, `Date` in TypeScript, ISO-8601 on the
|
|
12
|
+
* wire — and it is a function rather than a spread for one reason: **a spread would carry any field the
|
|
13
|
+
* reader shape later gained, straight to a client, silently.** Naming every field means a new fact has
|
|
14
|
+
* to be added here, to `responses.ts`, and to the test that asserts both field sets, which is three
|
|
15
|
+
* places somebody has to mean it.
|
|
16
|
+
*
|
|
17
|
+
* Null passes through as null, in every case, because null is a fact on this surface: never rotated,
|
|
18
|
+
* not stored here, no cadence declared, unanswerable. See `admin/status.ts`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** One secret's status, rendered for the wire. */
|
|
22
|
+
export function secretStatusView(status: SecretStatus): SecretStatusView {
|
|
23
|
+
return {
|
|
24
|
+
name: status.name,
|
|
25
|
+
backend: status.backend,
|
|
26
|
+
valueType: status.valueType,
|
|
27
|
+
rotatable: status.rotatable,
|
|
28
|
+
rotation: status.rotation,
|
|
29
|
+
keyVersion: status.keyVersion,
|
|
30
|
+
createdAt: status.createdAt?.toISOString() ?? null,
|
|
31
|
+
updatedAt: status.updatedAt?.toISOString() ?? null,
|
|
32
|
+
lastRotatedAt: status.lastRotatedAt?.toISOString() ?? null,
|
|
33
|
+
rotationCount: status.rotationCount,
|
|
34
|
+
rotateEveryDays: status.rotateEveryDays,
|
|
35
|
+
overdue: status.overdue,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One rotation outcome, rendered for the wire.
|
|
41
|
+
*
|
|
42
|
+
* **Two fields of the core's outcome are not named here, and that is the whole of what this function is
|
|
43
|
+
* for.** `cause` is an `unknown` thrown by a store — an exception raised where a value was in scope — and
|
|
44
|
+
* it does not cross. Optionality is flattened rather than forwarded: `rollFailed` becomes a plain boolean
|
|
45
|
+
* because absent and false mean the same thing beside `rolled`, while `reason` and `attempts` become null,
|
|
46
|
+
* because for those two the absence *is* the fact — nothing was skipped, nothing was stored.
|
|
47
|
+
*
|
|
48
|
+
* Naming every field rather than spreading, for the reason `secretStatusView` states: a spread would carry
|
|
49
|
+
* a field the core's outcome later gained straight to a client, silently.
|
|
50
|
+
*/
|
|
51
|
+
export function secretRotationOutcomeView(outcome: SecretRotationOutcome): SecretRotationOutcomeView {
|
|
52
|
+
return {
|
|
53
|
+
name: outcome.name,
|
|
54
|
+
status: outcome.status,
|
|
55
|
+
kind: outcome.kind,
|
|
56
|
+
rolled: outcome.rolled,
|
|
57
|
+
rollFailed: outcome.rollFailed ?? false,
|
|
58
|
+
recorded: [...outcome.recorded],
|
|
59
|
+
stranded: [...outcome.stranded],
|
|
60
|
+
reason: outcome.reason ?? null,
|
|
61
|
+
attempts: outcome.attempts ?? null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One rotation attempt, rendered for the wire. */
|
|
66
|
+
export function secretRotationView(record: SecretRotationRecord): SecretRotationView {
|
|
67
|
+
return {
|
|
68
|
+
startedAt: record.startedAt.toISOString(),
|
|
69
|
+
completedAt: record.completedAt?.toISOString() ?? null,
|
|
70
|
+
status: record.status,
|
|
71
|
+
trigger: record.trigger,
|
|
72
|
+
rotatedBy: record.rotatedBy,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add secrets` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory, the registry helper and the schemas a registry entry is written
|
|
7
|
+
* against, the two accessors a Worker reads secrets through, and the table map. Every other module is
|
|
8
|
+
* imported by deep path (`@pithy-sh/secrets/src/...`); this is the documented contract, not a barrel.
|
|
9
|
+
*
|
|
10
|
+
* It exists because `pithy add` writes `import { secrets } from "@pithy-sh/secrets/src/index";`, and
|
|
11
|
+
* for a while nothing answered that specifier. `@pithy-sh/core/src/index.ts` says the same thing for
|
|
12
|
+
* the same reason.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
isSecretsCapability,
|
|
17
|
+
type SecretsCapability,
|
|
18
|
+
type SecretsConfig,
|
|
19
|
+
secrets,
|
|
20
|
+
secretsTokenProfile,
|
|
21
|
+
} from "./capability";
|
|
22
|
+
export { type SecretsTables, secretsTables } from "./data/tables";
|
|
23
|
+
// A keyspace member's stored name. Exported because the app that mints per-tenant credentials writes
|
|
24
|
+
// them, and composing that name by hand is exactly the mistake `keyedSecretName` exists to prevent.
|
|
25
|
+
export { keyedSecretName, SecretKey } from "./keyspace";
|
|
26
|
+
export type { KeyedWriteMode } from "./keyspaceWrite";
|
|
27
|
+
export {
|
|
28
|
+
defineSecretRegistry,
|
|
29
|
+
type KeyedSecretName,
|
|
30
|
+
SecretBackend,
|
|
31
|
+
type SecretName,
|
|
32
|
+
type SecretRegistry,
|
|
33
|
+
type SecretRegistryEntry,
|
|
34
|
+
SecretScope,
|
|
35
|
+
type SecretValue,
|
|
36
|
+
SecretValueType,
|
|
37
|
+
} from "./registry";
|
|
38
|
+
// `SecretsAccessor` is a type here, not the class. Its constructor takes already-resolved plaintext,
|
|
39
|
+
// and an entrypoint that calls itself narrow has no business handing out a way to mint one over
|
|
40
|
+
// arbitrary values. `secretsStore` is how a Worker gets one.
|
|
41
|
+
export {
|
|
42
|
+
type KeyedPutOptions,
|
|
43
|
+
type KeyedWriteAudit,
|
|
44
|
+
type KeyedWriteOptions,
|
|
45
|
+
type KeyedWriteResult,
|
|
46
|
+
type SecretsAccessor,
|
|
47
|
+
secretsStore,
|
|
48
|
+
type VersionedSecret,
|
|
49
|
+
} from "./secretsStore";
|
|
50
|
+
export { DEFAULT_SECRETS_CACHE_TTL_SECONDS, sharedSecretsStore } from "./sharedSecretsStore";
|
package/src/keyspace.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keyspaces — the naming rule behind a `keyed` registry entry.
|
|
9
|
+
*
|
|
10
|
+
* A named entry is one secret with one value. A keyed entry declares a *keyspace*: one schema, one
|
|
11
|
+
* backend, and an unbounded set of members whose keys only exist at runtime — one signing key per
|
|
12
|
+
* customer connection, one credential per tenant. The registry can no more list those names than it
|
|
13
|
+
* can list next month's customers.
|
|
14
|
+
*
|
|
15
|
+
* A member is stored under `<entry>/<key>` in the same encrypted store as everything else, so at-rest
|
|
16
|
+
* rotation, the audit and teardown all keep working with no second storage path. `/` is the one
|
|
17
|
+
* separator, and `defineSecretRegistry` refuses it in an entry name — so a keyspace member can never
|
|
18
|
+
* collide with a declared secret, and no key can compose its way into a neighboring keyspace.
|
|
19
|
+
*
|
|
20
|
+
* That is the whole security argument, and it rests on {@link SecretKey}: every key is validated
|
|
21
|
+
* before it is composed, so `../OTHER/victim` is refused at the call rather than resolved at the
|
|
22
|
+
* store. One tenant's credential is not reachable with another tenant's key.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The one separator between a keyspace and a member key. Illegal in a registry entry name. */
|
|
26
|
+
export const KEYSPACE_SEPARATOR = "/";
|
|
27
|
+
|
|
28
|
+
/** Longest member key. Generous for a uuid or a prefixed id; short enough to keep a stored name bounded. */
|
|
29
|
+
const MAX_KEY_LENGTH = 128;
|
|
30
|
+
|
|
31
|
+
export const SecretKey = z
|
|
32
|
+
.string()
|
|
33
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/)
|
|
34
|
+
.max(MAX_KEY_LENGTH)
|
|
35
|
+
.describe(
|
|
36
|
+
"A keyspace member key supplied at runtime — a tenant, connection, or installation id. Letters, digits, dot, dash and underscore only, starting with a letter or digit, at most 128 characters: the separator, whitespace and control characters are excluded so a key can never compose its way out of its keyspace.",
|
|
37
|
+
);
|
|
38
|
+
export type SecretKey = z.output<typeof SecretKey>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stored name of one keyspace member. The only sanctioned way to compose one — read and write
|
|
42
|
+
* both go through here, so there is a single place where a key is proven legal.
|
|
43
|
+
*
|
|
44
|
+
* The refusal names the keyspace and never the key. `detail` reaches logs verbatim, and a rejected
|
|
45
|
+
* key is unvalidated input: echoing it would put attacker-chosen bytes, newlines included, in the
|
|
46
|
+
* operator's log.
|
|
47
|
+
*/
|
|
48
|
+
export function keyedSecretName(name: string, key: string): string {
|
|
49
|
+
const parsed = SecretKey.safeParse(key);
|
|
50
|
+
if (!parsed.success) {
|
|
51
|
+
throw new ValidationError({
|
|
52
|
+
message: "That secret key is not valid.",
|
|
53
|
+
action: "Use letters, digits, dot, dash or underscore — starting with a letter or digit, up to 128 characters.",
|
|
54
|
+
detail: `keyspace '${name}': the supplied key failed SecretKey validation`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return `${name}${KEYSPACE_SEPARATOR}${parsed.data}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Split a stored name back into its keyspace and key, or `undefined` when it is not a keyspace
|
|
62
|
+
* member. The audit uses it to attribute a stored member to the keyspace that declared it, so a
|
|
63
|
+
* tenant's credential is not reported as an orphan.
|
|
64
|
+
*/
|
|
65
|
+
export function parseKeyedSecretName(stored: string): { name: string; key: string } | undefined {
|
|
66
|
+
const at = stored.indexOf(KEYSPACE_SEPARATOR);
|
|
67
|
+
if (at <= 0) return undefined;
|
|
68
|
+
const key = stored.slice(at + 1);
|
|
69
|
+
return SecretKey.safeParse(key).success ? { name: stored.slice(0, at), key } : undefined;
|
|
70
|
+
}
|