@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,119 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
5
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
6
|
+
import { CloudflareSecretsStoreManager } from "@pithy-sh/cloudflare/src/secrets/secretsStoreManager";
|
|
7
|
+
import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
|
|
8
|
+
import { resolveEncryptionConfig, type SecretBinding, type SecretsStoreEnv } from "../env/bindings";
|
|
9
|
+
import { isRotationDue } from "../rotation/keyRotation";
|
|
10
|
+
import type { ManagedEnvironment } from "../scope";
|
|
11
|
+
import { configureSharedSecrets, sharedSecretsStore } from "../sharedSecretsStore";
|
|
12
|
+
import { managerRegistry } from "./managerRegistry";
|
|
13
|
+
import { secretsWorkflowRetry } from "./retryPolicy";
|
|
14
|
+
import { runRotationWorkflow } from "./rotationWorkflow";
|
|
15
|
+
import { rotationConfigWriter } from "./secretsConfigWriter";
|
|
16
|
+
import { runWriteWorkflow, type WriteWorkflowPayload, type WriteWorkflowResult } from "./writeWorkflow";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The prebuilt per-environment secrets manager worker. `pithy add secrets` deploys one per managed
|
|
20
|
+
* environment (`<project>-staging-secrets`, `<project>-prod-secrets`); the user authors no code for it.
|
|
21
|
+
* It hosts two Workflows and a cron:
|
|
22
|
+
*
|
|
23
|
+
* - `SecretsWriteWorkflow` — the CLI's dispatch target for create/update/remove.
|
|
24
|
+
* - `AtRestKeyRotationWorkflow` — re-encrypts the store under a fresh master key.
|
|
25
|
+
* - `scheduled()` — fires the rotation Workflow when the configured interval has elapsed.
|
|
26
|
+
*
|
|
27
|
+
* The Workflow bodies (`runWriteWorkflow`, `runRotationWorkflow`) are tested against Miniflare; these
|
|
28
|
+
* classes are the thin durable-execution shells over them. This module imports `cloudflare:workers`,
|
|
29
|
+
* so it runs only in the Workers runtime (excluded from the node meta-test).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const DEFAULT_ROTATION_INTERVAL_DAYS = 30;
|
|
33
|
+
|
|
34
|
+
// This is a standalone worker, not assembled by `createBackend`, so its `compose` hook never runs.
|
|
35
|
+
// Configure the shared per-invocation accessor directly from the manager's own registry, so reads go
|
|
36
|
+
// through the one cached path (a rotation run reads `CLOUDFLARE_API_TOKEN` once) like every other worker.
|
|
37
|
+
configureSharedSecrets({ registry: managerRegistry });
|
|
38
|
+
|
|
39
|
+
/** The manager worker's env: the secrets D1 + key binding, the rotation Workflow binding, CF creds for the write-back. */
|
|
40
|
+
export interface SecretsManagerEnv extends SecretsStoreEnv {
|
|
41
|
+
/** The at-rest rotation Workflow binding, triggered by the cron. */
|
|
42
|
+
AT_REST_ROTATION: { create(): Promise<unknown> };
|
|
43
|
+
/**
|
|
44
|
+
* The scoped CF API token for the at-rest config write-back — the only live-CF write. A
|
|
45
|
+
* `cf-secrets-store` binding read via `sharedSecretsStore(env, managerRegistry).get("CLOUDFLARE_API_TOKEN")`,
|
|
46
|
+
* a string from `.dev.vars` in local dev. Never a plaintext env var.
|
|
47
|
+
*/
|
|
48
|
+
CLOUDFLARE_API_TOKEN: SecretBinding | string;
|
|
49
|
+
CLOUDFLARE_ACCOUNT_ID: string;
|
|
50
|
+
SECRETS_STORE_ID: string;
|
|
51
|
+
/**
|
|
52
|
+
* This manager's environment. The rotation write-back uses it to target the project- and env-scoped
|
|
53
|
+
* master-key store entry — the same entry the `SECRETS_ENCRYPTION_KEYS` binding reads — so a rotation
|
|
54
|
+
* persists to the entry the worker actually binds. Filled at provision from `managedEnvironments()`;
|
|
55
|
+
* still `ManagedEnvironment.parse`d at the read site, since a wrangler var is external config.
|
|
56
|
+
*/
|
|
57
|
+
ENVIRONMENT: ManagedEnvironment;
|
|
58
|
+
/**
|
|
59
|
+
* This manager's project — the other half of the master-key entry name
|
|
60
|
+
* (`<project>-<env>-secrets-encryption-keys`). Stamped at provision from the root `pithy.config.ts`
|
|
61
|
+
* `name`. It is here because the account's Secrets Store is flat: the entry name is the only thing
|
|
62
|
+
* separating this project's key from another project's in the same account, and the rotation has to
|
|
63
|
+
* reproduce that name inside the Worker to write the new key set back where the binding reads it.
|
|
64
|
+
*/
|
|
65
|
+
PROJECT: string;
|
|
66
|
+
/** Rotation cadence in days; defaults to 30. Sourced from the `rotationIntervalDays` config option. */
|
|
67
|
+
ROTATION_INTERVAL_DAYS?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The management write Workflow — the CLI dispatches create/update/remove here.
|
|
72
|
+
*
|
|
73
|
+
* The step runs under {@link secretsWorkflowRetry}, so a refusal the store has already decided
|
|
74
|
+
* (`create` over a name that exists, `update` of a name that does not) fails the instance on its first
|
|
75
|
+
* attempt instead of backing off into the same answer. See `retryPolicy.ts` for the whole classification.
|
|
76
|
+
*/
|
|
77
|
+
export class SecretsWriteWorkflow extends WorkflowEntrypoint<SecretsManagerEnv, WriteWorkflowPayload> {
|
|
78
|
+
override async run(event: WorkflowEvent<WriteWorkflowPayload>, step: WorkflowStep): Promise<WriteWorkflowResult> {
|
|
79
|
+
const steps = classifiedSteps(step, secretsWorkflowRetry, NonRetryableError);
|
|
80
|
+
return await steps.do("write-secret", () => runWriteWorkflow(this.env, event.payload));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The at-rest key-rotation Workflow — re-encrypts the store under a fresh master key. */
|
|
85
|
+
export class AtRestKeyRotationWorkflow extends WorkflowEntrypoint<SecretsManagerEnv, unknown> {
|
|
86
|
+
override async run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void> {
|
|
87
|
+
// Build the manager's secret accessor once; read the CF API token at the point of need. Every
|
|
88
|
+
// place a secret is consumed is a visible `secrets.get(...)` call site (grep-able), never hidden
|
|
89
|
+
// behind a wrapper.
|
|
90
|
+
const secrets = await sharedSecretsStore(this.env, managerRegistry);
|
|
91
|
+
const manager = new CloudflareSecretsStoreManager({
|
|
92
|
+
accountId: this.env.CLOUDFLARE_ACCOUNT_ID,
|
|
93
|
+
apiToken: secrets.get("CLOUDFLARE_API_TOKEN"),
|
|
94
|
+
storeId: this.env.SECRETS_STORE_ID,
|
|
95
|
+
});
|
|
96
|
+
// The writer targets the project- and env-scoped master-key entry this worker actually binds
|
|
97
|
+
// (see rotationConfigWriter), so the rotation persists exactly where it is read — and never into
|
|
98
|
+
// another project's key entry in the same account-wide Secrets Store.
|
|
99
|
+
//
|
|
100
|
+
// Its steps run under the same classification the write does: the CF API being unreachable is worth
|
|
101
|
+
// another attempt, and ciphertext that will not decrypt under the bound key set is not.
|
|
102
|
+
await runRotationWorkflow(
|
|
103
|
+
this.env,
|
|
104
|
+
rotationConfigWriter(manager, this.env.PROJECT, this.env.ENVIRONMENT),
|
|
105
|
+
classifiedSteps(step, secretsWorkflowRetry, NonRetryableError),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default {
|
|
111
|
+
/** Cron entry: trigger the at-rest rotation Workflow only when the interval has elapsed. */
|
|
112
|
+
async scheduled(_controller: unknown, env: SecretsManagerEnv): Promise<void> {
|
|
113
|
+
const config = await resolveEncryptionConfig(env);
|
|
114
|
+
const intervalDays = Number(env.ROTATION_INTERVAL_DAYS ?? DEFAULT_ROTATION_INTERVAL_DAYS);
|
|
115
|
+
if (isRotationDue(config.lastRotatedAt, intervalDays)) {
|
|
116
|
+
await env.AT_REST_ROTATION.create();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The prebuilt per-environment secrets manager worker. This is a TEMPLATE, not a wrangler
|
|
3
|
+
// env-stanza file: staging and prod are two genuinely separate workers with distinct
|
|
4
|
+
// resources, not one logical worker with `[env.*]` overrides. So `pithy add secrets` resolves
|
|
5
|
+
// this into two complete standalone configs — replacing every name and `<...>` placeholder (the
|
|
6
|
+
// worker name, SECRETS D1 id and name, Secrets Store id and entry names, both Workflow names) per
|
|
7
|
+
// environment — and deploys each with its own config (`wrangler deploy --config <resolved>`). No
|
|
8
|
+
// `--env` name-mangling. The user authors none of it.
|
|
9
|
+
//
|
|
10
|
+
// Every literal name below is a placeholder, replaced rather than suffixed. The deployed names carry
|
|
11
|
+
// the project, and nothing here knows what the project is — see docs/NAMING.md.
|
|
12
|
+
"name": "pithy-secrets", // resolved → <project>-<env>-secrets
|
|
13
|
+
"main": "./worker.ts",
|
|
14
|
+
// The compatibility date every Worker in this repository runs on. Stated once in the repository
|
|
15
|
+
// root's `compatibility.ts` and copied here because JSONC cannot import it —
|
|
16
|
+
// `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
|
|
17
|
+
"compatibility_date": "2026-06-01",
|
|
18
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
19
|
+
|
|
20
|
+
// No public URL. This manager has no HTTP routes — it is reached only by Workflow dispatch (over the
|
|
21
|
+
// CF API) and its rotation cron. `workers_dev: false` keeps it off workers.dev so nothing accidentally
|
|
22
|
+
// exposes it. (The account still needs a registered workers.dev subdomain — Cloudflare requires one to
|
|
23
|
+
// deploy any Worker that hosts Workflows, regardless of this flag. Provisioning preflights that.)
|
|
24
|
+
"workers_dev": false,
|
|
25
|
+
|
|
26
|
+
// The dedicated per-environment secrets database — distinct from the app DB, durable, shared by
|
|
27
|
+
// every feature branch in this environment. Resolved to `<project>-<env>-secrets`, the same name the
|
|
28
|
+
// manager itself deploys under: the worker and its database are one unit.
|
|
29
|
+
"d1_databases": [{ "binding": "SECRETS", "database_name": "pithy-secrets", "database_id": "<filled-at-provision>" }],
|
|
30
|
+
|
|
31
|
+
// Worker-only secrets, both CF Secrets Store bindings read through the secretsStore accessor.
|
|
32
|
+
// The **binding** names are fixed and never scoped; every `secret_name` below is a placeholder that
|
|
33
|
+
// resolveManagerConfig replaces with the project-scoped entry provisioning actually wrote —
|
|
34
|
+
// <project>-<env>-secrets-encryption-keys for the master key, and
|
|
35
|
+
// <project>-global-secrets-manager-cf-api-token for the scoped CF API token that backs the rotation
|
|
36
|
+
// write-back. The account has one flat Secrets Store, so the name is the only partition between two
|
|
37
|
+
// Pithy projects sharing it. The manager reads the token via its registry, never a plaintext env var.
|
|
38
|
+
"secrets_store_secrets": [
|
|
39
|
+
{
|
|
40
|
+
"binding": "SECRETS_ENCRYPTION_KEYS",
|
|
41
|
+
"store_id": "<filled-at-provision>",
|
|
42
|
+
"secret_name": "<filled-at-provision>"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"binding": "CLOUDFLARE_API_TOKEN",
|
|
46
|
+
"store_id": "<filled-at-provision>",
|
|
47
|
+
"secret_name": "<filled-at-provision>"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
|
|
51
|
+
// The two Workflows this manager hosts. The write Workflow is the CLI's dispatch target — resolved to
|
|
52
|
+
// <project>-<env>-secrets-write, so two projects' managers in one account are addressable separately
|
|
53
|
+
// (see secretsWriteWorkflowName). The rotation Workflow, <project>-<env>-secrets-rotate, is triggered
|
|
54
|
+
// by the cron below. Both names are composed from the **binding**, so these literals never deploy.
|
|
55
|
+
"workflows": [
|
|
56
|
+
{ "binding": "SECRETS_WRITE", "name": "pithy-secrets-write", "class_name": "SecretsWriteWorkflow" },
|
|
57
|
+
{ "binding": "AT_REST_ROTATION", "name": "pithy-secrets-rotate", "class_name": "AtRestKeyRotationWorkflow" }
|
|
58
|
+
],
|
|
59
|
+
|
|
60
|
+
// The at-rest key-rotation cron. Fires daily at 03:00 UTC; the worker's scheduled() handler then
|
|
61
|
+
// checks ROTATION_INTERVAL_DAYS and only triggers a rotation when the interval has actually
|
|
62
|
+
// elapsed — so the check is cheap and the rotation cadence stays at the configured interval.
|
|
63
|
+
"triggers": { "crons": ["0 3 * * *"] },
|
|
64
|
+
|
|
65
|
+
"vars": {
|
|
66
|
+
"ROTATION_INTERVAL_DAYS": "30", // exposed as the `rotationIntervalDays` config option (manifest)
|
|
67
|
+
"CLOUDFLARE_ACCOUNT_ID": "<filled-at-provision>",
|
|
68
|
+
"SECRETS_STORE_ID": "<filled-at-provision>",
|
|
69
|
+
// This manager's environment and project. The rotation write-back derives the master-key store
|
|
70
|
+
// entry name from both (<project>-<env>-secrets-encryption-keys) — the same entry the
|
|
71
|
+
// SECRETS_ENCRYPTION_KEYS binding reads. Omit either and the rotation re-encrypts every row under
|
|
72
|
+
// a key it then writes to an entry nobody binds: silent, total data loss at the next cold read.
|
|
73
|
+
"ENVIRONMENT": "<filled-at-provision>",
|
|
74
|
+
"PROJECT": "<filled-at-provision>"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { currentValue } from "../crypto/versionedValue";
|
|
6
|
+
import { RotationTrigger } from "../data/secretRotations";
|
|
7
|
+
import type { SecretsStoreEnv } from "../env/bindings";
|
|
8
|
+
import { runWriteSecret, WriteSecretOutcome, type WriteSecretParams } from "../management/writeSecret";
|
|
9
|
+
import { RotationClosure, type RotationFailureCode } from "../rotation/rotationLedger";
|
|
10
|
+
import { RotationTracker } from "../store/rotationTracker";
|
|
11
|
+
import { SystemSecretsStore } from "../store/systemSecretsStore";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The write Workflow payload: the write params plus an optional **test-only** `audit` flag. With
|
|
15
|
+
* `audit`, the workflow re-reads and decrypts the just-written secret and reports only whether it
|
|
16
|
+
* round-trips — never the value (see {@link runWriteWorkflow}).
|
|
17
|
+
*/
|
|
18
|
+
export type WriteWorkflowPayload =
|
|
19
|
+
| (WriteSecretParams & {
|
|
20
|
+
/** Test-only: verify the write decrypts back to the input. Only a boolean is returned, never a value. */
|
|
21
|
+
audit?: boolean;
|
|
22
|
+
})
|
|
23
|
+
| RotationLedgerCommand;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* **The rotation ledger, dispatched.** A `pithy secrets rotate` opens a row before it rolls and closes it
|
|
27
|
+
* after, and `pithy_secrets_rotations` lives in this database — so the two calls arrive here, on the same
|
|
28
|
+
* Workflow the value itself is written through. `#379`: without them a successful command-line rotation
|
|
29
|
+
* recorded nothing and the secret reported overdue forever.
|
|
30
|
+
*
|
|
31
|
+
* Parsed rather than trusted. The payload crosses the Workflows REST API from another process, which is a
|
|
32
|
+
* boundary like any other — and `rotationId` addresses a row, so an unvalidated one closes somebody else's.
|
|
33
|
+
* The closure carries a **code**, never the failure text: free text is where a value gets pasted by
|
|
34
|
+
* accident, so the sentence is composed further in, by `RotationTracker.markFailure`.
|
|
35
|
+
*/
|
|
36
|
+
export const RotationLedgerCommand = z
|
|
37
|
+
.discriminatedUnion("mode", [
|
|
38
|
+
z
|
|
39
|
+
.object({
|
|
40
|
+
mode: z.literal("rotation-open").describe("Open an `in_progress` rotation row and return its id."),
|
|
41
|
+
name: z.string().min(1).describe("The secret being rotated, by registry name."),
|
|
42
|
+
trigger: RotationTrigger.describe("What caused the rotation. `baseline` is a first write, not a rotation."),
|
|
43
|
+
rotatedBy: z.string().min(1).describe("Who or what asked, recorded verbatim in the row."),
|
|
44
|
+
})
|
|
45
|
+
.describe("Open a rotation row before anything is rolled, so a rotator that never returns leaves a trace."),
|
|
46
|
+
z
|
|
47
|
+
.object({
|
|
48
|
+
mode: z.literal("rotation-close").describe("Close a row this environment's manager previously opened."),
|
|
49
|
+
rotationId: z.number().int().positive().describe("The row id returned by the matching `rotation-open`."),
|
|
50
|
+
closure: RotationClosure.describe("How the row closes here: success, or failed with a reason code."),
|
|
51
|
+
})
|
|
52
|
+
.describe("Close a rotation row with what the run actually did in this environment."),
|
|
53
|
+
])
|
|
54
|
+
.describe("One rotation-ledger call dispatched to an environment's manager: open a row, or close one.");
|
|
55
|
+
export type RotationLedgerCommand = z.output<typeof RotationLedgerCommand>;
|
|
56
|
+
|
|
57
|
+
/** Whether a dispatched payload is a ledger call rather than a write. */
|
|
58
|
+
function isRotationLedgerCommand(payload: WriteWorkflowPayload): payload is RotationLedgerCommand {
|
|
59
|
+
return payload.mode === "rotation-open" || payload.mode === "rotation-close";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Everything one instance of this Workflow can report.
|
|
64
|
+
*
|
|
65
|
+
* Wider than {@link WriteSecretOutcome} because the Workflow does more than the write core does: it also
|
|
66
|
+
* carries the rotation ledger, which writes no secret and reports opening and closing rows. Derived from
|
|
67
|
+
* the write core's own enum rather than restated, so a member added there cannot go missing here.
|
|
68
|
+
*/
|
|
69
|
+
export const WriteWorkflowOutcome = z
|
|
70
|
+
.enum([...WriteSecretOutcome.options, "opened", "closed"])
|
|
71
|
+
.describe(
|
|
72
|
+
"What one management Workflow instance did: a write outcome, or a rotation row `opened` or `closed`. Never a value.",
|
|
73
|
+
);
|
|
74
|
+
export type WriteWorkflowOutcome = z.output<typeof WriteWorkflowOutcome>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The write Workflow's result — its instance output, and the whole of what leaves this worker.
|
|
78
|
+
*
|
|
79
|
+
* A Zod object rather than an interface because it is read back **outside** the Worker, off the
|
|
80
|
+
* Workflows REST API, by a CLI that must validate it like any other external input: a `PithyError`
|
|
81
|
+
* raised on a shape nobody expected is a run that stops, and a silently-`undefined` `outcome` is a
|
|
82
|
+
* provisioning gate that passes because it could not read its own subject.
|
|
83
|
+
*/
|
|
84
|
+
export const WriteWorkflowResult = z
|
|
85
|
+
.object({
|
|
86
|
+
outcome: WriteWorkflowOutcome.describe("What the instance did — the manager's answer, never a value."),
|
|
87
|
+
rotationId: z
|
|
88
|
+
.number()
|
|
89
|
+
.int()
|
|
90
|
+
.positive()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(
|
|
93
|
+
"The rotation row just opened, present only on a `rotation-open`. A surrogate row id in this environment's ledger — it addresses a record and describes nothing about a secret.",
|
|
94
|
+
),
|
|
95
|
+
audited: z
|
|
96
|
+
.boolean()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
"Test-only: whether the stored secret decrypted back to the dispatched value. The value itself never leaves the worker.",
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
.describe(
|
|
103
|
+
"One management write Workflow instance's output: what it did, and nothing that could reconstruct a value.",
|
|
104
|
+
);
|
|
105
|
+
export type WriteWorkflowResult = z.output<typeof WriteWorkflowResult>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Open or close a rotation row in this environment's ledger.
|
|
109
|
+
*
|
|
110
|
+
* The failure sentence is composed by the tracker, from the closure's own code, and never from anything
|
|
111
|
+
* the caller wrote. `admin/status.ts` refuses to publish `error_message` precisely because it is free text
|
|
112
|
+
* written at a failure site — accepting one over the wire would be that hazard arranged in advance.
|
|
113
|
+
*
|
|
114
|
+
* Since `#386` this path could not do otherwise: `markFailure` takes a {@link RotationFailureCode}, so the
|
|
115
|
+
* dispatched closure hands over the code it already carries and there is no argument a sentence would fit.
|
|
116
|
+
*/
|
|
117
|
+
async function runRotationLedgerCommand(
|
|
118
|
+
env: SecretsStoreEnv,
|
|
119
|
+
command: RotationLedgerCommand,
|
|
120
|
+
): Promise<WriteWorkflowResult> {
|
|
121
|
+
const tracker = RotationTracker.fromD1(env.SECRETS);
|
|
122
|
+
if (command.mode === "rotation-open") {
|
|
123
|
+
const rotationId = await tracker.startRotation(command.name, command.trigger, command.rotatedBy);
|
|
124
|
+
return { outcome: "opened", rotationId };
|
|
125
|
+
}
|
|
126
|
+
if (command.closure.status === "success") await tracker.markSuccess(command.rotationId);
|
|
127
|
+
else await tracker.markFailure(command.rotationId, command.closure.reason);
|
|
128
|
+
return { outcome: "closed" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The management write Workflow's body: build the store + tracker from the worker env and run the
|
|
133
|
+
* write core. This is what the CLI's dispatch lands on. Decrypting/encrypting happens here because
|
|
134
|
+
* the master key (resolved by `SystemSecretsStore.fromEnv` from the worker-only binding) never
|
|
135
|
+
* leaves the worker. Tested against Miniflare with the `SECRETS_ENCRYPTION_KEYS` string binding.
|
|
136
|
+
*
|
|
137
|
+
* **Audit (test-only round-trip check).** With `payload.audit` on a create/update, after the write
|
|
138
|
+
* the workflow opens a *fresh* store (re-resolves the key, re-reads D1), decrypts the secret, and
|
|
139
|
+
* compares it to the dispatched value — returning only `{ audited: boolean }`. The plaintext is
|
|
140
|
+
* compared inside the worker and never returned or logged, so the round trip is proven without a
|
|
141
|
+
* secret ever leaving. This is how the live integration test confirms encrypt → store → decrypt.
|
|
142
|
+
*/
|
|
143
|
+
export async function runWriteWorkflow(
|
|
144
|
+
env: SecretsStoreEnv,
|
|
145
|
+
payload: WriteWorkflowPayload,
|
|
146
|
+
): Promise<WriteWorkflowResult> {
|
|
147
|
+
// The ledger calls touch no value and need no master key, so they are answered before the store is even
|
|
148
|
+
// opened — which is also what lets a rotation record itself in an environment whose store is refusing.
|
|
149
|
+
if (isRotationLedgerCommand(payload))
|
|
150
|
+
return await runRotationLedgerCommand(env, RotationLedgerCommand.parse(payload));
|
|
151
|
+
|
|
152
|
+
const store = await SystemSecretsStore.fromEnv(env);
|
|
153
|
+
const tracker = RotationTracker.fromD1(env.SECRETS);
|
|
154
|
+
const outcome = await runWriteSecret({ store, tracker }, payload);
|
|
155
|
+
|
|
156
|
+
if (payload.audit && payload.mode !== "delete" && payload.mode !== "probe") {
|
|
157
|
+
const fresh = await SystemSecretsStore.fromEnv(env);
|
|
158
|
+
const stored = await fresh.getValue(payload.name);
|
|
159
|
+
return { outcome, audited: stored !== undefined && currentValue(stored) === payload.value };
|
|
160
|
+
}
|
|
161
|
+
return { outcome };
|
|
162
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create the two secrets tables in the per-environment secrets D1. Both carry the
|
|
9
|
+
* `pithy_secrets_` prefix so they never clash with an adopter's own tables.
|
|
10
|
+
*
|
|
11
|
+
* Identifiers — table, index, and column names — are declared in **camelCase**: the
|
|
12
|
+
* runner installs `CamelCasePlugin`, which snake-cases every identifier in the emitted
|
|
13
|
+
* DDL. So we never hand-write snake_case (CLAUDE.md §Data layer); the `pithy_secrets_`
|
|
14
|
+
* snake form exists only in the actual database. `pithySecretsSystemSecrets` becomes the
|
|
15
|
+
* SQL table `pithy_secrets_system_secrets`, and the column names match the Zod schemas.
|
|
16
|
+
*
|
|
17
|
+
* `down` is the tested inverse: drop both tables. D1 has no transactional DDL, so the
|
|
18
|
+
* order is deliberate — the rotations index and table first, then the secrets table.
|
|
19
|
+
*/
|
|
20
|
+
export const secrets_0001_init: Migration = {
|
|
21
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
22
|
+
await db.schema
|
|
23
|
+
.createTable("pithySecretsSystemSecrets")
|
|
24
|
+
.addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
|
|
25
|
+
.addColumn("name", "text", (c) => c.notNull().unique())
|
|
26
|
+
.addColumn("encryptedValue", "text", (c) => c.notNull())
|
|
27
|
+
.addColumn("iv", "text", (c) => c.notNull())
|
|
28
|
+
.addColumn("keyVersion", "integer", (c) => c.notNull())
|
|
29
|
+
.addColumn("valueType", "text", (c) => c.notNull().defaultTo("text"))
|
|
30
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
31
|
+
.addColumn("updatedAt", "integer", (c) => c.notNull())
|
|
32
|
+
.execute();
|
|
33
|
+
|
|
34
|
+
await db.schema
|
|
35
|
+
.createTable("pithySecretsRotations")
|
|
36
|
+
.addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
|
|
37
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
38
|
+
.addColumn("startedAt", "integer", (c) => c.notNull())
|
|
39
|
+
.addColumn("completedAt", "integer")
|
|
40
|
+
.addColumn("status", "text", (c) => c.notNull())
|
|
41
|
+
.addColumn("trigger", "text", (c) => c.notNull())
|
|
42
|
+
.addColumn("rotatedBy", "text", (c) => c.notNull())
|
|
43
|
+
.addColumn("errorMessage", "text")
|
|
44
|
+
.addColumn("metadataSnapshot", "text")
|
|
45
|
+
.execute();
|
|
46
|
+
|
|
47
|
+
await db.schema.createIndex("pithySecretsRotationsNameIdx").on("pithySecretsRotations").column("name").execute();
|
|
48
|
+
},
|
|
49
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
50
|
+
await db.schema.dropTable("pithySecretsRotations").execute();
|
|
51
|
+
await db.schema.dropTable("pithySecretsSystemSecrets").execute();
|
|
52
|
+
},
|
|
53
|
+
};
|
package/src/mintValue.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { DevSecretValue } from "@pithy-sh/core/src/capability/devSecret";
|
|
5
|
+
|
|
6
|
+
/** Bytes of entropy behind a minted value — 256 bits, the same budget as the master key. */
|
|
7
|
+
const RANDOM_BYTES = 32;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A fresh random string: 32 bytes of CSPRNG entropy, base64url and unpadded.
|
|
11
|
+
*
|
|
12
|
+
* **`crypto.getRandomValues`, and it must stay that way.** This is the only source of key material the
|
|
13
|
+
* kit generates — for local dev and for a deployed environment alike — so a substitution here that is
|
|
14
|
+
* not cryptographically secure would silently weaken every session signature, every signed link, and
|
|
15
|
+
* every key-encryption key the tool creates. There is no fallback path and there must not be one: on
|
|
16
|
+
* Workers, Node 22 and Bun, `crypto` is global and this always resolves.
|
|
17
|
+
*
|
|
18
|
+
* base64url rather than base64 so the value survives every place a minted value is carried by hand — a
|
|
19
|
+
* `.dev.vars` line, a shell export, a URL — without quoting or an `=` that reads as a second separator.
|
|
20
|
+
*/
|
|
21
|
+
function randomValue(): string {
|
|
22
|
+
const bytes = crypto.getRandomValues(new Uint8Array(RANDOM_BYTES));
|
|
23
|
+
let binary = "";
|
|
24
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
25
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mint a value for a secret whose registry entry declares one may be minted.
|
|
30
|
+
*
|
|
31
|
+
* **For every environment, including production.** This was documented as local-dev-only, and named for
|
|
32
|
+
* it, through four releases in which that was true. #321 made it the sole producer of key material
|
|
33
|
+
* written into a real account's Secrets Store and into a deployed environment's secrets D1 — by
|
|
34
|
+
* `pithy provision --env staging`, `--env prod`, `pithy secrets provision` and `pithy feature` — and
|
|
35
|
+
* left the contract saying the opposite. A security requirement documented as applying to dev, on the
|
|
36
|
+
* function that generates production keys, is a requirement nobody is checking. So the name and the
|
|
37
|
+
* sentence now say what the function is for; see `randomValue` for the property that must hold.
|
|
38
|
+
*
|
|
39
|
+
* The declaration is what limits it, not the environment. An entry carries a mintable value only when
|
|
40
|
+
* the value is *arbitrary* — a session signing key, a link signing key — and that is a fact about the
|
|
41
|
+
* value rather than about where it runs: nothing outside the project validates one, in prod for exactly
|
|
42
|
+
* the reason it does not on a laptop. A secret that must match something already issued carries no
|
|
43
|
+
* declaration and nothing here invents one. See {@link isMintableSecret}.
|
|
44
|
+
*
|
|
45
|
+
* The switch is exhaustive on purpose. A second {@link DevSecretValue} kind that this cannot produce
|
|
46
|
+
* fails the build here, rather than silently writing a random string where something else was meant.
|
|
47
|
+
*/
|
|
48
|
+
export function mintSecretValue(kind: DevSecretValue): string {
|
|
49
|
+
switch (kind) {
|
|
50
|
+
case "random":
|
|
51
|
+
return randomValue();
|
|
52
|
+
}
|
|
53
|
+
}
|