Skip to content

Commit 330f151

Browse files
committed
kern v0.2 — encrypted secrets in git, scoped by folder, managed by agents
TypeScript SDK + CLI + MCP server + local form for secure credential capture. Built on age encryption. One dependency. MIT.
1 parent c3ebe57 commit 330f151

11 files changed

Lines changed: 745 additions & 116 deletions

File tree

.github/workflows/test.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: oven-sh/setup-bun@v2
15+
with:
16+
bun-version: latest
17+
- run: bun install --frozen-lockfile
18+
- run: bun test ./test/smoke.ts ./test/proxy.test.ts

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
node_modules/
2+
.smoke-tmp/

README.md

Lines changed: 215 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,258 @@
11
# kern
22

3-
Secrets for agents. TypeScript SDK + CLI built on [age](https://age-encryption.org/) encryption.
3+
[![tests](https://github.com/daslabhq/kern/actions/workflows/test.yml/badge.svg)](https://github.com/daslabhq/kern/actions/workflows/test.yml)
44

5-
## Why
5+
Encrypted secrets in git. Scoped by folder. Managed by agents.
66

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+
```
1110

12-
What you get:
11+
## The problem
1312

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.
2214

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.
2418

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
2746
```
2847

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.
3149

32-
## Programmatic API
50+
## Quick start
51+
52+
```bash
53+
# create your identity
54+
kern identity init
3355

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
3572
import { loadIdentityFromHost, openVault } from "@daslab/kern";
3673

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
40122

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:
43124

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"
48144

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
55146
```
56147

57-
## CLI
148+
### Add CI
58149

59150
```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+
```
63162

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.
70164

71-
# add a teammate
72-
echo "age1theirpubkey..." >> secrets/.recipients
73-
kern secret rewrap # re-encrypts every secret to current recipients
165+
### Revoke access
74166

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
77216
```
78217

79218
## Environment
80219

81220
| Variable | Purpose | Default |
82221
|---|---|---|
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` |
85224

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
89226

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 |
91235

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
101237

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:
104239

105-
## Roadmap
240+
```typescript
241+
import { loadIdentityFromHost, openVault } from "@daslab/kern";
106242

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() });
110245

111-
## Testing
246+
// kern handles: encrypted storage, folder scoping, recipients
247+
const creds = await vault.get("oauth/github");
112248

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");
115252
```
116253

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.
120255

121256
## License
122257

123-
MIT.
258+
MIT

0 commit comments

Comments
 (0)