@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,187 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { SecretsStoreEnv } from "./env/bindings";
|
|
7
|
+
import type { SecretRegistry, SecretRegistryEntry } from "./registry";
|
|
8
|
+
import { d1KeyedIO, type SecretsAccessor, secretsStore } from "./secretsStore";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shared, per-invocation secrets accessor. Within one worker invocation many capabilities each
|
|
12
|
+
* read secrets; resolving them independently means a Secrets Store round-trip per call site, and a
|
|
13
|
+
* repeated round-trip when two capabilities share a secret. This module resolves the **combined**
|
|
14
|
+
* registry — every capability's {@link Capability.secretRegistry} slice merged into one — exactly
|
|
15
|
+
* once, caches the resulting accessor for a configurable TTL (default 60 s), and hands each call site
|
|
16
|
+
* a precisely-typed view over only its own slice via {@link SecretsAccessor.subset}.
|
|
17
|
+
*
|
|
18
|
+
* Sharing one resolution is what made #170 bite: the combined registry is every capability's, so an
|
|
19
|
+
* unset secret anywhere in it used to fail the one resolution every capability waits on. The fix is in
|
|
20
|
+
* {@link secretsStore} — a failure is held against its own name and raised at its own read — and it is
|
|
21
|
+
* the reason a shared accessor is safe to share. This module resolves once; it does not resolve
|
|
22
|
+
* all-or-nothing.
|
|
23
|
+
*
|
|
24
|
+
* The cache is module-scoped, so it is per worker isolate: built lazily on the first request that
|
|
25
|
+
* needs secrets, reused by every access within the TTL, and rebuilt on the first access after it
|
|
26
|
+
* expires. `@pithy-sh/secrets`' capability {@link configureSharedSecrets | configures} the combined
|
|
27
|
+
* registry and TTL at worker startup (via its `compose` hook); a standalone worker that uses the
|
|
28
|
+
* accessor without `createBackend` (the email and secrets-manager workers) configures it directly.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Default cache lifetime when the secrets capability does not override it. */
|
|
32
|
+
export const DEFAULT_SECRETS_CACHE_TTL_SECONDS = 60;
|
|
33
|
+
|
|
34
|
+
/** Resolves the combined registry against `env` — the real path calls {@link secretsStore}; tests inject a fake. */
|
|
35
|
+
type Resolver = (env: SecretsStoreEnv, registry: SecretRegistry) => Promise<SecretsAccessor<SecretRegistry>>;
|
|
36
|
+
|
|
37
|
+
/** A monotonic millisecond clock — `Date.now` in production, controllable in tests. */
|
|
38
|
+
type Clock = () => number;
|
|
39
|
+
|
|
40
|
+
/** Options for {@link configureSharedSecrets}. `resolve`/`now` are seams overridden only by tests. */
|
|
41
|
+
export interface ConfigureSharedSecretsOptions {
|
|
42
|
+
/** The combined registry to resolve — every capability's slice merged. */
|
|
43
|
+
registry: SecretRegistry;
|
|
44
|
+
/** Cache lifetime in seconds. Defaults to {@link DEFAULT_SECRETS_CACHE_TTL_SECONDS}. */
|
|
45
|
+
ttlSeconds?: number;
|
|
46
|
+
/** Override the resolver (testing). Defaults to a real {@link secretsStore} call. */
|
|
47
|
+
resolve?: Resolver;
|
|
48
|
+
/** Override the clock (testing). Defaults to `Date.now`. */
|
|
49
|
+
now?: Clock;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SharedConfig {
|
|
53
|
+
registry: SecretRegistry;
|
|
54
|
+
ttlMs: number;
|
|
55
|
+
resolve: Resolver;
|
|
56
|
+
now: Clock;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface CacheEntry {
|
|
60
|
+
accessor: SecretsAccessor<SecretRegistry>;
|
|
61
|
+
expiresAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let config: SharedConfig | null = null;
|
|
65
|
+
let cache: CacheEntry | null = null;
|
|
66
|
+
let inflight: Promise<SecretsAccessor<SecretRegistry>> | null = null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Configure the shared accessor with the combined registry and TTL. Called once at worker startup
|
|
70
|
+
* (the secrets capability's `compose` hook, or a standalone worker's module scope). Resets any
|
|
71
|
+
* cached accessor so the next access re-resolves against the new configuration.
|
|
72
|
+
*/
|
|
73
|
+
export function configureSharedSecrets(options: ConfigureSharedSecretsOptions): void {
|
|
74
|
+
const ttlSeconds = options.ttlSeconds ?? DEFAULT_SECRETS_CACHE_TTL_SECONDS;
|
|
75
|
+
config = {
|
|
76
|
+
registry: options.registry,
|
|
77
|
+
ttlMs: ttlSeconds * 1000,
|
|
78
|
+
resolve: options.resolve ?? ((env, registry) => secretsStore(env, registry)),
|
|
79
|
+
now: options.now ?? Date.now,
|
|
80
|
+
};
|
|
81
|
+
cache = null;
|
|
82
|
+
inflight = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Clear all shared state — configuration and cache. For test isolation between cases. */
|
|
86
|
+
export function resetSharedSecrets(): void {
|
|
87
|
+
config = null;
|
|
88
|
+
cache = null;
|
|
89
|
+
inflight = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requireConfig(): SharedConfig {
|
|
93
|
+
if (!config) {
|
|
94
|
+
throw new InternalError({
|
|
95
|
+
message: "The shared secrets accessor is not configured.",
|
|
96
|
+
detail: "configureSharedSecrets was never called — compose the `secrets` capability, or configure it directly.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return config;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve the combined accessor, honoring the TTL cache and de-duplicating a concurrent first fetch. */
|
|
103
|
+
async function resolveCombined(env: SecretsStoreEnv): Promise<SecretsAccessor<SecretRegistry>> {
|
|
104
|
+
const cfg = requireConfig();
|
|
105
|
+
if (cache && cfg.now() < cache.expiresAt) return cache.accessor;
|
|
106
|
+
// A concurrent access during the fetch shares the one in-flight resolution, so the combined
|
|
107
|
+
// registry is fetched once even when several capabilities read secrets in the same invocation.
|
|
108
|
+
if (inflight) return inflight;
|
|
109
|
+
inflight = cfg
|
|
110
|
+
.resolve(env, cfg.registry)
|
|
111
|
+
.then((accessor) => {
|
|
112
|
+
cache = { accessor, expiresAt: cfg.now() + cfg.ttlMs };
|
|
113
|
+
return accessor;
|
|
114
|
+
})
|
|
115
|
+
.finally(() => {
|
|
116
|
+
inflight = null;
|
|
117
|
+
});
|
|
118
|
+
return inflight;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The shared, per-invocation accessor for `registry` — the calling capability's own slice. Resolves
|
|
123
|
+
* the combined registry once (cached for the TTL) and returns a precisely-typed view over `registry`.
|
|
124
|
+
* Every name in `registry` must be part of the configured combined registry; a name that is not is a
|
|
125
|
+
* wiring bug (the capability did not declare its slice on `secretRegistry`) and throws at the call.
|
|
126
|
+
*/
|
|
127
|
+
export async function sharedSecretsStore<R extends SecretRegistry>(
|
|
128
|
+
env: SecretsStoreEnv,
|
|
129
|
+
registry: R,
|
|
130
|
+
): Promise<SecretsAccessor<R>> {
|
|
131
|
+
const cfg = requireConfig();
|
|
132
|
+
for (const name of Object.keys(registry)) {
|
|
133
|
+
if (!(name in cfg.registry)) {
|
|
134
|
+
throw new InternalError({
|
|
135
|
+
message: `Secret "${name}" is not in the aggregated registry.`,
|
|
136
|
+
detail: `'${name}' was requested but no capability declared it on its secretRegistry slice`,
|
|
137
|
+
action: "Declare the secret on the reading capability's `secretRegistry`.",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const combined = await resolveCombined(env);
|
|
142
|
+
// The combined accessor is cached across requests; a keyspace read or write is not cached at all,
|
|
143
|
+
// and runs real I/O. Bind it to *this* invocation's env so a member is never fetched — or sealed —
|
|
144
|
+
// through the binding of whichever earlier request happened to fill the cache.
|
|
145
|
+
return combined.subset(registry, d1KeyedIO(env));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Merge every capability's {@link Capability.secretRegistry} slice into one combined registry — the
|
|
150
|
+
* source of truth the shared accessor resolves. A secret name declared by more than one capability is
|
|
151
|
+
* allowed only when the declarations agree on every axis (`backend`, `scope`, `valueType`,
|
|
152
|
+
* `rotatable`, `keyed`); a divergent re-declaration is an author conflict and throws. The `secretsStore`
|
|
153
|
+
* reader keys purely on the name, so identical re-declarations resolve the same stored value.
|
|
154
|
+
*/
|
|
155
|
+
export function aggregateSecretRegistries(capabilities: readonly Capability[]): SecretRegistry {
|
|
156
|
+
const combined: Record<string, SecretRegistryEntry> = {};
|
|
157
|
+
const owners: Record<string, string> = {};
|
|
158
|
+
for (const cap of capabilities) {
|
|
159
|
+
// The Capability contract carries the slice as the loose `SecretRegistrySeam`; the concrete
|
|
160
|
+
// entries are always built by `defineSecretRegistry`, so this narrowing is sound at the seam.
|
|
161
|
+
const slice = cap.secretRegistry as SecretRegistry | undefined;
|
|
162
|
+
if (!slice) continue;
|
|
163
|
+
for (const [name, entry] of Object.entries(slice)) {
|
|
164
|
+
const existing = combined[name];
|
|
165
|
+
if (existing) {
|
|
166
|
+
if (
|
|
167
|
+
existing.backend !== entry.backend ||
|
|
168
|
+
existing.scope !== entry.scope ||
|
|
169
|
+
existing.valueType !== entry.valueType ||
|
|
170
|
+
existing.rotatable !== entry.rotatable ||
|
|
171
|
+
// A keyspace and a name are not the same secret, whatever else agrees: one resolves
|
|
172
|
+
// `<name>/<key>`, the other `<name>`.
|
|
173
|
+
Boolean(existing.keyed) !== Boolean(entry.keyed)
|
|
174
|
+
) {
|
|
175
|
+
throw new InternalError({
|
|
176
|
+
message: `Secret "${name}" is declared incompatibly by capabilities "${owners[name]}" and "${cap.name}".`,
|
|
177
|
+
action: "Declare the same backend, scope, valueType, rotatable, and keyed for a shared secret name.",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
combined[name] = entry;
|
|
183
|
+
owners[name] = cap.name;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return combined;
|
|
187
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
6
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
7
|
+
import type { Kysely } from "kysely";
|
|
8
|
+
import type { RotationStatus, RotationTrigger } from "../data/secretRotations";
|
|
9
|
+
import { type SecretsTables, secretsTables } from "../data/tables";
|
|
10
|
+
import {
|
|
11
|
+
type OpenRotation,
|
|
12
|
+
type RotationFailureCode,
|
|
13
|
+
type RotationLedger,
|
|
14
|
+
rotationClosure,
|
|
15
|
+
rotationFailureText,
|
|
16
|
+
} from "../rotation/rotationLedger";
|
|
17
|
+
import type { ManagedEnvironment } from "../scope";
|
|
18
|
+
|
|
19
|
+
type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Append-only tracker for rotation attempts, over the per-environment secrets D1's
|
|
23
|
+
* `pithy_secrets_rotations` table. Ported from the CMS `RotationTracker`, scoped to one
|
|
24
|
+
* environment (the per-env manager owns one store).
|
|
25
|
+
*
|
|
26
|
+
* `startRotation` opens an `in_progress` row and returns its id; `markSuccess`/`markFailure`
|
|
27
|
+
* close it. `recordBaseline` seeds a `success`/`baseline` row when a rotatable secret is first
|
|
28
|
+
* written, so the cadence check never flags a brand-new secret as overdue. `purgeHistory` clears
|
|
29
|
+
* a deleted secret's rows so they don't linger.
|
|
30
|
+
*/
|
|
31
|
+
export class RotationTracker {
|
|
32
|
+
readonly #db: SecretsDb;
|
|
33
|
+
|
|
34
|
+
constructor(db: SecretsDb) {
|
|
35
|
+
this.#db = db;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build a tracker over a raw `SECRETS` D1 binding. */
|
|
39
|
+
static fromD1(d1: D1Database): RotationTracker {
|
|
40
|
+
return new RotationTracker(createDatabase(d1, secretsTables));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Open an `in_progress` rotation row and return its id. */
|
|
44
|
+
async startRotation(
|
|
45
|
+
name: string,
|
|
46
|
+
trigger: RotationTrigger,
|
|
47
|
+
rotatedBy: string,
|
|
48
|
+
metadataSnapshot?: unknown,
|
|
49
|
+
): Promise<number> {
|
|
50
|
+
const inserted = await this.#db
|
|
51
|
+
.insertInto("pithySecretsRotations")
|
|
52
|
+
.values({
|
|
53
|
+
name,
|
|
54
|
+
startedAt: SQLiteDate.encode(new Date()),
|
|
55
|
+
completedAt: null,
|
|
56
|
+
status: "in_progress" satisfies RotationStatus,
|
|
57
|
+
trigger,
|
|
58
|
+
rotatedBy,
|
|
59
|
+
errorMessage: null,
|
|
60
|
+
metadataSnapshot: metadataSnapshot === undefined ? null : JSON.stringify(metadataSnapshot),
|
|
61
|
+
})
|
|
62
|
+
.returning("id")
|
|
63
|
+
.executeTakeFirstOrThrow();
|
|
64
|
+
return inserted.id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Close a rotation row as `success`. */
|
|
68
|
+
async markSuccess(rotationId: number): Promise<void> {
|
|
69
|
+
await this.#db
|
|
70
|
+
.updateTable("pithySecretsRotations")
|
|
71
|
+
.set({ status: "success" satisfies RotationStatus, completedAt: SQLiteDate.encode(new Date()) })
|
|
72
|
+
.where("id", "=", rotationId)
|
|
73
|
+
.execute();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Close a rotation row as `failed`, under a code that names the failure.
|
|
78
|
+
*
|
|
79
|
+
* **It takes a code and not a sentence, and that is the whole of `#386`.** `error_message` is the one
|
|
80
|
+
* column on this table a failure site writes, `rotationLedger.ts` states that its text is fixed and
|
|
81
|
+
* chosen by a code, and the at-rest rotation path composed it from `cause.message` anyway — from a catch
|
|
82
|
+
* reached by decryption, envelope decoding and config parsing, which are the paths whose exception text
|
|
83
|
+
* can carry key material. Four files already refuse to publish this column; that refusal is defense in
|
|
84
|
+
* depth and was never the invariant. The invariant is that there is nothing here to publish.
|
|
85
|
+
*
|
|
86
|
+
* A comment asking for a code would have been the same comment that was already there. So the signature
|
|
87
|
+
* asks: {@link RotationFailureCode} is a closed union, `rotationFailureText` maps it here rather than at
|
|
88
|
+
* the call site, and a caller holding an exception has nowhere to put it. The exception is still raised,
|
|
89
|
+
* and its context still travels in a `PithyError`'s `detail`, which the HTTP codec strips.
|
|
90
|
+
*/
|
|
91
|
+
async markFailure(rotationId: number, code: RotationFailureCode): Promise<void> {
|
|
92
|
+
await this.#db
|
|
93
|
+
.updateTable("pithySecretsRotations")
|
|
94
|
+
.set({
|
|
95
|
+
status: "failed" satisfies RotationStatus,
|
|
96
|
+
completedAt: SQLiteDate.encode(new Date()),
|
|
97
|
+
errorMessage: rotationFailureText(code),
|
|
98
|
+
})
|
|
99
|
+
.where("id", "=", rotationId)
|
|
100
|
+
.execute();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Seed a `success`/`baseline` row so a brand-new rotatable secret is not flagged overdue.
|
|
105
|
+
*
|
|
106
|
+
* **A first write, and it stays that.** `trigger: "baseline"` is what distinguishes establishing a value
|
|
107
|
+
* from replacing one — a rotation writes `manual` or `cron` through {@link trackerRotationLedger} and
|
|
108
|
+
* carries an actor. Widening this to cover updates would let a typo fix advance a freshness clock
|
|
109
|
+
* nobody rotated; see `../rotation/rotationLedger.ts`.
|
|
110
|
+
*/
|
|
111
|
+
async recordBaseline(name: string): Promise<void> {
|
|
112
|
+
const now = SQLiteDate.encode(new Date());
|
|
113
|
+
await this.#db
|
|
114
|
+
.insertInto("pithySecretsRotations")
|
|
115
|
+
.values({
|
|
116
|
+
name,
|
|
117
|
+
startedAt: now,
|
|
118
|
+
completedAt: now,
|
|
119
|
+
status: "success" satisfies RotationStatus,
|
|
120
|
+
trigger: "baseline" satisfies RotationTrigger,
|
|
121
|
+
rotatedBy: "baseline",
|
|
122
|
+
errorMessage: null,
|
|
123
|
+
metadataSnapshot: null,
|
|
124
|
+
})
|
|
125
|
+
.execute();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The most recent successful completion for a name, or `null` if it has never succeeded. */
|
|
129
|
+
async getLatestSuccess(name: string): Promise<Date | null> {
|
|
130
|
+
const row = await this.#db
|
|
131
|
+
.selectFrom("pithySecretsRotations")
|
|
132
|
+
.select("completedAt")
|
|
133
|
+
.where("name", "=", name)
|
|
134
|
+
.where("status", "=", "success")
|
|
135
|
+
.where("completedAt", "is not", null)
|
|
136
|
+
.orderBy("completedAt", "desc")
|
|
137
|
+
.limit(1)
|
|
138
|
+
.executeTakeFirst();
|
|
139
|
+
if (!row || row.completedAt == null) return null;
|
|
140
|
+
return new Date(row.completedAt as number);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Remove all rotation rows for a secret (called on delete). Returns the count removed. */
|
|
144
|
+
async purgeHistory(name: string): Promise<number> {
|
|
145
|
+
const before = await this.#db
|
|
146
|
+
.selectFrom("pithySecretsRotations")
|
|
147
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
148
|
+
.where("name", "=", name)
|
|
149
|
+
.executeTakeFirst();
|
|
150
|
+
await this.#db.deleteFrom("pithySecretsRotations").where("name", "=", name).execute();
|
|
151
|
+
return Number(before?.count ?? 0);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What {@link trackerRotationLedger} needs beyond the tracker: which environment it is, and who is asking. */
|
|
156
|
+
export interface TrackerRotationLedgerOptions {
|
|
157
|
+
/** The environment this D1 belongs to. Decides how the row closes — see `rotationClosure`. */
|
|
158
|
+
environment: ManagedEnvironment;
|
|
159
|
+
/** What caused the rotation: an operator (`manual`) or the manager's own schedule (`cron`). Never `baseline`. */
|
|
160
|
+
trigger: Exclude<RotationTrigger, "baseline">;
|
|
161
|
+
/** Who asked. A verified control-plane subject in a Worker, a workflow instance id for a scheduled run. */
|
|
162
|
+
rotatedBy: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The in-Worker {@link RotationLedger}: the rotation table this process already holds a handle to.
|
|
167
|
+
*
|
|
168
|
+
* The direct half of the seam. Anything running *inside* an environment — a control-plane rotate route, the
|
|
169
|
+
* manager's own cron — records through this; a process outside one records the identical rows through
|
|
170
|
+
* `../cli/rotationLedger.ts`, over a dispatch. Both compose the closing verdict with `rotationClosure` and
|
|
171
|
+
* the failure sentence with `rotationFailureText`, which is what stops the two paths from disagreeing about
|
|
172
|
+
* whether a rotation happened (`#379`).
|
|
173
|
+
*/
|
|
174
|
+
export function trackerRotationLedger(tracker: RotationTracker, options: TrackerRotationLedgerOptions): RotationLedger {
|
|
175
|
+
return {
|
|
176
|
+
async open(name: string): Promise<OpenRotation> {
|
|
177
|
+
const rotationId = await tracker.startRotation(name, options.trigger, options.rotatedBy);
|
|
178
|
+
return {
|
|
179
|
+
async close(outcome): Promise<void> {
|
|
180
|
+
const closure = rotationClosure(outcome, options.environment);
|
|
181
|
+
if (closure.status === "success") await tracker.markSuccess(rotationId);
|
|
182
|
+
// The reason, not its sentence. Every reason is a `RotationFailureCode`, and the tracker renders
|
|
183
|
+
// it — one place composes the text, on both sides of the seam (`#386`).
|
|
184
|
+
else await tracker.markFailure(rotationId, closure.reason);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
5
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
6
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
7
|
+
import type { Kysely } from "kysely";
|
|
8
|
+
import { decryptValue, type EncryptionConfig, encryptValue } from "../crypto/envelope";
|
|
9
|
+
import { decodeVersionedValue, encodeVersionedValue, type VersionedValue } from "../crypto/versionedValue";
|
|
10
|
+
import { type SecretsTables, secretsTables } from "../data/tables";
|
|
11
|
+
import { resolveEncryptionConfig, type SecretsStoreEnv } from "../env/bindings";
|
|
12
|
+
import { SecretCryptoError } from "../error/errors";
|
|
13
|
+
import type { SecretValueType } from "../registry";
|
|
14
|
+
|
|
15
|
+
/** The Kysely instance the store queries — typed over the secrets tables, CamelCasePlugin installed. */
|
|
16
|
+
type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One stored secret as a read found it: its envelope, or that the row is there and would not open (#384).
|
|
20
|
+
*
|
|
21
|
+
* **The state rides on the value**, so a caller cannot reach a plaintext without narrowing and forgetting
|
|
22
|
+
* the unreadable case is a compile error rather than a silent empty. That is the shape `#350`, `#371` and
|
|
23
|
+
* `#380` settled, and it is here for the reason it was there: this is a *batch* read, so an unreadable row
|
|
24
|
+
* that throws is an unreadable row that costs every other name in the call. `#170` promised that a failure
|
|
25
|
+
* belongs to its secret; it was true of a **missing** row and not of an **unreadable** one, which is a
|
|
26
|
+
* narrower guarantee than the sentence reads.
|
|
27
|
+
*
|
|
28
|
+
* **Absent is a third fact and it is not in this union.** A name with no row is simply not a key in the
|
|
29
|
+
* record `getValues` returns. Missing and unreadable are different faults with different remedies —
|
|
30
|
+
* provision it, versus investigate the row or re-seal it — and folding either into the other loses the
|
|
31
|
+
* only thing the operator needed.
|
|
32
|
+
*
|
|
33
|
+
* `unreadable` carries nothing. There is nothing safe to put on it: what the decrypt threw names a key
|
|
34
|
+
* version and a ciphertext it could not open, and the row's own name is already the key this value is
|
|
35
|
+
* filed under.
|
|
36
|
+
*/
|
|
37
|
+
export type StoredSecretValue =
|
|
38
|
+
| {
|
|
39
|
+
/** The row opened and its plaintext parsed as a value envelope. */
|
|
40
|
+
state: "readable";
|
|
41
|
+
/** The decrypted `{ currentVersion, versions }` envelope. */
|
|
42
|
+
value: VersionedValue;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
/** The row is stored and did not open — a corrupt ciphertext, or a key version no longer held. */
|
|
46
|
+
state: "unreadable";
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What a stored secret that will not open is told — the sibling of `secretsStore`'s `unprovisioned`, and
|
|
51
|
+
* deliberately a different sentence with a different code.
|
|
52
|
+
*
|
|
53
|
+
* Constructed here rather than re-thrown from the decrypt, because nothing derived from that failure may
|
|
54
|
+
* travel: the guard's `catch` takes no binding (`#350`), so there is no error object in scope to attach as
|
|
55
|
+
* `cause` and no text to fold into `detail`. What survives is the name — never a ciphertext, an IV, or a
|
|
56
|
+
* key version.
|
|
57
|
+
*
|
|
58
|
+
* **Only for a name a registry declares.** `message` crosses the HTTP boundary, so the name in it must be
|
|
59
|
+
* one an operator wrote in a registry and not one a caller supplied. A keyspace member's stored name embeds
|
|
60
|
+
* a tenant key; {@link SystemSecretsStore.getValue} keeps that out of `message` for exactly that reason.
|
|
61
|
+
*/
|
|
62
|
+
export function unreadableSecret(name: string): SecretCryptoError {
|
|
63
|
+
return new SecretCryptoError({
|
|
64
|
+
message: `Secret '${name}' is stored and could not be read.`,
|
|
65
|
+
action:
|
|
66
|
+
"Check SECRETS_ENCRYPTION_KEYS still holds the key version this row was sealed under. If it does not, re-seal the secret with `pithy secrets update`.",
|
|
67
|
+
detail: `d1 secret '${name}' has a row that did not decrypt`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decrypt and parse one row, or answer that it would not open (#384).
|
|
73
|
+
*
|
|
74
|
+
* **The `catch` takes no binding, and that is the point rather than a tidiness.** A decryption failure's
|
|
75
|
+
* own text names the key version it tried and the context it tried under; `decodeVersionedValue`'s names
|
|
76
|
+
* the shape it found in a plaintext. Neither may reach a log, a response, or a held error, and a `catch`
|
|
77
|
+
* with nothing bound makes that impossible to get wrong later rather than merely absent today.
|
|
78
|
+
*
|
|
79
|
+
* Both halves are inside it. A ciphertext that opens to something that is not an envelope is as unreadable
|
|
80
|
+
* as one that does not open at all, and it arrives by the same routes.
|
|
81
|
+
*/
|
|
82
|
+
async function readRow(
|
|
83
|
+
config: EncryptionConfig,
|
|
84
|
+
row: { name: string; encryptedValue: string; iv: string; keyVersion: number },
|
|
85
|
+
): Promise<StoredSecretValue> {
|
|
86
|
+
try {
|
|
87
|
+
// The row's own name is the bound context: a ciphertext moved to another row does not open.
|
|
88
|
+
return { state: "readable", value: decodeVersionedValue(await decryptValue(config, row.name, row)) };
|
|
89
|
+
} catch {
|
|
90
|
+
return { state: "unreadable" };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The D1-backed encrypted store for `d1`-backed secrets, ported from the CMS `SystemSecretsStore`
|
|
96
|
+
* with Pithy's universal value envelope layered on. Every secret's plaintext is a
|
|
97
|
+
* `{ currentVersion, versions }` envelope (`crypto/versionedValue`), sealed in one AES-256-GCM
|
|
98
|
+
* envelope (`crypto/envelope`) and persisted as one `pithy_secrets_system_secrets` row. The master
|
|
99
|
+
* key is held only in memory, resolved once from the worker-only `SECRETS_ENCRYPTION_KEYS` binding.
|
|
100
|
+
*
|
|
101
|
+
* The store is the low-level read/write primitive: it persists and returns value envelopes. The
|
|
102
|
+
* create/update/rotate *semantics* (which envelope to write) live above it — in the manager
|
|
103
|
+
* Workflow and the CLI. Construct per request so a `SECRETS_ENCRYPTION_KEYS` rotation is picked up.
|
|
104
|
+
*/
|
|
105
|
+
export class SystemSecretsStore {
|
|
106
|
+
readonly #db: SecretsDb;
|
|
107
|
+
readonly #config: EncryptionConfig;
|
|
108
|
+
|
|
109
|
+
constructor(db: SecretsDb, config: EncryptionConfig) {
|
|
110
|
+
this.#db = db;
|
|
111
|
+
this.#config = config;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Build a store from the worker env: its `SECRETS` D1 and resolved master-key config. */
|
|
115
|
+
static async fromEnv(env: SecretsStoreEnv): Promise<SystemSecretsStore> {
|
|
116
|
+
const config = await resolveEncryptionConfig(env);
|
|
117
|
+
return new SystemSecretsStore(createDatabase(env.SECRETS, secretsTables), config);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read every requested name that exists, each row's outcome on its own value (absent names omitted).
|
|
122
|
+
*
|
|
123
|
+
* The name list is the *application's* size, not a query's: `secretsStore` hands this every D1-backed
|
|
124
|
+
* secret the registry declares, in one call, at boot. So it is chunked against D1's cap rather than
|
|
125
|
+
* assumed to fit. Unchunked, an app declaring 101 of them read none of them, and because every
|
|
126
|
+
* capability's secrets resolve through this one call, that is the whole Worker failing to start over a
|
|
127
|
+
* limit nothing in a registry mentions (#250).
|
|
128
|
+
*
|
|
129
|
+
* **And for the same reason a row that will not open costs only itself (#384).** The decrypt used to run
|
|
130
|
+
* in a bare loop, so one corrupt ciphertext — or one `keyVersion` orphaned by a master-key rotation —
|
|
131
|
+
* threw out of the batch, and the caller above held that against *every* `d1` name in the read. An
|
|
132
|
+
* unreadable `auth-github-credentials` therefore took `auth-session-secret` down with it, which is how
|
|
133
|
+
* one unreadable OAuth credential ended every form of sign-in (#381). Each row is guarded now and lands
|
|
134
|
+
* as a {@link StoredSecretValue} against its own name, which is what #170 promised.
|
|
135
|
+
*/
|
|
136
|
+
async getValues(names: string[]): Promise<Record<string, StoredSecretValue>> {
|
|
137
|
+
if (names.length === 0) return {};
|
|
138
|
+
const out: Record<string, StoredSecretValue> = {};
|
|
139
|
+
for (const chunk of chunkByBoundParameters(names, 0)) {
|
|
140
|
+
const rows = await this.#db
|
|
141
|
+
.selectFrom("pithySecretsSystemSecrets")
|
|
142
|
+
.select(["name", "encryptedValue", "iv", "keyVersion"])
|
|
143
|
+
.where("name", "in", chunk)
|
|
144
|
+
.execute();
|
|
145
|
+
|
|
146
|
+
for (const row of rows) out[row.name] = await readRow(this.#config, row);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The value envelope for one secret, or `undefined` if it is not stored. A stored row that will not open
|
|
153
|
+
* throws `secrets/crypto_failed`.
|
|
154
|
+
*
|
|
155
|
+
* **The union collapses here, and only here, because a batch of one has nothing to protect.** The state
|
|
156
|
+
* rides on the value in {@link getValues} so that one bad row cannot cost its neighbors; asking for a
|
|
157
|
+
* single name there are no neighbors, and every caller of this one — a keyspace member read, a rotate's
|
|
158
|
+
* baseline, the dev seeder — wants the value or an exception. What it must *not* do is answer
|
|
159
|
+
* `undefined`, which means "nothing is stored" and sends the reader to provision a row that is already
|
|
160
|
+
* there.
|
|
161
|
+
*
|
|
162
|
+
* **The name goes in `detail` and never in `message`, which is why this is not {@link unreadableSecret}.**
|
|
163
|
+
* That one is handed a registry literal. This one is handed a *stored* name, and for a keyspace member
|
|
164
|
+
* that is the whole `<keyspace>/<key>` — a tenant identifier, supplied by a caller. `message` crosses the
|
|
165
|
+
* HTTP boundary and `detail` is stripped there, so a sentence naming the secret would answer "which of
|
|
166
|
+
* your tenants has a broken credential" to whoever could provoke it.
|
|
167
|
+
*/
|
|
168
|
+
async getValue(name: string): Promise<VersionedValue | undefined> {
|
|
169
|
+
const stored = (await this.getValues([name]))[name];
|
|
170
|
+
if (!stored) return undefined;
|
|
171
|
+
if (stored.state === "unreadable") {
|
|
172
|
+
throw new SecretCryptoError({ detail: `stored secret '${name}' has a row that did not decrypt` });
|
|
173
|
+
}
|
|
174
|
+
return stored.value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Whether a secret with this name is stored. */
|
|
178
|
+
async has(name: string): Promise<boolean> {
|
|
179
|
+
const row = await this.#db
|
|
180
|
+
.selectFrom("pithySecretsSystemSecrets")
|
|
181
|
+
.select("name")
|
|
182
|
+
.where("name", "=", name)
|
|
183
|
+
.executeTakeFirst();
|
|
184
|
+
return row !== undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Encrypt `value` under the current key version and upsert the row. Inserts on first write,
|
|
189
|
+
* updates the envelope in place otherwise. `name` is sealed into the ciphertext as authenticated
|
|
190
|
+
* data (`crypto/envelope`), so a rename has to go through here and not through SQL: an
|
|
191
|
+
* `UPDATE ... SET name` leaves a row nothing can open. The caller owns the envelope's shape —
|
|
192
|
+
* `initialVersionedValue` for a create, an edited envelope for an update, `appendVersion` for a
|
|
193
|
+
* value rotation.
|
|
194
|
+
*/
|
|
195
|
+
async put(name: string, value: VersionedValue, valueType: SecretValueType = "text"): Promise<void> {
|
|
196
|
+
const { encryptedValue, iv, keyVersion } = await encryptValue(this.#config, name, encodeVersionedValue(value));
|
|
197
|
+
const now = SQLiteDate.encode(new Date());
|
|
198
|
+
|
|
199
|
+
if (await this.has(name)) {
|
|
200
|
+
await this.#db
|
|
201
|
+
.updateTable("pithySecretsSystemSecrets")
|
|
202
|
+
.set({ encryptedValue, iv, keyVersion, valueType, updatedAt: now })
|
|
203
|
+
.where("name", "=", name)
|
|
204
|
+
.execute();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await this.#db
|
|
208
|
+
.insertInto("pithySecretsSystemSecrets")
|
|
209
|
+
.values({ name, encryptedValue, iv, keyVersion, valueType, createdAt: now, updatedAt: now })
|
|
210
|
+
.execute();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Remove a secret. A no-op if it does not exist. */
|
|
214
|
+
async delete(name: string): Promise<void> {
|
|
215
|
+
await this.#db.deleteFrom("pithySecretsSystemSecrets").where("name", "=", name).execute();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Every stored secret name, sorted — the metadata `ls` reads without touching values. */
|
|
219
|
+
async listNames(): Promise<string[]> {
|
|
220
|
+
const rows = await this.#db.selectFrom("pithySecretsSystemSecrets").select("name").orderBy("name").execute();
|
|
221
|
+
return rows.map((row) => row.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { EncryptionConfig } from "../crypto/envelope";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A fresh AES-256 master-key config for a test run, as the string a `SECRETS_ENCRYPTION_KEYS` binding
|
|
8
|
+
* carries — the same shape `.dev.vars` supplies in local dev. Put it on a package's Miniflare
|
|
9
|
+
* `bindings` and the worker resolves its encryption config from it, so encrypt, decrypt, and at-rest
|
|
10
|
+
* rotation are all exercisable locally with no live Cloudflare Secrets Store.
|
|
11
|
+
*
|
|
12
|
+
* **Its own module, with one type-only import and nothing else, and that is load-bearing.** A
|
|
13
|
+
* `vitest.workers.config.ts` is loaded by vite through Node's own ESM resolver, which cannot follow
|
|
14
|
+
* the extensionless specifiers this repository's TypeScript sources use — so a config importing
|
|
15
|
+
* anything with a runtime dependency fails before a single test runs. The type import is erased.
|
|
16
|
+
* Keep this file free of runtime imports, or every workers config that reads it stops loading.
|
|
17
|
+
*
|
|
18
|
+
* Generated per call, and never reused as anything but a test key.
|
|
19
|
+
*/
|
|
20
|
+
export function devEncryptionKeys(): string {
|
|
21
|
+
const key = crypto.getRandomValues(new Uint8Array(32));
|
|
22
|
+
let binary = "";
|
|
23
|
+
for (const byte of key) binary += String.fromCharCode(byte);
|
|
24
|
+
const config: EncryptionConfig = {
|
|
25
|
+
currentVersion: "1",
|
|
26
|
+
versions: { "1": btoa(binary) },
|
|
27
|
+
lastRotatedAt: "2026-01-01T00:00:00.000Z",
|
|
28
|
+
};
|
|
29
|
+
return JSON.stringify(config);
|
|
30
|
+
}
|