@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @pithy-sh/secrets
|
|
2
|
+
|
|
3
|
+
Encrypted secret storage for Pithy. One dedicated store per environment, a worker-only master key, automatic at-rest key rotation, and the `pithy secrets` CLI to manage it.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pithy add secrets
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Documentation: [pithy.sh/docs/capabilities/secrets](https://pithy.sh/docs/capabilities/secrets).** Overview, adding it, using it, and the reference: the two backends, keyspaces, scope, rotation, the master key, dev values.
|
|
10
|
+
|
|
11
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/secrets",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/secrets"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"!src/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./src/*": "./src/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:node": "vitest run --project=node",
|
|
27
|
+
"test:workers": "vitest run --project=workers",
|
|
28
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
29
|
+
"clean": "rm -rf dist .turbo",
|
|
30
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
34
|
+
"@hono/zod-validator": "^0.9.0",
|
|
35
|
+
"@pithy-sh/cloudflare": "workspace:*",
|
|
36
|
+
"@pithy-sh/core": "workspace:*",
|
|
37
|
+
"comment-json": "^5.0.0",
|
|
38
|
+
"hono": "^4.13.2",
|
|
39
|
+
"kysely": "^0.29.0",
|
|
40
|
+
"zod": "^4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
44
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
45
|
+
"@types/node": "^22.15.0",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
47
|
+
"kysely-d1": "^0.4.0",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^4.1.0",
|
|
50
|
+
"wrangler": "^4.115.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "secrets",
|
|
3
|
+
"package": "@pithy-sh/secrets",
|
|
4
|
+
"requiredBindings": [{ "type": "d1", "name": "SECRETS" }, { "type": "secret", "name": "SECRETS_ENCRYPTION_KEYS" }],
|
|
5
|
+
"optionalCapabilities": ["controlplane", "audit"],
|
|
6
|
+
"migrationNamespace": "secrets",
|
|
7
|
+
"secrets": [
|
|
8
|
+
{
|
|
9
|
+
"name": "SECRETS_ENCRYPTION_KEYS",
|
|
10
|
+
"origin": {
|
|
11
|
+
"kind": "minted",
|
|
12
|
+
"recipe": {
|
|
13
|
+
"kind": "encryptionConfig"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"rotation": {
|
|
17
|
+
"kind": "local"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "CLOUDFLARE_API_TOKEN",
|
|
22
|
+
"origin": {
|
|
23
|
+
"kind": "helped",
|
|
24
|
+
"issuer": "cloudflare",
|
|
25
|
+
"needs": { "cloudflare": ["secrets:read", "secrets:write"] },
|
|
26
|
+
"documentation": "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/"
|
|
27
|
+
},
|
|
28
|
+
"rotation": {
|
|
29
|
+
"kind": "provider",
|
|
30
|
+
"issuer": "cloudflare",
|
|
31
|
+
"documentation": "https://developers.cloudflare.com/api/resources/user/subresources/tokens/methods/update/"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"whenToEnable": "Encrypted secret storage with a worker-only master key and automatic at-rest key rotation. Auth's token-signing key lives here.",
|
|
36
|
+
"configOptions": [
|
|
37
|
+
{
|
|
38
|
+
"key": "registry",
|
|
39
|
+
"default": {},
|
|
40
|
+
"describe": "Your secrets, keyed by name — each declaring where it lives (`backend`), whether its value is the same everywhere (`scope`), whether a rotator may manage it (`rotatable`), how it is read (`valueType`, plus a Zod schema for `json`), and — optionally, together — how it comes to exist (`origin`) and how it is replaced (`rotation`), which is what lets a tool tell you *run this* or *get it here* instead of *not set*. Empty because the contents are yours and `pithy add` cannot invent them. Both the worker's `secretsStore` and the `pithy secrets` CLI read this object, so a secret that is not declared here does not exist. Fill it in before you store anything — see @pithy-sh/secrets' README."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"key": "rotationIntervalDays",
|
|
44
|
+
"default": 30,
|
|
45
|
+
"describe": "How often the at-rest encryption key rotates. The secrets manager re-encrypts every stored secret on this cadence; raise it to rotate less often, lower it to rotate more aggressively."
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type CapabilityHealth, defineCapabilityHealth } from "@pithy-sh/core/src/controlPlane/discovery/health";
|
|
5
|
+
import { secretsStatusDatabase } from "../data/statusDb";
|
|
6
|
+
import { SECRETS_STATUS_READ_SCOPE } from "../http/guards";
|
|
7
|
+
import type { SecretRegistry } from "../registry";
|
|
8
|
+
import { readSecretStatus, type SecretStatusEntry, type SecretsStatusDb } from "./status";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What this capability contributes to its manifest entry: one number, so a management client can say
|
|
12
|
+
* "3 secrets need rotating" from the read it already made (#317).
|
|
13
|
+
*
|
|
14
|
+
* ## Why a count belongs on the manifest and the list does not
|
|
15
|
+
*
|
|
16
|
+
* The list is `GET {base}/admin/status`, and it stays there. What a rail needs is whether the detail is
|
|
17
|
+
* worth fetching, and that is one scalar. Anything more — which secrets, since when — grows with the
|
|
18
|
+
* adopter's registry and would turn a discovery read into a data API.
|
|
19
|
+
*
|
|
20
|
+
* ## What it costs, stated
|
|
21
|
+
*
|
|
22
|
+
* Exactly what the status read costs, because it *is* the status read: one `where name in (...)` against
|
|
23
|
+
* `pithy_secrets_system_secrets`, where `name` is unique, and one grouped `where name in (...)` against
|
|
24
|
+
* `pithy_secrets_rotations`, where `name` is indexed (`pithySecretsRotationsNameIdx`). Both are bounded
|
|
25
|
+
* by how many secrets the composed registry *declares* — a number in the adopter's source — and never by
|
|
26
|
+
* how many rows they hold. That is what `cost: "indexed"` claims, and it is the whole reason this may
|
|
27
|
+
* sit on the most frequently fetched read the seam serves.
|
|
28
|
+
*
|
|
29
|
+
* ## One definition of late
|
|
30
|
+
*
|
|
31
|
+
* The count is derived from {@link readSecretStatus} rather than from a second query that re-decides
|
|
32
|
+
* what overdue means. A secret whose registry entry declares no cadence, or that has nothing to measure
|
|
33
|
+
* from, reports `overdue: null` there and is not counted here: nobody has said what late means for it,
|
|
34
|
+
* and counting it would be an opinion the capability does not hold.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The key the count appears under. Exported so a client and a test name it from one place. */
|
|
38
|
+
export const SECRETS_DUE_FOR_ROTATION = "secretsDueForRotation";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How many of these entries are past their declared cadence — the one definition of "due", read by
|
|
42
|
+
* the manifest count here and by the status route's own audit metadata.
|
|
43
|
+
*
|
|
44
|
+
* `overdue === true` and never merely truthy: the third state is null — nobody has said what late means
|
|
45
|
+
* for that secret — and folding it into "not overdue" would be the same mistake as reporting a withheld
|
|
46
|
+
* number as zero.
|
|
47
|
+
*
|
|
48
|
+
* **An unreadable entry is not counted, and the count is still a number (`#387`).** Before the per-row
|
|
49
|
+
* guard, one malformed row threw out of `readSecretStatus` and this capability reported `unavailable` for
|
|
50
|
+
* the whole manifest key — `#350` working exactly as designed, and still the wrong answer, because the
|
|
51
|
+
* other secrets' freshness was knowable and went unreported. A secret whose row will not decode is now in
|
|
52
|
+
* the same position as one that declares no cadence: nobody can say whether it is late, so it is not
|
|
53
|
+
* asserted to be. That is a smaller lie than counting it either way, and a much smaller one than
|
|
54
|
+
* withholding the number.
|
|
55
|
+
*/
|
|
56
|
+
export function dueForRotation(entries: readonly SecretStatusEntry[]): number {
|
|
57
|
+
return entries.filter((entry) => entry.state === "readable" && entry.status.overdue === true).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How many declared secrets are past the cadence their registry entry declares. */
|
|
61
|
+
export async function countSecretsDueForRotation(
|
|
62
|
+
db: SecretsStatusDb,
|
|
63
|
+
registry: SecretRegistry,
|
|
64
|
+
now?: Date,
|
|
65
|
+
): Promise<number> {
|
|
66
|
+
return dueForRotation(await readSecretStatus(db, registry, now ? { now } : {}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The capability's health summary, over the registry it actually composed.
|
|
71
|
+
*
|
|
72
|
+
* A thunk for the same reason the status routes take one: the set worth reporting is every capability's
|
|
73
|
+
* combined registry, and that only exists after `compose` has run.
|
|
74
|
+
*/
|
|
75
|
+
export function secretsHealth(registry: () => SecretRegistry): CapabilityHealth {
|
|
76
|
+
return defineCapabilityHealth({
|
|
77
|
+
keys: [
|
|
78
|
+
{
|
|
79
|
+
key: SECRETS_DUE_FOR_ROTATION,
|
|
80
|
+
kind: "count",
|
|
81
|
+
states: null,
|
|
82
|
+
// The scope the status read is already behind. A count is a smaller disclosure than the listing
|
|
83
|
+
// it summarizes, but it is a disclosure of the same thing — which credentials are stale is a map
|
|
84
|
+
// of where to push — so an adopter who withheld the listing withholds the number with it.
|
|
85
|
+
scope: SECRETS_STATUS_READ_SCOPE,
|
|
86
|
+
cost: "indexed",
|
|
87
|
+
summary: "Secrets past the rotation cadence their registry entry declares.",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
read: async (c) => ({
|
|
91
|
+
[SECRETS_DUE_FOR_ROTATION]: await countSecretsDueForRotation(secretsStatusDatabase(c), registry()),
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
}
|