@coffre/cli 0.0.0 → 0.1.1
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 +13 -3
- package/dist/main.js +1713 -0
- package/dist/templates/node/README.md +108 -0
- package/dist/templates/node/gitignore +5 -0
- package/dist/templates/node/package.json +24 -0
- package/dist/templates/node/pnpm-workspace.yaml +7 -0
- package/dist/templates/node/server.env.example +18 -0
- package/dist/templates/node/src/server.ts +37 -0
- package/dist/templates/node/src/vault.ts +27 -0
- package/dist/templates/node/tsconfig.json +15 -0
- package/dist/templates/node/vault.env.example +16 -0
- package/dist/templates/workers/README.md +137 -0
- package/dist/templates/workers/app/src/worker.ts +33 -0
- package/dist/templates/workers/app/wrangler.jsonc +35 -0
- package/dist/templates/workers/gitignore +3 -0
- package/dist/templates/workers/package.json +27 -0
- package/dist/templates/workers/pnpm-workspace.yaml +14 -0
- package/dist/templates/workers/tsconfig.json +14 -0
- package/dist/templates/workers/vault/src/worker.ts +21 -0
- package/dist/templates/workers/vault/wrangler.jsonc +29 -0
- package/package.json +33 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# coffre on Node
|
|
2
|
+
|
|
3
|
+
Two processes, configured in code:
|
|
4
|
+
|
|
5
|
+
- `src/server.ts`, the server: the API, sign-in, the pages and a job every
|
|
6
|
+
five minutes, on one port. Put a proxy that terminates TLS in front of it.
|
|
7
|
+
- `src/vault.ts`, the vault: the keys, and the members and grants, which it
|
|
8
|
+
keeps in the server's database through a login of its own. It answers
|
|
9
|
+
only the server, on a Unix socket.
|
|
10
|
+
|
|
11
|
+
Run them as two users that share a group, and the process facing the network
|
|
12
|
+
never holds the KEK. For local development, `server.ts` can run the vault
|
|
13
|
+
in its own process instead; see the comment there. Everything below runs
|
|
14
|
+
from this directory, on Node 24 or later.
|
|
15
|
+
|
|
16
|
+
## 1. Settings
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm install
|
|
20
|
+
cp server.env.example server.env
|
|
21
|
+
cp vault.env.example vault.env
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Fill both in: `PUBLIC_URL`, a GitHub OAuth app whose callback is
|
|
25
|
+
`<PUBLIC_URL>/auth/callback/github`, `ROOT_ADMINS`, and the keys from
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
coffre keys
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run it with the CLI you ran `coffre init` with, or as
|
|
32
|
+
`npx @coffre/cli keys`. It prints three keys and the KEK's id, once, and
|
|
33
|
+
keeps no copy. Save its output in your password manager, with the OAuth
|
|
34
|
+
client secret, before anything else. `KEK_ID`, `KEK` and `SIGNING_KEY` go in
|
|
35
|
+
`vault.env`, and `AUDIT_CHAIN_KEY` in `server.env`, so that the server,
|
|
36
|
+
which faces the network, never holds what decrypts a value:
|
|
37
|
+
|
|
38
|
+
- `KEK` decrypts every value. Lose it, and every value is lost.
|
|
39
|
+
- `SIGNING_KEY` signs the vault's log entries and member rows.
|
|
40
|
+
- `AUDIT_CHAIN_KEY` signs the server's log entries, sessions and tokens.
|
|
41
|
+
|
|
42
|
+
Lose either of the last two, and the log stops verifying and everyone is
|
|
43
|
+
locked out.
|
|
44
|
+
|
|
45
|
+
## 2. The database
|
|
46
|
+
|
|
47
|
+
Use one Postgres database with three logins: its owner for migrations,
|
|
48
|
+
`coffre_runtime` for the server, and `coffre_vault_runtime` for the vault.
|
|
49
|
+
As an administrator, connect to the database with `psql` and create the
|
|
50
|
+
runtime logins. `\password` prompts for each password without putting it
|
|
51
|
+
in a SQL statement or shell history:
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
CREATE ROLE coffre_runtime LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
55
|
+
CREATE ROLE coffre_vault_runtime LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
56
|
+
\password coffre_runtime
|
|
57
|
+
\password coffre_vault_runtime
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Migrate as the owner, who must also be able to create and grant roles:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pnpm migrate "postgres://owner:…@db.example.com:5432/coffre"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The migration creates the `coffre_app` and `coffre_vault` group roles and
|
|
67
|
+
grants each login only its group's rights. Only the vault writes members
|
|
68
|
+
and grants; each login appends to the audit log only as itself. Run the
|
|
69
|
+
migration again after every package upgrade, before starting either process.
|
|
70
|
+
|
|
71
|
+
Fill `DATABASE_URL` in each env file with the same host and database, using
|
|
72
|
+
`coffre_runtime` in `server.env` and `coffre_vault_runtime` in `vault.env`.
|
|
73
|
+
URL-encode special characters in passwords. Neither process gets the owner's
|
|
74
|
+
URL. Keep each env file readable only by its process's user (`chmod 600`).
|
|
75
|
+
|
|
76
|
+
For tests and local development only, both URLs may instead name the same
|
|
77
|
+
absolute SQLite file, e.g. `file:/tmp/coffre-local.db`; migrate that URL once.
|
|
78
|
+
SQLite has no database logins or separation of privileges.
|
|
79
|
+
|
|
80
|
+
## 3. Run
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
pnpm vault # first: the server connects to its socket
|
|
84
|
+
pnpm start
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then sign in at `PUBLIC_URL` as a root admin, and from a terminal:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
coffre login https://secrets.example.com
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Point a monitor at `<PUBLIC_URL>/readyz`: it turns red when the audit log
|
|
94
|
+
stops taking writes or the vault stops checkpointing it.
|
|
95
|
+
|
|
96
|
+
Everything coffre keeps is in the database: secrets, members, grants and
|
|
97
|
+
the audit log. Back it up as one, keep the escrowed keys apart from it, and
|
|
98
|
+
follow the [restore runbook](https://github.com/erwinkn/coffre/blob/main/docs/restore.md) to bring it back.
|
|
99
|
+
|
|
100
|
+
`pnpm typecheck` checks the configuration against coffre's types.
|
|
101
|
+
|
|
102
|
+
## Conformance
|
|
103
|
+
|
|
104
|
+
`pnpm conformance` runs the vault and the server on SQLite in a temporary
|
|
105
|
+
directory, signs people in through a stand-in GitHub, and checks what coffre
|
|
106
|
+
must never do: show a value to someone without access, act for another site
|
|
107
|
+
with someone's cookie, keep a removed member in, give a value it did not
|
|
108
|
+
log. Run it after changing this project, and before deploying the change.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "coffre-node",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"vault": "node --env-file=vault.env src/vault.ts",
|
|
10
|
+
"start": "node --env-file=server.env src/server.ts",
|
|
11
|
+
"migrate": "coffre-server migrate",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"conformance": "coffre-conformance node"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@coffre/server": "0.1.1",
|
|
17
|
+
"@coffre/vault": "0.1.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@coffre/conformance": "0.1.1",
|
|
21
|
+
"@types/node": "26.1.1",
|
|
22
|
+
"typescript": "5.9.3"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Do not install a version published in the last 7 days: most malicious
|
|
2
|
+
# releases are yanked within hours, and a service that holds every credential
|
|
3
|
+
# can wait a week. Coffre's own packages are exempt, so a fix to them lands
|
|
4
|
+
# the day it is published; they depend on nothing newer than this allows.
|
|
5
|
+
minimumReleaseAge: 10080
|
|
6
|
+
minimumReleaseAgeExclude:
|
|
7
|
+
- '@coffre/*'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# The server's settings, read by `pnpm start`. Copy to server.env.
|
|
2
|
+
|
|
3
|
+
PORT=3000
|
|
4
|
+
# Where people reach coffre, through the proxy that terminates TLS in front
|
|
5
|
+
# of it: an origin, no path.
|
|
6
|
+
PUBLIC_URL=https://secrets.example.com
|
|
7
|
+
# The vault uses this Postgres database through a different login.
|
|
8
|
+
DATABASE_URL=postgres://coffre_runtime:CHANGE_ME@127.0.0.1:5432/coffre
|
|
9
|
+
# The vault's socket, as in vault.env.
|
|
10
|
+
VAULT_SOCKET=vault.sock
|
|
11
|
+
|
|
12
|
+
# A GitHub OAuth app whose callback is <PUBLIC_URL>/auth/callback/github.
|
|
13
|
+
GITHUB_CLIENT_ID=
|
|
14
|
+
GITHUB_CLIENT_SECRET=
|
|
15
|
+
|
|
16
|
+
# From `coffre keys`, saved in your password manager first: signs the
|
|
17
|
+
# server's log entries, sessions and tokens.
|
|
18
|
+
AUDIT_CHAIN_KEY=
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// coffre's server: the API, sign-in, pages and scheduled job, in one
|
|
2
|
+
// process. It holds no KEK: it asks the vault, a process of its own
|
|
3
|
+
// (src/vault.ts), over a Unix socket. Settings come from server.env.
|
|
4
|
+
import { github, serve, signin } from '@coffre/server/node';
|
|
5
|
+
import { connectVault } from '@coffre/vault/node';
|
|
6
|
+
|
|
7
|
+
function env(name: string): string {
|
|
8
|
+
const value = process.env[name];
|
|
9
|
+
if (!value) throw new Error(`${name} is not set; see server.env.example`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const server = await serve({
|
|
14
|
+
port: Number(env('PORT')),
|
|
15
|
+
publicUrl: env('PUBLIC_URL'),
|
|
16
|
+
database: env('DATABASE_URL'),
|
|
17
|
+
vault: connectVault(env('VAULT_SOCKET')),
|
|
18
|
+
// For tests or local SQLite development only, the vault in this process:
|
|
19
|
+
// vault: await localVault({ database: env('DATABASE_URL'), kek: …, rootAdmins: […], signingKey: … }),
|
|
20
|
+
auth: signin({
|
|
21
|
+
providers: [
|
|
22
|
+
github({
|
|
23
|
+
clientId: env('GITHUB_CLIENT_ID'),
|
|
24
|
+
clientSecret: env('GITHUB_CLIENT_SECRET'),
|
|
25
|
+
// Set for GitHub Enterprise Server; github.com otherwise.
|
|
26
|
+
webUrl: process.env.GITHUB_URL,
|
|
27
|
+
apiUrl: process.env.GITHUB_API_URL,
|
|
28
|
+
}),
|
|
29
|
+
],
|
|
30
|
+
}),
|
|
31
|
+
auditChainKey: env('AUDIT_CHAIN_KEY'),
|
|
32
|
+
});
|
|
33
|
+
console.log(`coffre is listening on ${server.url}`);
|
|
34
|
+
|
|
35
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
36
|
+
process.once(signal, () => void server.close().then(() => process.exit(0)));
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The vault: the keys, and the members and grants, which it keeps in the
|
|
2
|
+
// server's database through a login of its own. It answers only on a Unix
|
|
3
|
+
// socket, which it makes 0660: run it as its own user, sharing a group with
|
|
4
|
+
// the server's, and nothing that faces the network can read the KEK.
|
|
5
|
+
// Settings come from vault.env.
|
|
6
|
+
import { serveVault } from '@coffre/vault/node';
|
|
7
|
+
|
|
8
|
+
function env(name: string): string {
|
|
9
|
+
const value = process.env[name];
|
|
10
|
+
if (!value) throw new Error(`${name} is not set; see vault.env.example`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const vault = await serveVault({
|
|
15
|
+
socket: env('VAULT_SOCKET'),
|
|
16
|
+
database: env('DATABASE_URL'),
|
|
17
|
+
kek: { id: env('KEK_ID'), key: env('KEK') },
|
|
18
|
+
// After a rotation, the KEKs before it, so the data keys they wrapped
|
|
19
|
+
// still open: previousKeks: [{ id: 'kek-1', key: env('KEK_1') }],
|
|
20
|
+
rootAdmins: env('ROOT_ADMINS').split(',').map((email) => email.trim()),
|
|
21
|
+
signingKey: env('SIGNING_KEY'),
|
|
22
|
+
});
|
|
23
|
+
console.log(`the vault is listening on ${vault.socket}`);
|
|
24
|
+
|
|
25
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
26
|
+
process.once(signal, () => void vault.close().then(() => process.exit(0)));
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"types": ["node"],
|
|
6
|
+
"module": "nodenext",
|
|
7
|
+
"moduleResolution": "nodenext",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"erasableSyntaxOnly": true,
|
|
12
|
+
"verbatimModuleSyntax": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# The vault's settings, read by `pnpm vault`. Copy to vault.env, readable by
|
|
2
|
+
# the vault's user alone.
|
|
3
|
+
|
|
4
|
+
VAULT_SOCKET=vault.sock
|
|
5
|
+
# The server's Postgres database, as the vault's own login.
|
|
6
|
+
DATABASE_URL=postgres://coffre_vault_runtime:CHANGE_ME@127.0.0.1:5432/coffre
|
|
7
|
+
|
|
8
|
+
# From `coffre keys`, saved in your password manager first.
|
|
9
|
+
# The key-encryption key, and its name. A rotation gives the new one a new name.
|
|
10
|
+
KEK_ID=kek-1
|
|
11
|
+
KEK=
|
|
12
|
+
# Signs the vault's log entries, checkpoints and member rows.
|
|
13
|
+
SIGNING_KEY=
|
|
14
|
+
|
|
15
|
+
# Comma-separated emails: the first people in, whom nobody can remove.
|
|
16
|
+
ROOT_ADMINS=you@example.com
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# coffre on Cloudflare Workers
|
|
2
|
+
|
|
3
|
+
Two Workers, configured in code:
|
|
4
|
+
|
|
5
|
+
- `app/src/worker.ts`, the app: the API, sign-in, the pages and a Cron job
|
|
6
|
+
every five minutes. It reaches Postgres through Hyperdrive, and the vault
|
|
7
|
+
through a service binding.
|
|
8
|
+
- `vault/src/worker.ts`, the vault: the keys, and the members and grants,
|
|
9
|
+
which it keeps in the same database through a login of its own. It has no
|
|
10
|
+
URL of its own.
|
|
11
|
+
|
|
12
|
+
Settings that are not secret are `vars` in each `wrangler.jsonc`; secrets are
|
|
13
|
+
Worker secrets. Everything below runs from this directory.
|
|
14
|
+
|
|
15
|
+
## 1. Settings
|
|
16
|
+
|
|
17
|
+
- `app/wrangler.jsonc`: `PUBLIC_URL`, and `GITHUB_CLIENT_ID` from a GitHub
|
|
18
|
+
OAuth app whose callback is `<PUBLIC_URL>/auth/callback/github`.
|
|
19
|
+
- `vault/wrangler.jsonc`: `ROOT_ADMINS`, the emails of the first people in,
|
|
20
|
+
and `KEK_ID`, which `coffre keys` gives you below.
|
|
21
|
+
|
|
22
|
+
## 2. The database
|
|
23
|
+
|
|
24
|
+
coffre wants a Postgres database with three logins: its owner, for
|
|
25
|
+
migrations; `coffre_runtime`, which the app runs as; and
|
|
26
|
+
`coffre_vault_runtime`, which the vault runs as. Create the two as plain
|
|
27
|
+
logins. As the database's administrator, connect with `psql` and run:
|
|
28
|
+
|
|
29
|
+
```sql
|
|
30
|
+
CREATE ROLE coffre_runtime LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
31
|
+
CREATE ROLE coffre_vault_runtime LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
32
|
+
\password coffre_runtime
|
|
33
|
+
\password coffre_vault_runtime
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Each `\password` prompts for a different generated password. The owner
|
|
37
|
+
must be able to create and grant roles: the migration creates `coffre_app`
|
|
38
|
+
and `coffre_vault`, and grants each login its own group. Only the vault may
|
|
39
|
+
write members and grants, and each appends to the log only as itself.
|
|
40
|
+
URL-encode special characters in the passwords in these URLs:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pnpm install
|
|
44
|
+
pnpm migrate "postgres://owner:…@db.example.com:5432/coffre"
|
|
45
|
+
pnpm exec wrangler hyperdrive create coffre --caching-disabled \
|
|
46
|
+
--connection-string="postgres://coffre_runtime:…@db.example.com:5432/coffre"
|
|
47
|
+
pnpm exec wrangler hyperdrive create coffre-vault --caching-disabled \
|
|
48
|
+
--connection-string="postgres://coffre_vault_runtime:…@db.example.com:5432/coffre"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Keep `--caching-disabled`: Hyperdrive otherwise caches reads for up to a
|
|
52
|
+
minute, and a revoked token or a signed-out session could keep working that
|
|
53
|
+
long. `wrangler.jsonc` cannot set it, so for a config made another way,
|
|
54
|
+
check `caching` in `wrangler hyperdrive get <id>`.
|
|
55
|
+
|
|
56
|
+
Put the ids they print under `hyperdrive`, the first in
|
|
57
|
+
`app/wrangler.jsonc` and the second in `vault/wrangler.jsonc`. Run
|
|
58
|
+
`pnpm migrate` again after every upgrade of `@coffre/server`, before
|
|
59
|
+
deploying it.
|
|
60
|
+
|
|
61
|
+
## 3. Keys
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
coffre keys
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run it with the CLI you ran `coffre init` with, or as
|
|
68
|
+
`npx @coffre/cli keys`. It prints three keys and the KEK's id, once, and
|
|
69
|
+
keeps no copy. Save its output in your password manager, with the GitHub
|
|
70
|
+
client secret, before anything else. The vault gets `KEK` and `SIGNING_KEY`,
|
|
71
|
+
and the app `AUDIT_CHAIN_KEY`, so that the app, which faces the network,
|
|
72
|
+
never holds what decrypts a value:
|
|
73
|
+
|
|
74
|
+
- `KEK` decrypts every value. Lose it, and every value is lost.
|
|
75
|
+
- `SIGNING_KEY` signs the vault's log entries and member rows.
|
|
76
|
+
- `AUDIT_CHAIN_KEY` signs the app's log entries, sessions and tokens.
|
|
77
|
+
|
|
78
|
+
Lose either of the last two, and the log stops verifying and everyone is
|
|
79
|
+
locked out. Put `KEK_ID` in `vault/wrangler.jsonc`, then set the secrets;
|
|
80
|
+
each command prompts for the saved value:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
pnpm exec wrangler secret put KEK -c vault/wrangler.jsonc
|
|
84
|
+
pnpm exec wrangler secret put SIGNING_KEY -c vault/wrangler.jsonc
|
|
85
|
+
pnpm exec wrangler secret put AUDIT_CHAIN_KEY -c app/wrangler.jsonc
|
|
86
|
+
pnpm exec wrangler secret put GITHUB_CLIENT_SECRET -c app/wrangler.jsonc
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Keep older KEKs after a rotation too: what they wrapped still needs them.
|
|
90
|
+
|
|
91
|
+
## 4. Deploy
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
pnpm run deploy
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
deploys the vault, then the app, which binds to it. Route the app to
|
|
98
|
+
`PUBLIC_URL` in the dashboard, or with `routes` in `app/wrangler.jsonc`, then
|
|
99
|
+
sign in there as a root admin, and from a terminal:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
coffre login https://secrets.example.com
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Point a monitor at `<PUBLIC_URL>/readyz`: it turns red when the audit log
|
|
106
|
+
stops taking writes or the vault stops checkpointing it.
|
|
107
|
+
|
|
108
|
+
Everything coffre keeps is in the database: secrets, members, grants and
|
|
109
|
+
the audit log. Back it up as one, keep the escrowed keys apart from it, and
|
|
110
|
+
follow the [restore runbook](https://github.com/erwinkn/coffre/blob/main/docs/restore.md) to bring it back.
|
|
111
|
+
|
|
112
|
+
## Locally
|
|
113
|
+
|
|
114
|
+
`pnpm dev` runs both Workers with `wrangler dev`. Put local secrets in
|
|
115
|
+
`app/.dev.vars` and `vault/.dev.vars`, and point Hyperdrive at a local
|
|
116
|
+
database with `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE`,
|
|
117
|
+
for the app's login, and
|
|
118
|
+
`CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_VAULT_HYPERDRIVE`, for the
|
|
119
|
+
vault's.
|
|
120
|
+
`pnpm typecheck` checks the configuration against coffre's types, and
|
|
121
|
+
`pnpm build` bundles both Workers without deploying them.
|
|
122
|
+
|
|
123
|
+
## Conformance
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
pnpm conformance \
|
|
127
|
+
--postgres "postgres://owner:…@127.0.0.1:5432" \
|
|
128
|
+
--runtime "postgres://coffre_runtime:…@127.0.0.1:5432" \
|
|
129
|
+
--vault-runtime "postgres://coffre_vault_runtime:…@127.0.0.1:5432"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
runs both Workers under `wrangler dev`, on a database of their own that it
|
|
133
|
+
creates on that Postgres and drops after, signs people in through a
|
|
134
|
+
stand-in GitHub, and checks what coffre must never do: show a value to
|
|
135
|
+
someone without access, act for another site with someone's cookie, keep a
|
|
136
|
+
removed member in, give a value it did not log. Run it after changing this
|
|
137
|
+
project, and before deploying the change.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The app Worker: coffre's API, sign-in, pages and scheduled job. It reaches
|
|
2
|
+
// the database through Hyperdrive and the keys only through the vault
|
|
3
|
+
// Worker's service binding.
|
|
4
|
+
import { coffre, github, postgres, signin, type Vault } from '@coffre/server/cloudflare';
|
|
5
|
+
|
|
6
|
+
type Env = {
|
|
7
|
+
HYPERDRIVE: Hyperdrive;
|
|
8
|
+
VAULT: Vault;
|
|
9
|
+
PUBLIC_URL: string;
|
|
10
|
+
GITHUB_CLIENT_ID: string;
|
|
11
|
+
GITHUB_CLIENT_SECRET: string;
|
|
12
|
+
/** Set for GitHub Enterprise Server; github.com otherwise. */
|
|
13
|
+
GITHUB_URL?: string;
|
|
14
|
+
GITHUB_API_URL?: string;
|
|
15
|
+
AUDIT_CHAIN_KEY: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default coffre((env: Env) => ({
|
|
19
|
+
publicUrl: env.PUBLIC_URL,
|
|
20
|
+
database: postgres(env.HYPERDRIVE),
|
|
21
|
+
vault: env.VAULT,
|
|
22
|
+
auth: signin({
|
|
23
|
+
providers: [
|
|
24
|
+
github({
|
|
25
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
26
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
27
|
+
webUrl: env.GITHUB_URL,
|
|
28
|
+
apiUrl: env.GITHUB_API_URL,
|
|
29
|
+
}),
|
|
30
|
+
],
|
|
31
|
+
}),
|
|
32
|
+
auditChainKey: env.AUDIT_CHAIN_KEY,
|
|
33
|
+
}));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The app Worker. Settings that are not secret are `vars` below; the secrets
|
|
2
|
+
// go in with `wrangler secret put <NAME> -c app/wrangler.jsonc`, and locally
|
|
3
|
+
// in app/.dev.vars.
|
|
4
|
+
{
|
|
5
|
+
"$schema": "../node_modules/wrangler/config-schema.json",
|
|
6
|
+
"name": "coffre",
|
|
7
|
+
"main": "src/worker.ts",
|
|
8
|
+
"compatibility_date": "2026-08-06",
|
|
9
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
10
|
+
"workers_dev": false,
|
|
11
|
+
"observability": { "enabled": true },
|
|
12
|
+
"vars": {
|
|
13
|
+
// Where people reach coffre: an origin, no path. Add it as a route or
|
|
14
|
+
// custom domain too.
|
|
15
|
+
"PUBLIC_URL": "https://secrets.example.com",
|
|
16
|
+
// A GitHub OAuth app whose callback is <PUBLIC_URL>/auth/callback/github.
|
|
17
|
+
"GITHUB_CLIENT_ID": "replace-with-your-client-id"
|
|
18
|
+
},
|
|
19
|
+
"secrets": {
|
|
20
|
+
// AUDIT_CHAIN_KEY is from `coffre keys`; the app never gets the vault's keys.
|
|
21
|
+
"required": ["GITHUB_CLIENT_SECRET", "AUDIT_CHAIN_KEY"]
|
|
22
|
+
},
|
|
23
|
+
// `wrangler hyperdrive create coffre --caching-disabled --connection-string=<runtime login URL>`
|
|
24
|
+
// prints the id. Caching must stay off, or a revoked session keeps working
|
|
25
|
+
// for about a minute, and this file cannot set it: see README.md. Locally,
|
|
26
|
+
// CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE stands in for
|
|
27
|
+
// the config.
|
|
28
|
+
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "replace-with-your-hyperdrive-id" }],
|
|
29
|
+
// The vault Worker, vault/wrangler.jsonc. Deploy it first.
|
|
30
|
+
"services": [{ "binding": "VAULT", "service": "coffre-vault" }],
|
|
31
|
+
// The heartbeat, audit checkpoints and due syncs.
|
|
32
|
+
"triggers": { "crons": ["*/5 * * * *"] },
|
|
33
|
+
// The pages' scripts, styles and fonts, all under /_coffre/assets/.
|
|
34
|
+
"assets": { "directory": "../node_modules/@coffre/ui/dist/client" }
|
|
35
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "coffre-workers",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "wrangler dev -c app/wrangler.jsonc -c vault/wrangler.jsonc",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"build": "wrangler deploy --dry-run -c vault/wrangler.jsonc && wrangler deploy --dry-run -c app/wrangler.jsonc",
|
|
12
|
+
"migrate": "coffre-server migrate",
|
|
13
|
+
"deploy": "wrangler deploy -c vault/wrangler.jsonc && wrangler deploy -c app/wrangler.jsonc",
|
|
14
|
+
"conformance": "coffre-conformance workers"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@coffre/server": "0.1.1",
|
|
18
|
+
"@coffre/ui": "0.1.1",
|
|
19
|
+
"@coffre/vault": "0.1.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@cloudflare/workers-types": "5.20260920.1",
|
|
23
|
+
"@coffre/conformance": "0.1.1",
|
|
24
|
+
"typescript": "5.9.3",
|
|
25
|
+
"wrangler": "4.118.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# wrangler's esbuild and workerd each come with an install script that checks
|
|
2
|
+
# the platform binary it already has as an optional dependency. Neither needs
|
|
3
|
+
# to run, and pnpm wants to be told.
|
|
4
|
+
allowBuilds:
|
|
5
|
+
esbuild: false
|
|
6
|
+
workerd: false
|
|
7
|
+
|
|
8
|
+
# Do not install a version published in the last 7 days: most malicious
|
|
9
|
+
# releases are yanked within hours, and a service that holds every credential
|
|
10
|
+
# can wait a week. Coffre's own packages are exempt, so a fix to them lands
|
|
11
|
+
# the day it is published; they depend on nothing newer than this allows.
|
|
12
|
+
minimumReleaseAge: 10080
|
|
13
|
+
minimumReleaseAgeExclude:
|
|
14
|
+
- '@coffre/*'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"types": ["@cloudflare/workers-types"],
|
|
6
|
+
"module": "esnext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"isolatedModules": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["app/src", "vault/src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The vault Worker: the keys, and the members and grants, which it decides
|
|
2
|
+
// on in the app's database through its own login. It has no route of its
|
|
3
|
+
// own; only the app Worker's service binding reaches it.
|
|
4
|
+
import { postgres, vault } from '@coffre/vault/cloudflare';
|
|
5
|
+
|
|
6
|
+
type Env = {
|
|
7
|
+
VAULT_HYPERDRIVE: Hyperdrive;
|
|
8
|
+
KEK_ID: string;
|
|
9
|
+
KEK: string;
|
|
10
|
+
SIGNING_KEY: string;
|
|
11
|
+
ROOT_ADMINS: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default vault((env: Env) => ({
|
|
15
|
+
database: postgres(env.VAULT_HYPERDRIVE),
|
|
16
|
+
kek: { id: env.KEK_ID, key: env.KEK },
|
|
17
|
+
// After a rotation, the KEKs before it, so the data keys they wrapped
|
|
18
|
+
// still open: previousKeks: [{ id: 'kek-2026-09', key: env.KEK_2026_09 }],
|
|
19
|
+
rootAdmins: env.ROOT_ADMINS.split(',').map((email) => email.trim()),
|
|
20
|
+
signingKey: env.SIGNING_KEY,
|
|
21
|
+
}));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The vault Worker. No route and no workers.dev address: only the app
|
|
2
|
+
// Worker's service binding reaches it. Its secrets go in with
|
|
3
|
+
// `wrangler secret put <NAME> -c vault/wrangler.jsonc`, and locally in
|
|
4
|
+
// vault/.dev.vars.
|
|
5
|
+
{
|
|
6
|
+
"$schema": "../node_modules/wrangler/config-schema.json",
|
|
7
|
+
"name": "coffre-vault",
|
|
8
|
+
"main": "src/worker.ts",
|
|
9
|
+
"compatibility_date": "2026-08-06",
|
|
10
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
11
|
+
"workers_dev": false,
|
|
12
|
+
"observability": { "enabled": true },
|
|
13
|
+
"vars": {
|
|
14
|
+
// Names the KEK below: from `coffre keys`. A rotation gives the new one a new id.
|
|
15
|
+
"KEK_ID": "kek-1",
|
|
16
|
+
// Comma-separated emails: the first people in, whom nobody can remove.
|
|
17
|
+
"ROOT_ADMINS": "you@example.com"
|
|
18
|
+
},
|
|
19
|
+
"secrets": {
|
|
20
|
+
// From `coffre keys`: wrangler secret put KEK, then SIGNING_KEY.
|
|
21
|
+
"required": ["KEK", "SIGNING_KEY"]
|
|
22
|
+
},
|
|
23
|
+
// The app's database, as the vault's own login: `wrangler hyperdrive
|
|
24
|
+
// create coffre-vault --caching-disabled --connection-string=<vault login
|
|
25
|
+
// URL>` prints the id. Its own config, not the app's: the vault writes
|
|
26
|
+
// members and grants, which the app's login may only read. Locally,
|
|
27
|
+
// CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_VAULT_HYPERDRIVE stands in.
|
|
28
|
+
"hyperdrive": [{ "binding": "VAULT_HYPERDRIVE", "id": "replace-with-your-vault-hyperdrive-id" }]
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The coffre command line: secrets in your shell, and `coffre init` for a new deployment.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/erwinkn/coffre.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"coffre": "./dist/main.js"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "26.1.1",
|
|
26
|
+
"tsdown": "0.23.0",
|
|
27
|
+
"typescript": "5.9.3",
|
|
28
|
+
"@coffre/core": "0.1.1",
|
|
29
|
+
"@coffre/client": "0.1.1"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown && node scripts/copy-templates.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "node --conditions=coffre:source --test"
|
|
35
|
+
}
|
|
36
|
+
}
|