@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
package/src/registry.ts
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { DevSecretValue } from "@pithy-sh/core/src/capability/devSecret";
|
|
5
|
+
import { SecretOrigin, SecretRotation } from "@pithy-sh/core/src/capability/secretOrigin";
|
|
6
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { KEYSPACE_SEPARATOR } from "./keyspace";
|
|
9
|
+
import type { ValueRotator } from "./rotation/valueRotator";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The secret registry is the dispatcher. Each entry declares where a secret lives
|
|
13
|
+
* (`backend`), whether its value is shared across environments (`scope`), whether a future
|
|
14
|
+
* value-rotator may manage it (`rotatable`), and how it is interpreted (`valueType`). The
|
|
15
|
+
* `secretsStore` read seam and the `pithy secrets` CLI both route off these axes — never off
|
|
16
|
+
* hard-coded names. The entry itself is a TypeScript type (not a Zod object) because a `json`
|
|
17
|
+
* entry carries a Zod schema; the enum axes below are exported Zod enums so they document
|
|
18
|
+
* themselves and can be validated at the boundary.
|
|
19
|
+
*
|
|
20
|
+
* **One uniform serde.** Every secret is stored the same way — a `{ currentVersion, versions }`
|
|
21
|
+
* value envelope inside one AES-256-GCM envelope — and read through one uniform API
|
|
22
|
+
* (`get` / `getVersions`). `rotatable` never changes storage or the fetch shape; it is
|
|
23
|
+
* forward-looking metadata: may a value-rotator (a deferred feature) manage this secret, and so
|
|
24
|
+
* accumulate multiple still-valid versions. Storing consistently today is how adding that rotator
|
|
25
|
+
* becomes append-a-version rather than reshape-everything.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const SecretBackend = z
|
|
29
|
+
.enum(["d1", "cf-secrets-store"])
|
|
30
|
+
.describe(
|
|
31
|
+
"Where a secret physically lives: `d1` is an encrypted row in the per-environment secrets D1; `cf-secrets-store` is a native Cloudflare Secrets Store entry bound into the worker.",
|
|
32
|
+
);
|
|
33
|
+
export type SecretBackend = z.output<typeof SecretBackend>;
|
|
34
|
+
|
|
35
|
+
export const SecretScope = z
|
|
36
|
+
.enum(["environment", "global"])
|
|
37
|
+
.describe(
|
|
38
|
+
"Whether a secret's value differs per environment (`environment`) or is identical everywhere (`global`). Drives whether a CLI write targets one environment or fans out to all of them.",
|
|
39
|
+
);
|
|
40
|
+
export type SecretScope = z.output<typeof SecretScope>;
|
|
41
|
+
|
|
42
|
+
export const SecretValueType = z
|
|
43
|
+
.enum(["text", "json"])
|
|
44
|
+
.describe(
|
|
45
|
+
"How a decrypted value is interpreted: `text` is a raw string; `json` is parsed and validated against the entry's Zod schema before it is exposed.",
|
|
46
|
+
);
|
|
47
|
+
export type SecretValueType = z.output<typeof SecretValueType>;
|
|
48
|
+
|
|
49
|
+
/** Fields shared by every registry entry, regardless of value type. */
|
|
50
|
+
interface SecretRegistryEntryBase {
|
|
51
|
+
/** Storage backend — drives how the value is read and written. */
|
|
52
|
+
backend: SecretBackend;
|
|
53
|
+
/** Whether the value is shared across environments or differs per environment. */
|
|
54
|
+
scope: SecretScope;
|
|
55
|
+
/**
|
|
56
|
+
* Whether a future value-rotator may manage this secret (and so it may carry multiple
|
|
57
|
+
* still-valid versions). Forward-looking metadata only — storage and the read API are the
|
|
58
|
+
* same either way. Value rotation itself is deferred.
|
|
59
|
+
*/
|
|
60
|
+
rotatable: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* How often this secret is expected to be rotated, in days. Absent means no expectation, and then
|
|
63
|
+
* nothing may call it overdue.
|
|
64
|
+
*
|
|
65
|
+
* **This is why "overdue" is a fact rather than a client's guess.** An age is a number; whether that
|
|
66
|
+
* age is late is a policy, and the policy belongs beside the secret it is about. Ninety days is
|
|
67
|
+
* unremarkable for a session signing key and a long time for a payment processor's live key. Without
|
|
68
|
+
* this, every management client picks its own threshold, they disagree, and the one an owner happens
|
|
69
|
+
* to be looking at decides whether they are told.
|
|
70
|
+
*
|
|
71
|
+
* **It is independent of {@link rotatable}, deliberately.** `rotatable` says what automation may do;
|
|
72
|
+
* this says what the *organization* expects, whoever performs it. A `rotatable: false` third-party
|
|
73
|
+
* key — a Stripe key, an OAuth client secret — is exactly the case where no tooling will ever help
|
|
74
|
+
* and a stated expectation is the only thing that surfaces the drift. Refusing this field on a
|
|
75
|
+
* non-rotatable secret would silence the secrets that most need saying.
|
|
76
|
+
*
|
|
77
|
+
* Measured from the newest successful rotation, or from when the secret was first written when there
|
|
78
|
+
* has never been one. See `admin/status.ts`.
|
|
79
|
+
*/
|
|
80
|
+
rotateEveryDays?: number;
|
|
81
|
+
/**
|
|
82
|
+
* How this secret's value may be minted, when it may be minted at all.
|
|
83
|
+
*
|
|
84
|
+
* Set it when the value is *arbitrary* — a session signing key, a link signing key: any random
|
|
85
|
+
* string works, because nothing outside the project has to agree with it. Leave it off when the
|
|
86
|
+
* value must match something that already exists — an OAuth app's client secret, a Stripe key, a
|
|
87
|
+
* storage credential. A generated value there authenticates against nothing, and hides the real gap
|
|
88
|
+
* behind one that looks filled in.
|
|
89
|
+
*
|
|
90
|
+
* **The name says dev; the property does not (#321).** *Nothing outside the project has to agree with
|
|
91
|
+
* this* is a fact about the value, and it is as true of production as of a laptop: a session signing
|
|
92
|
+
* key is arbitrary in prod for exactly the reason it is arbitrary in dev, because no third party
|
|
93
|
+
* validates it. For four releases only local dev read this field, so provisioning a deployed
|
|
94
|
+
* environment stopped to ask a human to run `pithy secrets create` on values with no human decision in
|
|
95
|
+
* them. Every environment reads it now. Ask {@link isMintableSecret} rather than the field, so the day
|
|
96
|
+
* the name is corrected there is one site to correct.
|
|
97
|
+
*
|
|
98
|
+
* The declaration lives here, with the capability that owns the secret, so `pithy add` never carries
|
|
99
|
+
* a list of names that drifts as capabilities are added. It is mirrored into the capability's
|
|
100
|
+
* `pithy.manifest.json` as `devSecrets` — the CLI wires a capability without executing it — and the
|
|
101
|
+
* capability's own tests assert the two agree.
|
|
102
|
+
*/
|
|
103
|
+
devValue?: DevSecretValue;
|
|
104
|
+
/**
|
|
105
|
+
* Whether this secret is read **straight from its binding**, by code that runs before the store it
|
|
106
|
+
* would otherwise be decoded through exists.
|
|
107
|
+
*
|
|
108
|
+
* **Read this before deleting it as a special case for one secret.** It has exactly one member today —
|
|
109
|
+
* `SECRETS_ENCRYPTION_KEYS` — and that is not the same thing as being a special case. It describes an
|
|
110
|
+
* asymmetry that **already exists in production and predates this axis**: `ensureMasterKey` has always
|
|
111
|
+
* written the master key into the Secrets Store as a bare `EncryptionConfig`, and
|
|
112
|
+
* `resolveEncryptionConfig` has always parsed its binding directly. Nothing was changed to make that
|
|
113
|
+
* true; this only writes it down where the code that materialises a value can see it.
|
|
114
|
+
*
|
|
115
|
+
* **Why it cannot be otherwise.** Every other secret is stored — in the Secrets Store, in a D1 row, and
|
|
116
|
+
* in a `.dev.vars` line — as an encoded `{ currentVersion, versions }` envelope, and read back through
|
|
117
|
+
* {@link decodeVersionedValue}. The master key is what that decoder's *decryption* needs in order to
|
|
118
|
+
* exist: it is resolved first, before any secret can be read at all, so it cannot arrive in a form
|
|
119
|
+
* whose reading depends on it. Its binding therefore carries the value, and a `bootstrap` secret's
|
|
120
|
+
* materialised value is its **current version's value** rather than the envelope.
|
|
121
|
+
*
|
|
122
|
+
* **What deleting it would cost.** Making the master key uniform means changing what
|
|
123
|
+
* `resolveEncryptionConfig` accepts and rewriting the stored value in every already-provisioned
|
|
124
|
+
* environment — a data migration of the one value that, botched, makes every other secret unreadable
|
|
125
|
+
* with no error naming the cause. Weigh that before treating this field as tidying.
|
|
126
|
+
*
|
|
127
|
+
* **The dev secrets file states the value, not an envelope around it (#323).** It used to state one,
|
|
128
|
+
* and the seeder took it off again on the way to the binding — so `currentVersion` appeared twice for
|
|
129
|
+
* one concept, carrying no information, and two readers in a row reported a correct file as corrupt.
|
|
130
|
+
* The file states the payload its destination receives, for this secret as for every other; a future
|
|
131
|
+
* *value* rotation of the master key is a rotation of the `EncryptionConfig`'s own `versions` map —
|
|
132
|
+
* the axis that key has always rotated on, and the reason `rotatable` stays false.
|
|
133
|
+
*
|
|
134
|
+
* Enforced at define time: a `bootstrap` entry must be `cf-secrets-store` (a D1 row cannot be read
|
|
135
|
+
* before the store that decrypts it is open) and must not be `keyed` (a keyspace has no one value to
|
|
136
|
+
* bind). See {@link defineSecretRegistry}.
|
|
137
|
+
*/
|
|
138
|
+
bootstrap?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* How this value comes to exist — minted by the kit, composable into a command, or a human in
|
|
141
|
+
* somebody else's console. See {@link SecretOrigin}.
|
|
142
|
+
*
|
|
143
|
+
* **Declared with {@link rotation} or not at all**, enforced at define time. An origin alone renders as
|
|
144
|
+
* a whole answer — *obtained from GitHub*, and nothing about what to do when it is ninety days old — and
|
|
145
|
+
* the operator most in need of the second half is the one who reads only the first.
|
|
146
|
+
*
|
|
147
|
+
* **It does not replace {@link devValue} yet, and it may not contradict it.** `origin.kind: "minted"`
|
|
148
|
+
* with `recipe.kind: "random"` is exactly the `devValue` case, and `defineSecretRegistry` refuses either
|
|
149
|
+
* without the other, so the two cannot drift while both exist. When `devValue` goes, {@link
|
|
150
|
+
* isMintableSecret} becomes a read of this field and no caller changes.
|
|
151
|
+
*/
|
|
152
|
+
origin?: SecretOrigin;
|
|
153
|
+
/**
|
|
154
|
+
* How this value is replaced. See {@link SecretRotation}.
|
|
155
|
+
*
|
|
156
|
+
* **A separate axis from {@link origin}, because neither follows from the other.** A GitLab token is
|
|
157
|
+
* `obtained` — a human made the first one — and rotates by `provider`, since the API authenticates with
|
|
158
|
+
* the token being replaced and returns its successor. An OAuth client secret is also `obtained` and
|
|
159
|
+
* rotates only by `manual`. One field cannot say both.
|
|
160
|
+
*
|
|
161
|
+
* **It does not derive {@link rotatable}, and must not.** They answer different questions, and two
|
|
162
|
+
* secrets in this repository prove it: `SECRETS_ENCRYPTION_KEYS` is `rotation: "local"` and
|
|
163
|
+
* `rotatable: false`, because it rotates on the `versions` map inside its own `EncryptionConfig` rather
|
|
164
|
+
* than on a second value envelope; `payments-provider-credentials` is `rotatable: true` and rotates only
|
|
165
|
+
* by a human in Stripe's console. `rotatable` is about how a value is *stored* while it changes; this is
|
|
166
|
+
* about who changes it.
|
|
167
|
+
*/
|
|
168
|
+
rotation?: SecretRotation;
|
|
169
|
+
/**
|
|
170
|
+
* **The code behind a `provider` rotation.** See {@link ValueRotator}.
|
|
171
|
+
*
|
|
172
|
+
* `rotation` is a tag and crosses into `pithy.manifest.json`; this is a function and cannot. That is the
|
|
173
|
+
* whole reason they are two fields rather than one — #322's constraint, stated where an author meets it.
|
|
174
|
+
* Neither is derivable from the other, so both are declared, and `defineSecretRegistry` refuses a pair
|
|
175
|
+
* that disagrees:
|
|
176
|
+
*
|
|
177
|
+
* - **`provider` may carry one, and only `provider` may.** `local` is rotated by the same
|
|
178
|
+
* `mintSecretValue` that created the value, and a second producer beside `origin.recipe` is exactly
|
|
179
|
+
* the drift the origin/rotation checks exist to refuse. `manual` means no API returns the new value,
|
|
180
|
+
* so a rotator there is a contradiction the declaration already spelled out.
|
|
181
|
+
* - **`provider` is not *required* to carry one**, because an adopter may hold the credentials a
|
|
182
|
+
* capability's secret rotates against while the capability does not. Turnstile is the case in the kit
|
|
183
|
+
* today: `rotation.kind: "provider"`, issuer `cloudflare`, and no rotator, because rolling that widget
|
|
184
|
+
* needs an account token this package must never hold. `pithy secrets rotate` answers that state by
|
|
185
|
+
* naming it and the two ways out, which beats a define-time refusal that would make declaring the
|
|
186
|
+
* truth impossible.
|
|
187
|
+
*
|
|
188
|
+
* **Never serialized.** `DeclaredSecret` — the manifest's projection — carries `name`, `origin` and
|
|
189
|
+
* `rotation`, and parses rather than copies, so a function has no route into a JSON document. Held to
|
|
190
|
+
* that by `registry.test.ts` rather than by this sentence.
|
|
191
|
+
*/
|
|
192
|
+
rotator?: ValueRotator;
|
|
193
|
+
/** Optional human note surfaced by the audit (`ls --check`). */
|
|
194
|
+
notes?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A `text` entry exposes a raw string. */
|
|
198
|
+
type TextEntry = { valueType: "text" };
|
|
199
|
+
/** A `json` entry is parsed and validated against `schema` before exposure. */
|
|
200
|
+
type JsonEntry = { valueType: "json"; schema: z.ZodType };
|
|
201
|
+
|
|
202
|
+
/** A named entry — one secret, one value, its name declared here. The default, and the common case. */
|
|
203
|
+
type NamedEntry = { keyed?: false };
|
|
204
|
+
/**
|
|
205
|
+
* A keyed entry declares a **keyspace** instead of a name: the same backend, scope and schema applied
|
|
206
|
+
* to an unbounded set of members whose keys exist only at runtime — one signing key per customer
|
|
207
|
+
* connection, one credential per tenant. Read a member with `getKeyed(name, key)`; `get(name)` is
|
|
208
|
+
* refused, because a keyspace has no single value.
|
|
209
|
+
*
|
|
210
|
+
* `d1` and `environment` are enforced, not conventional. A Cloudflare Secrets Store binding is
|
|
211
|
+
* declared in `wrangler.jsonc` at build time, so a name that does not exist then can never have one;
|
|
212
|
+
* and a member is written to one environment's store by the app itself, so `global` would promise a
|
|
213
|
+
* fan-out that no CLI write performs. See `./keyspace` for how a member is named.
|
|
214
|
+
*/
|
|
215
|
+
type KeyedEntry = { keyed: true };
|
|
216
|
+
|
|
217
|
+
/** A single registry entry — the cross-product of the base fields, the value-type discriminant, and named vs keyed. */
|
|
218
|
+
export type SecretRegistryEntry = SecretRegistryEntryBase & (TextEntry | JsonEntry) & (NamedEntry | KeyedEntry);
|
|
219
|
+
|
|
220
|
+
/** A registry: secret name (or keyspace) → entry. The source of truth for backend, scope, rotatability, and value type. */
|
|
221
|
+
export type SecretRegistry = Record<string, SecretRegistryEntry>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The in-memory value type for an entry: a `string` for `text`, the inferred schema type for
|
|
225
|
+
* `json`. `get(name)` returns this; `getVersions(name)` returns a map of it per version.
|
|
226
|
+
*/
|
|
227
|
+
export type SecretValue<E extends SecretRegistryEntry> = E extends { valueType: "json"; schema: infer S }
|
|
228
|
+
? S extends z.ZodType
|
|
229
|
+
? z.infer<S>
|
|
230
|
+
: never
|
|
231
|
+
: string;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The declared **named** secrets of a registry — constrains callers to entries that exist and that
|
|
235
|
+
* have one value. A keyed entry is excluded on purpose: `get("CONNECTION_SIGNING_KEY")` is a
|
|
236
|
+
* question with no answer, and the type says so before the accessor has to.
|
|
237
|
+
*/
|
|
238
|
+
export type SecretName<R extends SecretRegistry> = {
|
|
239
|
+
[K in keyof R]: R[K] extends { keyed: true } ? never : K;
|
|
240
|
+
}[keyof R] &
|
|
241
|
+
string;
|
|
242
|
+
|
|
243
|
+
/** The declared **keyspaces** of a registry — the only names `getKeyed` accepts. */
|
|
244
|
+
export type KeyedSecretName<R extends SecretRegistry> = {
|
|
245
|
+
[K in keyof R]: R[K] extends { keyed: true } ? K : never;
|
|
246
|
+
}[keyof R] &
|
|
247
|
+
string;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* **May this secret's value be minted?** The one question every creator asks, in every environment.
|
|
251
|
+
*
|
|
252
|
+
* Two kinds of secret, and the difference is the whole of it. A *supplied* secret was issued elsewhere —
|
|
253
|
+
* an OAuth client secret, a payment rail's key — and only the person holding it can provide one; a
|
|
254
|
+
* creator must stop and say so. A *generated* secret is random bytes nobody chooses, and stopping to ask
|
|
255
|
+
* a human to ask a tool to generate them is a round trip and nothing else.
|
|
256
|
+
*
|
|
257
|
+
* The answer is {@link SecretRegistryEntryBase.devValue}, read through here rather than directly: the
|
|
258
|
+
* field is named for the environment that happened to read it first, the property is not, and one
|
|
259
|
+
* predicate is what keeps the two from being confused again. A keyspace is refused a second time — the
|
|
260
|
+
* define-time check already refuses the pair, and this stays true of a registry assembled by hand.
|
|
261
|
+
*/
|
|
262
|
+
export function isMintableSecret(entry: SecretRegistryEntry): boolean {
|
|
263
|
+
return entry.devValue !== undefined && !entry.keyed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check one entry's `origin` and `rotation` — that each is well-formed, that they agree with each other,
|
|
268
|
+
* and that neither contradicts what the entry already says about itself.
|
|
269
|
+
*
|
|
270
|
+
* **Every rule here exists because the alternative is a client rendering a confident wrong thing.** A
|
|
271
|
+
* declaration is read by `pithy doctor`, by `pithy secrets ls`, and by a management dashboard, none of
|
|
272
|
+
* which can tell a mistake from a fact. So the mistakes are refused where the author is.
|
|
273
|
+
*/
|
|
274
|
+
function validateSecretDeclaration(name: string, entry: SecretRegistryEntry): void {
|
|
275
|
+
// The tag and the code must agree, and neither is derivable from the other (#322), so both are checked
|
|
276
|
+
// against each other here — where the author is — rather than at a rotation that finds a rotator on a
|
|
277
|
+
// secret nothing was ever going to call one for. See `SecretRegistryEntryBase.rotator`.
|
|
278
|
+
if (entry.rotator !== undefined) {
|
|
279
|
+
if (typeof entry.rotator.roll !== "function") {
|
|
280
|
+
throw new InternalError({ message: `secret registry: entry "${name}" has a rotator with no roll().` });
|
|
281
|
+
}
|
|
282
|
+
if (entry.rotation?.kind !== "provider") {
|
|
283
|
+
throw new InternalError({
|
|
284
|
+
message: `secret registry: entry "${name}" carries a rotator, so its rotation must be provider — ${
|
|
285
|
+
entry.rotation === undefined
|
|
286
|
+
? "it declares none"
|
|
287
|
+
: entry.rotation.kind === "local"
|
|
288
|
+
? "a local secret is re-minted from its own recipe, and a second producer is drift"
|
|
289
|
+
: "a manual secret is replaced by a human, and no call returns the new value"
|
|
290
|
+
}.`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Declared together or not at all. See `SecretRegistryEntryBase.origin`.
|
|
295
|
+
if ((entry.origin === undefined) !== (entry.rotation === undefined)) {
|
|
296
|
+
throw new InternalError({
|
|
297
|
+
message: `secret registry: entry "${name}" declares one of origin and rotation — declare both or neither.`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (entry.origin === undefined || entry.rotation === undefined) return;
|
|
301
|
+
const origin = SecretOrigin.safeParse(entry.origin);
|
|
302
|
+
if (!origin.success) {
|
|
303
|
+
throw new InternalError({ message: `secret registry: entry "${name}" has an invalid origin.` });
|
|
304
|
+
}
|
|
305
|
+
const rotation = SecretRotation.safeParse(entry.rotation);
|
|
306
|
+
if (!rotation.success) {
|
|
307
|
+
throw new InternalError({ message: `secret registry: entry "${name}" has an invalid rotation.` });
|
|
308
|
+
}
|
|
309
|
+
// What the kit can make, the kit can make again — and nothing else can be replaced without its issuer.
|
|
310
|
+
// So `minted` and `local` imply each other, and a pair that disagrees is one of the two being wrong.
|
|
311
|
+
const minted = origin.data.kind === "minted";
|
|
312
|
+
if (minted !== (rotation.data.kind === "local")) {
|
|
313
|
+
throw new InternalError({
|
|
314
|
+
message: minted
|
|
315
|
+
? `secret registry: entry "${name}" is minted, so its rotation is local — the kit can make another.`
|
|
316
|
+
: `secret registry: entry "${name}" is not minted, so its rotation cannot be local — only its issuer can replace it.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// One axis, not two fields that can disagree. `devValue` is the random-mint case and nothing else: the
|
|
320
|
+
// master key is minted too, and no random string is an `EncryptionConfig`.
|
|
321
|
+
const randomlyMinted = minted && origin.data.kind === "minted" && origin.data.recipe.kind === "random";
|
|
322
|
+
if (randomlyMinted !== (entry.devValue !== undefined)) {
|
|
323
|
+
throw new InternalError({
|
|
324
|
+
message: randomlyMinted
|
|
325
|
+
? `secret registry: entry "${name}" is minted from a random value, so it must declare devValue.`
|
|
326
|
+
: `secret registry: entry "${name}" declares devValue but its origin is not a random mint.`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// A structured mint produces a structure. The `random`/`text` half of this is enforced with `devValue`
|
|
330
|
+
// above; this is its mirror, and without it a json entry could claim a mint that cannot satisfy its schema.
|
|
331
|
+
if (origin.data.kind === "minted" && origin.data.recipe.kind !== "random" && entry.valueType !== "json") {
|
|
332
|
+
throw new InternalError({
|
|
333
|
+
message: `secret registry: entry "${name}" mints a structured value, so it must be a json entry.`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Author a registry. Validates each entry's enum axes, that `rotatable` is a boolean, and that
|
|
340
|
+
* every `json` entry carries a Zod schema, so a malformed registry fails at define time
|
|
341
|
+
* (attributed to the offending name) rather than deep in a read or a write. A misconfiguration is
|
|
342
|
+
* an author error, so it throws `InternalError` — mirroring `createMigrationRegistry`. The `const`
|
|
343
|
+
* type param preserves the precise entry literals for `SecretValue`/`SecretName`.
|
|
344
|
+
*/
|
|
345
|
+
export function defineSecretRegistry<const R extends SecretRegistry>(registry: R): R {
|
|
346
|
+
for (const [name, entry] of Object.entries(registry)) {
|
|
347
|
+
if (!name) throw new InternalError({ message: "secret registry: every entry needs a non-empty name." });
|
|
348
|
+
// A name carrying the separator would be indistinguishable from a keyspace member, so one
|
|
349
|
+
// registry entry could shadow another tenant's stored credential. Refused at define time.
|
|
350
|
+
if (name.includes(KEYSPACE_SEPARATOR)) {
|
|
351
|
+
throw new InternalError({
|
|
352
|
+
message: `secret registry: entry "${name}" must not contain '${KEYSPACE_SEPARATOR}' — it separates a keyspace from a member key.`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const axes: [string, z.ZodType, unknown][] = [
|
|
356
|
+
["backend", SecretBackend, entry.backend],
|
|
357
|
+
["scope", SecretScope, entry.scope],
|
|
358
|
+
["valueType", SecretValueType, entry.valueType],
|
|
359
|
+
];
|
|
360
|
+
for (const [field, schema, value] of axes) {
|
|
361
|
+
if (!schema.safeParse(value).success) {
|
|
362
|
+
throw new InternalError({
|
|
363
|
+
message: `secret registry: entry "${name}" has an invalid ${field} (${String(value)}).`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (typeof entry.rotatable !== "boolean") {
|
|
368
|
+
throw new InternalError({ message: `secret registry: entry "${name}" must declare rotatable as a boolean.` });
|
|
369
|
+
}
|
|
370
|
+
if (entry.rotateEveryDays !== undefined) {
|
|
371
|
+
// A cadence that is not a whole number of days, or is zero or negative, would make every read of
|
|
372
|
+
// this secret's status permanently overdue — a warning nobody can clear and everybody learns to
|
|
373
|
+
// ignore. Caught at define time, where the author is.
|
|
374
|
+
if (!Number.isInteger(entry.rotateEveryDays) || entry.rotateEveryDays < 1) {
|
|
375
|
+
throw new InternalError({
|
|
376
|
+
message: `secret registry: entry "${name}" must declare rotateEveryDays as a whole number of days, at least 1.`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (entry.valueType === "json" && !(entry.schema instanceof z.ZodType)) {
|
|
381
|
+
throw new InternalError({ message: `secret registry: json entry "${name}" must declare a Zod schema.` });
|
|
382
|
+
}
|
|
383
|
+
if (entry.devValue !== undefined) {
|
|
384
|
+
if (!DevSecretValue.safeParse(entry.devValue).success) {
|
|
385
|
+
throw new InternalError({
|
|
386
|
+
message: `secret registry: entry "${name}" has an invalid devValue (${String(entry.devValue)}).`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// A minted value is a random string, so only a `text` entry can hold one: a `json` entry's shape
|
|
390
|
+
// is the schema's, and nothing can invent a credential that satisfies it.
|
|
391
|
+
if (entry.valueType !== "text") {
|
|
392
|
+
throw new InternalError({
|
|
393
|
+
message: `secret registry: entry "${name}" declares devValue but is not a text entry — a random value cannot satisfy a json schema.`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// A keyspace has no single value to mint, and its members do not exist until runtime.
|
|
397
|
+
if (entry.keyed) {
|
|
398
|
+
throw new InternalError({
|
|
399
|
+
message: `secret registry: keyed entry "${name}" must not declare devValue — a keyspace has no one value.`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
validateSecretDeclaration(name, entry);
|
|
404
|
+
if (entry.bootstrap !== undefined) {
|
|
405
|
+
if (typeof entry.bootstrap !== "boolean") {
|
|
406
|
+
throw new InternalError({
|
|
407
|
+
message: `secret registry: entry "${name}" must declare bootstrap as a boolean.`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// A bootstrap secret is one a Worker reads from a binding before the store exists, so the D1 store
|
|
411
|
+
// is not a place it can come from. Caught here, where the author is, rather than at a read that
|
|
412
|
+
// finds a row nothing will ever look at.
|
|
413
|
+
if (entry.bootstrap && entry.backend !== "cf-secrets-store") {
|
|
414
|
+
throw new InternalError({
|
|
415
|
+
message: `secret registry: bootstrap entry "${name}" must use the cf-secrets-store backend — it is read from its binding, before any store is open.`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (entry.bootstrap && entry.keyed) {
|
|
419
|
+
throw new InternalError({
|
|
420
|
+
message: `secret registry: keyed entry "${name}" must not declare bootstrap — a keyspace has no one value to bind.`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// A bootstrap secret is what the decoder needs in order to exist, so nothing may invent one: the
|
|
424
|
+
// master key is composed by `initialMasterKeyConfig`, and a random string in its place orphans
|
|
425
|
+
// every secret already encrypted under the real one. Refused here so that every writer of a fresh
|
|
426
|
+
// dev secret — `pithy add`, `adopt`, the provisioners — can state a mintable secret is not
|
|
427
|
+
// bootstrap as a fact rather than as an assumption. See `initialDevSecret`.
|
|
428
|
+
if (entry.bootstrap && entry.devValue !== undefined) {
|
|
429
|
+
throw new InternalError({
|
|
430
|
+
message: `secret registry: bootstrap entry "${name}" must not declare devValue — nothing may mint the value every other secret is read through.`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (entry.keyed !== undefined && typeof entry.keyed !== "boolean") {
|
|
435
|
+
throw new InternalError({ message: `secret registry: entry "${name}" must declare keyed as a boolean.` });
|
|
436
|
+
}
|
|
437
|
+
if (entry.keyed) {
|
|
438
|
+
// Both axes are load-bearing for a keyspace — see `KeyedEntry`. Caught here, where the author
|
|
439
|
+
// is, rather than at a read that finds nothing and cannot say why.
|
|
440
|
+
if (entry.backend !== "d1") {
|
|
441
|
+
throw new InternalError({
|
|
442
|
+
message: `secret registry: keyed entry "${name}" must use the d1 backend — a Secrets Store binding is declared at build time, and its members are not.`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (entry.scope !== "environment") {
|
|
446
|
+
throw new InternalError({
|
|
447
|
+
message: `secret registry: keyed entry "${name}" must be environment-scoped — its members are written to one environment's store.`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return registry;
|
|
453
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
5
|
+
import type { Kysely } from "kysely";
|
|
6
|
+
import type { EncryptionConfig } from "../crypto/envelope";
|
|
7
|
+
import type { SecretsTables } from "../data/tables";
|
|
8
|
+
import type { ConfigWriter } from "../manager/configWriter";
|
|
9
|
+
import type { RotationTracker } from "../store/rotationTracker";
|
|
10
|
+
import { countOnOldKeys, mergeNextKey, pruneOldKeys, reencryptBatch } from "./keyRotation";
|
|
11
|
+
|
|
12
|
+
type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
|
|
13
|
+
|
|
14
|
+
/** The sentinel name a whole-store at-rest key rotation is recorded under in `pithy_secrets_rotations`. */
|
|
15
|
+
export const AT_REST_ROTATION_NAME = "__at_rest_key_rotation__";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A durable step runner — the structural subset of Cloudflare's `WorkflowStep` we use. The
|
|
19
|
+
* Workflow class passes the real runtime step; tests pass a synchronous mock that runs each
|
|
20
|
+
* callback immediately. Keeping it structural avoids a hard dependency on `cloudflare:workers`.
|
|
21
|
+
*/
|
|
22
|
+
export interface StepRunner {
|
|
23
|
+
do<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Everything the at-rest rotation needs, injected so the core is testable without CF wiring. */
|
|
27
|
+
export interface AtRestRotationDeps {
|
|
28
|
+
/** The per-environment secrets D1. */
|
|
29
|
+
db: SecretsDb;
|
|
30
|
+
/** The current master-key config (resolved from `SECRETS_ENCRYPTION_KEYS`). */
|
|
31
|
+
config: EncryptionConfig;
|
|
32
|
+
/** Writes the updated config back to CF Secrets Store. */
|
|
33
|
+
configWriter: ConfigWriter;
|
|
34
|
+
/** Records the rotation attempt in `pithy_secrets_rotations`. */
|
|
35
|
+
tracker: RotationTracker;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AtRestRotationOptions {
|
|
39
|
+
batchSize?: number;
|
|
40
|
+
maxBatches?: number;
|
|
41
|
+
rotatedBy?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The clock, for a test that wants a fixed one.
|
|
44
|
+
*
|
|
45
|
+
* Read **inside** the `pass-instant` step rather than beside it, so journalling this value did not
|
|
46
|
+
* close the seam: an injected clock is still what gets read and still what gets journalled.
|
|
47
|
+
*/
|
|
48
|
+
now?: Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AtRestRotationResult {
|
|
52
|
+
rotated: number;
|
|
53
|
+
failed: number;
|
|
54
|
+
newCurrentVersion: number;
|
|
55
|
+
pruned: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rotate the at-rest encryption key for one environment's store, in durable steps:
|
|
60
|
+
*
|
|
61
|
+
* 1. open a rotation row (`in_progress`);
|
|
62
|
+
* 2. generate a fresh master key, merge it as the new current version, and persist the config;
|
|
63
|
+
* 3. re-encrypt every row to the new key, in batches, until none remain (or `maxBatches`);
|
|
64
|
+
* 4. once no row references an old key, prune the old keys and persist again;
|
|
65
|
+
* 5. close the rotation row (`success`, or `failed` on any throw, which re-raises).
|
|
66
|
+
*
|
|
67
|
+
* Each step is retryable and idempotent: re-encryption only touches rows not yet on the current
|
|
68
|
+
* version, and the old key stays available until pruning, so a mid-run retry is safe. Scoped to one
|
|
69
|
+
* environment — the per-env manager owns one store; cross-env fan-out is the CLI's job.
|
|
70
|
+
*/
|
|
71
|
+
export async function runAtRestKeyRotation(
|
|
72
|
+
deps: AtRestRotationDeps,
|
|
73
|
+
step: StepRunner,
|
|
74
|
+
options: AtRestRotationOptions = {},
|
|
75
|
+
): Promise<AtRestRotationResult> {
|
|
76
|
+
const batchSize = options.batchSize ?? 100;
|
|
77
|
+
const maxBatches = options.maxBatches ?? 50;
|
|
78
|
+
const rotatedBy = options.rotatedBy ?? "cron";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The pass instant, journalled (pithy-sh/pithy#329).
|
|
82
|
+
*
|
|
83
|
+
* A Workflow re-executes this body from the top on a resume and serves every completed step from the
|
|
84
|
+
* journal, so a clock read beside this line answers differently on every attempt. It is the instant
|
|
85
|
+
* written as `lastRotatedAt`, and a pass interrupted at midnight and resumed at six dated the key it
|
|
86
|
+
* rotated by the resume — a rotation history that cannot be reconciled against the work it names.
|
|
87
|
+
*
|
|
88
|
+
* **This one is a stamp and nothing else, and that was checked rather than assumed.** `lastRotatedAt`
|
|
89
|
+
* has one reader, `isRotationDue`, which asks a cadence question in days on a cron that starts nothing
|
|
90
|
+
* while an instance is live; the rotation row's `startedAt`/`completedAt` are written by
|
|
91
|
+
* `RotationTracker` inside its own steps and read only for display. So freezing this instant strands no
|
|
92
|
+
* running work. The sibling case in the email worker looked equally plain and was not — there `now` is
|
|
93
|
+
* the scheduler's heartbeat too, and freezing it lets a live batch be re-driven as stuck.
|
|
94
|
+
*
|
|
95
|
+
* Epoch milliseconds rather than a `Date`, because a journal round-trips JSON: a `Date` would come back
|
|
96
|
+
* a string on the resume and an object on the first pass.
|
|
97
|
+
*/
|
|
98
|
+
const nowMs: number = await step.do("pass-instant", async () => (options.now ?? new Date()).getTime());
|
|
99
|
+
const now = new Date(nowMs);
|
|
100
|
+
|
|
101
|
+
const rotationId = await step.do("start", () => deps.tracker.startRotation(AT_REST_ROTATION_NAME, "cron", rotatedBy));
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const newConfig = await step.do("generate-key", () => mergeNextKey(deps.config, now));
|
|
105
|
+
await step.do("write-config", async () => {
|
|
106
|
+
await deps.configWriter.write(JSON.stringify(newConfig));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let rotated = 0;
|
|
110
|
+
let failed = 0;
|
|
111
|
+
for (let batch = 0; batch < maxBatches; batch++) {
|
|
112
|
+
const result = await step.do(`reencrypt-${batch}`, () => reencryptBatch(deps.db, newConfig, batchSize));
|
|
113
|
+
rotated += result.rotated;
|
|
114
|
+
failed += result.failed;
|
|
115
|
+
// Stop when a batch makes no progress — either the store is fully rotated, or only
|
|
116
|
+
// failures remain (which would loop forever).
|
|
117
|
+
if (result.rotated === 0) break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let pruned = false;
|
|
121
|
+
const remaining = await step.do("count-remaining", () => countOnOldKeys(deps.db, newConfig));
|
|
122
|
+
if (remaining === 0) {
|
|
123
|
+
const prunedConfig = pruneOldKeys(newConfig);
|
|
124
|
+
if (prunedConfig) {
|
|
125
|
+
await step.do("prune", async () => {
|
|
126
|
+
await deps.configWriter.write(JSON.stringify(prunedConfig));
|
|
127
|
+
});
|
|
128
|
+
pruned = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await step.do("mark-success", () => deps.tracker.markSuccess(rotationId));
|
|
133
|
+
return { rotated, failed, newCurrentVersion: Number(newConfig.currentVersion), pruned };
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
// **The binding rethrows and does nothing else (`#386`).** It used to become the row's `error_message`
|
|
136
|
+
// via `cause.message`, and the exceptions that reach here come from decryption, envelope decoding and
|
|
137
|
+
// config parsing — the paths whose text can carry key material. `markFailure` now takes a code and
|
|
138
|
+
// renders the sentence itself, so there is no argument this `cause` would fit.
|
|
139
|
+
//
|
|
140
|
+
// Rethrown unchanged, which is where the detail belongs: the Workflow logs a `PithyError` whose
|
|
141
|
+
// `detail` the HTTP codec strips. Nothing about this failure is written to a column, and the column
|
|
142
|
+
// is still refused for publication — that refusal is defense in depth, not this fix.
|
|
143
|
+
await step.do("mark-failure", () => deps.tracker.markFailure(rotationId, "at-rest-incomplete"));
|
|
144
|
+
throw cause;
|
|
145
|
+
}
|
|
146
|
+
}
|