|
1 | 1 | # kern |
2 | 2 |
|
3 | | -Secrets for agents. TypeScript SDK + CLI built on [age](https://age-encryption.org/) encryption. |
| 3 | +[](https://github.com/daslabhq/kern/actions/workflows/test.yml) |
4 | 4 |
|
5 | | -## Why |
| 5 | +Encrypted secrets in git. Scoped by folder. Managed by agents. |
6 | 6 |
|
7 | | -Every agent stack reinvents secret management (badly): env vars on the |
8 | | -server, plaintext in databases, "share the .env in Slack" for dev. Kern is |
9 | | -the small, composable, standards-aligned alternative — built on |
10 | | -[age](https://age-encryption.org/) and BIP-39, no new crypto. |
| 7 | +```bash |
| 8 | +npm install @daslab/kern |
| 9 | +``` |
11 | 10 |
|
12 | | -What you get: |
| 11 | +## The problem |
13 | 12 |
|
14 | | -- **One environment variable** in production (the server's age key) instead |
15 | | - of dozens |
16 | | -- **Encrypted vault committed to your repo** — auditable in git, useless |
17 | | - to non-recipients |
18 | | -- **Multi-recipient** by default: same secret encrypted to multiple |
19 | | - identities (team, prod server, your devices) at once |
20 | | -- **No vendor lock-in** — vault files are standard age, readable by the |
21 | | - `age` CLI / `rage` / any age library |
| 13 | +91 API keys in a `.env` file. Shared over Slack. Copied between machines. Half of them are test keys that shouldn't be in production. New teammate joins — "ask the lead dev for the keys." Rotate one key — update it in 7 places, miss 2. |
22 | 14 |
|
23 | | -## Install |
| 15 | +## How kern solves it |
| 16 | + |
| 17 | +Secrets are age-encrypted files organized in folders. Each folder has a `.recipients` file that controls who can decrypt. Commit the whole thing to git — it's useless without a private key. |
24 | 18 |
|
25 | | -```bash |
26 | | -bun add @daslab/kern |
| 19 | +``` |
| 20 | +secrets/ |
| 21 | +├── .recipients # everyone (Alice + Production + CI) |
| 22 | +├── .nodes # who's who |
| 23 | +│ |
| 24 | +├── infra/ # prod runtime |
| 25 | +│ ├── database_url.age |
| 26 | +│ ├── redis_url.age |
| 27 | +│ ├── jwt_secret.age |
| 28 | +│ └── r2.age # { access_key_id, secret, account_id } |
| 29 | +│ |
| 30 | +├── oauth/ # prod OAuth app credentials |
| 31 | +│ ├── github.age # { client_id, client_secret, app_id } |
| 32 | +│ ├── google.age |
| 33 | +│ └── slack.age |
| 34 | +│ |
| 35 | +├── testing/ # local dev + CI only |
| 36 | +│ ├── .recipients # Alice + CI (NOT production server) |
| 37 | +│ ├── llm.age # { anthropic, openai, gemini } |
| 38 | +│ ├── elevenlabs.age |
| 39 | +│ ├── stripe_test.age |
| 40 | +│ └── ci_demo.age |
| 41 | +│ |
| 42 | +└── deploy/ # CI/CD only |
| 43 | + ├── .recipients # Alice + CI |
| 44 | + ├── apple_signing.age |
| 45 | + └── tauri_signing.age |
27 | 46 | ``` |
28 | 47 |
|
29 | | -The package is ESM-only; works on Bun, Node 20+, and Deno. age-encryption |
30 | | -0.3+ is the only runtime dependency. |
| 48 | +Your production server only decrypts `infra/` and `oauth/` — it never sees test keys or deploy secrets. CI decrypts `testing/` and `deploy/` but never touches production database credentials. Each node sees only what it should. |
31 | 49 |
|
32 | | -## Programmatic API |
| 50 | +## Quick start |
| 51 | + |
| 52 | +```bash |
| 53 | +# create your identity |
| 54 | +kern identity init |
33 | 55 |
|
34 | | -```ts |
| 56 | +# create the vault |
| 57 | +mkdir -p secrets |
| 58 | +kern identity pubkey >> secrets/.recipients |
| 59 | + |
| 60 | +# add your first secret |
| 61 | +kern secret add github_token |
| 62 | + |
| 63 | +# group related credentials |
| 64 | +mkdir -p secrets/testing |
| 65 | +cp secrets/.recipients secrets/testing/.recipients |
| 66 | +kern secret add testing/llm # { "anthropic": "sk-...", "openai": "sk-..." } |
| 67 | +``` |
| 68 | + |
| 69 | +## Use from code |
| 70 | + |
| 71 | +```typescript |
35 | 72 | import { loadIdentityFromHost, openVault } from "@daslab/kern"; |
36 | 73 |
|
37 | | -// load from KORN_AGE_KEY env var, falling back to ~/.kern/key |
38 | | -const id = await loadIdentityFromHost(); |
39 | | -const vault = openVault({ identity: id }); // reads ./secrets by default |
| 74 | +const vault = openVault({ identity: await loadIdentityFromHost() }); |
| 75 | + |
| 76 | +// simple value |
| 77 | +const dbUrl = await vault.get("infra/database_url"); |
| 78 | + |
| 79 | +// grouped credentials |
| 80 | +const llm = JSON.parse(await vault.get("testing/llm")); |
| 81 | +const anthropicKey = llm.anthropic; |
| 82 | +``` |
| 83 | + |
| 84 | +## Use from Claude Code |
| 85 | + |
| 86 | +Add kern as an MCP server: |
| 87 | + |
| 88 | +```json |
| 89 | +{ |
| 90 | + "mcpServers": { |
| 91 | + "kern": { |
| 92 | + "command": "npx", |
| 93 | + "args": ["@daslab/kern", "mcp"] |
| 94 | + } |
| 95 | + } |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +``` |
| 100 | +You: "Set up secrets for this project — I need GitHub OAuth, |
| 101 | + an OpenAI key for testing, and Stripe test credentials" |
| 102 | +
|
| 103 | +Claude → kern_add("oauth/github") |
| 104 | + → browser opens kern's local form |
| 105 | + → you paste client_id + client_secret |
| 106 | + → encrypted into secrets/oauth/github.age |
| 107 | +
|
| 108 | +Claude → kern_add("testing/openai") |
| 109 | + → same flow |
| 110 | + → encrypted into secrets/testing/openai.age |
| 111 | +
|
| 112 | +Claude → kern_add("testing/stripe") |
| 113 | + → same flow |
| 114 | +
|
| 115 | +"3 secrets added. testing/ is scoped to Alice + CI. |
| 116 | + oauth/ is available to all nodes." |
| 117 | +``` |
| 118 | + |
| 119 | +Credentials go: browser form → kern → encrypted vault. The LLM never sees them. |
| 120 | + |
| 121 | +## Nodes |
40 | 122 |
|
41 | | -// shorthand: returns plaintext string |
42 | | -const openaiKey = await vault.get("openai_api_key"); |
| 123 | +A node is any machine with an age keypair — your laptop, CI, a server. Add a `.nodes` file to track them: |
43 | 124 |
|
44 | | -// scoped form — preferred. Plaintext only lives inside the callback. |
45 | | -const result = await vault.with("anthropic_api_key", async (key) => { |
46 | | - return await anthropic.messages.create({ ... }); |
47 | | -}); |
| 125 | +``` |
| 126 | +# secrets/.nodes |
| 127 | +age1abc... Alice owner |
| 128 | +age1def... Production server |
| 129 | +age1ghi... GitHub CI ci |
| 130 | +``` |
| 131 | + |
| 132 | +### Add a teammate |
| 133 | + |
| 134 | +```bash |
| 135 | +# Bob generates his keypair |
| 136 | +kern identity init && kern identity pubkey |
| 137 | +# → age1xyz... |
| 138 | + |
| 139 | +# You add him to the folders he needs |
| 140 | +echo "age1xyz..." >> secrets/.recipients |
| 141 | +echo "age1xyz..." >> secrets/testing/.recipients |
| 142 | +kern secret rewrap |
| 143 | +git commit -am "add Bob" |
48 | 144 |
|
49 | | -// listing, writing, deleting |
50 | | -vault.list(); // ["openai_api_key", ...] |
51 | | -await vault.put("slack_webhook", "https://..."); // re-encrypts to current recipients |
52 | | -vault.delete("old_key"); |
53 | | -const r = await vault.rewrap(); // after editing .recipients |
54 | | -console.log(`rewrapped ${r.rewrapped}`); |
| 145 | +# Bob clones, everything works |
55 | 146 | ``` |
56 | 147 |
|
57 | | -## CLI |
| 148 | +### Add CI |
58 | 149 |
|
59 | 150 | ```bash |
60 | | -# one-time setup per machine |
61 | | -kern identity init # writes ~/.kern/key (age private key) |
62 | | -kern identity pubkey # → "age1..." |
| 151 | +# generate a keypair for CI |
| 152 | +kern identity init --save /tmp/ci-key |
| 153 | +# add to the folders CI needs |
| 154 | +echo "$(kern identity pubkey)" >> secrets/.recipients |
| 155 | +echo "$(kern identity pubkey)" >> secrets/testing/.recipients |
| 156 | +echo "$(kern identity pubkey)" >> secrets/deploy/.recipients |
| 157 | +kern secret rewrap |
| 158 | +# set the one CI secret |
| 159 | +gh secret set KERN_AGE_KEY < /tmp/ci-key |
| 160 | +rm /tmp/ci-key |
| 161 | +``` |
63 | 162 |
|
64 | | -# vault management (in your project) |
65 | | -mkdir secrets/ |
66 | | -echo "age1yourpubkey..." > secrets/.recipients |
67 | | -kern secret add openai_api_key # prompts on stdin |
68 | | -kern secret list |
69 | | -kern secret get openai_api_key # decrypts to stdout |
| 163 | +One secret in CI. Everything else decrypts from git. |
70 | 164 |
|
71 | | -# add a teammate |
72 | | -echo "age1theirpubkey..." >> secrets/.recipients |
73 | | -kern secret rewrap # re-encrypts every secret to current recipients |
| 165 | +### Revoke access |
74 | 166 |
|
75 | | -# rotate a value |
76 | | -kern secret rotate openai_api_key |
| 167 | +```bash |
| 168 | +# remove Bob's key from all .recipients files |
| 169 | +kern recipients remove age1xyz... |
| 170 | +kern secret rewrap |
| 171 | +# rotate any secrets Bob had access to |
| 172 | +kern secret rotate testing/openai |
| 173 | +``` |
| 174 | + |
| 175 | +## Scoping rules |
| 176 | + |
| 177 | +- Each folder can have its own `.recipients` |
| 178 | +- A secret is encrypted to the `.recipients` in its folder |
| 179 | +- If no `.recipients` in a folder, it inherits from the parent |
| 180 | +- Root `.recipients` is the default for everything |
| 181 | + |
| 182 | +This means: |
| 183 | +- Secrets in `infra/` → encrypted to root recipients (everyone) |
| 184 | +- Secrets in `testing/` → encrypted to testing's recipients (dev + CI, not prod) |
| 185 | +- Secrets in `deploy/` → encrypted to deploy's recipients (dev + CI) |
| 186 | + |
| 187 | +No duplication. No complex config. Just folders and recipient files. |
| 188 | + |
| 189 | +## Local form server |
| 190 | + |
| 191 | +```bash |
| 192 | +kern serve |
| 193 | +# → http://localhost:9271 |
| 194 | +``` |
| 195 | + |
| 196 | +Dashboard shows all secrets (names only), all nodes, folder structure. Add, edit, rotate secrets through the browser. Same server that MCP elicitation uses. |
| 197 | + |
| 198 | +## CLI reference |
| 199 | + |
| 200 | +```bash |
| 201 | +kern identity init [--save PATH] # create age keypair |
| 202 | +kern identity pubkey # print public key |
| 203 | + |
| 204 | +kern secret add [FOLDER/]NAME # encrypt and store |
| 205 | +kern secret get [FOLDER/]NAME # decrypt to stdout |
| 206 | +kern secret list # show all names |
| 207 | +kern secret rotate [FOLDER/]NAME # replace a value |
| 208 | +kern secret delete [FOLDER/]NAME # remove |
| 209 | +kern secret rewrap # re-encrypt for current recipients |
| 210 | + |
| 211 | +kern recipients list # show all nodes |
| 212 | +kern recipients remove KEY # remove a node from all folders |
| 213 | + |
| 214 | +kern mcp # start MCP server |
| 215 | +kern serve # start local form + dashboard |
77 | 216 | ``` |
78 | 217 |
|
79 | 218 | ## Environment |
80 | 219 |
|
81 | 220 | | Variable | Purpose | Default | |
82 | 221 | |---|---|---| |
83 | | -| `KORN_AGE_KEY` | private age key, used for decryption | (none) | |
84 | | -| `KORN_VAULT_DIR` | vault root directory | `./secrets` | |
| 222 | +| `KERN_AGE_KEY` | age private key | `~/.kern/key` | |
| 223 | +| `KERN_VAULT_DIR` | vault directory | `./secrets` | |
85 | 224 |
|
86 | | -Production deploys typically set `KORN_AGE_KEY` to the server's own age |
87 | | -private key. Local dev uses the file at `~/.kern/key`. Both work via the |
88 | | -same code path. |
| 225 | +## How it compares |
89 | 226 |
|
90 | | -## Vault layout |
| 227 | +| | .env | SOPS | Vault | 1Password | kern | |
| 228 | +|---|---|---|---|---|---| |
| 229 | +| Encrypted in git | no | yes | no | no | yes | |
| 230 | +| Folder scoping | no | no | yes | yes | yes | |
| 231 | +| No server needed | yes | yes | no | no | yes | |
| 232 | +| TypeScript SDK | no | no | no | no | yes | |
| 233 | +| Agent integration (MCP) | no | no | no | no | yes | |
| 234 | +| Grouped credentials | no | yes | yes | yes | yes | |
91 | 235 |
|
92 | | -``` |
93 | | -secrets/ |
94 | | -├── .recipients plaintext, one age pubkey per line |
95 | | -├── openai_api_key.age |
96 | | -├── anthropic_api_key.age |
97 | | -└── prod/ |
98 | | - ├── .recipients narrower recipient set |
99 | | - └── stripe_live.age |
100 | | -``` |
| 236 | +## Building on kern |
101 | 237 |
|
102 | | -Nearest enclosing `.recipients` applies. The format is canonical age |
103 | | -v1, fully interoperable with the upstream `age` binary. |
| 238 | +kern is the encryption + scoping primitive. Build governance on top: |
104 | 239 |
|
105 | | -## Roadmap |
| 240 | +```typescript |
| 241 | +import { loadIdentityFromHost, openVault } from "@daslab/kern"; |
106 | 242 |
|
107 | | -- **BIP-39 seed derivation** — derive age keys from mnemonic phrases |
108 | | -- **OS Keychain integration** — store the private key in macOS Keychain / Windows Credential Manager |
109 | | -- **Agent grants** — scoped, auditable secret access for sub-agents |
| 243 | +// your platform wraps kern with policy |
| 244 | +const vault = openVault({ identity: await loadIdentityFromHost() }); |
110 | 245 |
|
111 | | -## Testing |
| 246 | +// kern handles: encrypted storage, folder scoping, recipients |
| 247 | +const creds = await vault.get("oauth/github"); |
112 | 248 |
|
113 | | -```bash |
114 | | -bun test ./test/smoke.ts |
| 249 | +// you add: RBAC, approval flows, audit trails, asset-level permissions |
| 250 | +await checkPolicy(user, "oauth/github", "read"); |
| 251 | +await auditLog(user, "oauth/github", "read"); |
115 | 252 | ``` |
116 | 253 |
|
117 | | -10 tests covering identity generation, round-trip, multi-recipient, |
118 | | -nested recipients, rewrap, scoped `with()`, list, delete, missing-key, |
119 | | -path-traversal rejection. |
| 254 | +The folder structure maps naturally to governance scopes — `testing/` is one access tier, `deploy/` is another, `infra/` is another. The primitive is simple; the policy layer is yours. |
120 | 255 |
|
121 | 256 | ## License |
122 | 257 |
|
123 | | -MIT. |
| 258 | +MIT |
0 commit comments