Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
# keep — implementation notes
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
Design-stage document, now implemented — kept as the record of *why* the
|
|
|
|
|
design looks the way it does, not just a description of the code.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
## Threat model
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
- The server operator can be assumed hostile-but-honest: they might try
|
|
|
|
|
to read stored secrets, but the design should make that cryptographically
|
|
|
|
|
impossible, not just policy-forbidden.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
- The server **can** see: which vaults exist, which recipients exist,
|
|
|
|
|
which recipients are granted access to which vaults, payload size, and
|
|
|
|
|
access timestamps (who pulled/pushed what, when). It **cannot** see:
|
|
|
|
|
secret values, the symmetric key that encrypts any payload, or any
|
|
|
|
|
recipient's private key.
|
|
|
|
|
- **Not defended against**: a compromised recipient machine. If a
|
|
|
|
|
deploy box with legitimate access to a vault is compromised, the
|
|
|
|
|
attacker gets everything that machine's identity can decrypt — same as
|
|
|
|
|
any secrets manager, self-hosted or not. Revoking that recipient stops
|
|
|
|
|
*future* pulls; it does not retroactively invalidate secrets already
|
|
|
|
|
decrypted and sitting in that machine's memory/disk/logs. **The fix for
|
|
|
|
|
a compromised recipient is always rotating the underlying secrets
|
|
|
|
|
(`keep push` with new values), not just revoking the recipient.** This
|
|
|
|
|
is a standard, correct caveat that applies to every system built this
|
|
|
|
|
way (age, sops, Vault, all of them) — worth stating explicitly because
|
|
|
|
|
it's the single most common misunderstanding of what "revoke" buys you.
|
|
|
|
|
- **Not defended against**: a recipient with legitimate access
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
deliberately exfiltrating secrets they're authorized to read. Access
|
|
|
|
|
control stops unauthorized reads, not authorized-then-malicious ones —
|
|
|
|
|
the same boundary any system like this draws.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
## Crypto scheme
|
|
|
|
|
|
|
|
|
|
Per-vault, adapting the `age`/`sops`/PGP multi-recipient pattern (see
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
README's "Core model") to libsodium primitives:
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
- **Payload encryption**: `crypto_secretbox` (XSalsa20-Poly1305). A fresh
|
|
|
|
|
random 256-bit symmetric key per push.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
- **Key wrapping**: for each recipient granted access, the vault's
|
|
|
|
|
symmetric key is sealed to that recipient's X25519 public key via
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
`crypto_box_seal` — anonymous sealing, since the server has no business
|
|
|
|
|
knowing which specific identity wrapped a copy beyond the recipient it's
|
|
|
|
|
addressed to.
|
|
|
|
|
- **Identity**: Ed25519 signing keypair per client, with the standard
|
|
|
|
|
sign-to-curve25519 conversion (`crypto_sign_ed25519_pk_to_curve25519` /
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
`..._sk_to_curve25519`) to derive the X25519 keypair used for the
|
|
|
|
|
box-sealing above. One identity type, reused for both signing (proving
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
who's pushing/pulling, for the access log) and encryption (wrapping).
|
|
|
|
|
- **Push authentication**: every push is signed by the pusher's Ed25519
|
|
|
|
|
key so the access log records a real identity, not just "someone with
|
|
|
|
|
write access to this vault." Whether *anyone* with any registered
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
identity can push to a vault they're a recipient of, or whether push
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
requires a separate "can write" grant distinct from "can read," was an
|
|
|
|
|
open question at design time — resolved as "read implies write" for v1
|
|
|
|
|
(see Open Questions).
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
## Data model
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
Flat schema — no unnecessary normalization; a vault is identified by its
|
|
|
|
|
own key string, not a foreign-keyed `projects`/`environments` pair:
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE IF NOT EXISTS vaults (
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
vault_key TEXT PRIMARY KEY, -- e.g. "myapp/production" — free-form, app-chosen
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
ciphertext BLOB NOT NULL, -- crypto_secretbox(payload, vault_symmetric_key)
|
|
|
|
|
nonce BLOB NOT NULL,
|
|
|
|
|
updated_at INTEGER NOT NULL,
|
|
|
|
|
updated_by TEXT NOT NULL -- recipient_id of whoever last pushed
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS recipients (
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
recipient_id TEXT PRIMARY KEY, -- short label-derived slug, e.g. "prod-deploy-a1b2c3"
|
|
|
|
|
label TEXT NOT NULL, -- human-readable, e.g. "production deploy key"
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
public_key TEXT NOT NULL, -- hex Ed25519 pubkey
|
|
|
|
|
created_at INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
-- The multi-recipient key-wrapping table. One row per (vault, recipient)
|
|
|
|
|
-- pair that currently has access. Deleting a row IS the revocation —
|
|
|
|
|
-- no separate "enabled" flag needed.
|
|
|
|
|
CREATE TABLE IF NOT EXISTS vault_grants (
|
|
|
|
|
vault_key TEXT NOT NULL REFERENCES vaults(vault_key),
|
|
|
|
|
recipient_id TEXT NOT NULL REFERENCES recipients(recipient_id),
|
|
|
|
|
wrapped_key BLOB NOT NULL, -- crypto_box_seal(vault_symmetric_key, recipient_pubkey)
|
|
|
|
|
granted_at INTEGER NOT NULL,
|
|
|
|
|
PRIMARY KEY (vault_key, recipient_id)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
-- Access log — metadata only, exactly what the threat model says the
|
|
|
|
|
-- server is allowed to know.
|
|
|
|
|
CREATE TABLE IF NOT EXISTS access_log (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
vault_key TEXT NOT NULL,
|
|
|
|
|
recipient_id TEXT NOT NULL,
|
|
|
|
|
action TEXT NOT NULL, -- 'pull' | 'push'
|
|
|
|
|
accessed_at INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
A vault with zero rows in `vault_grants` is unreadable by anyone —
|
|
|
|
|
including, notably, whoever pushed it, unless they granted themselves
|
|
|
|
|
access first. This is intentional: creating a vault and granting access
|
|
|
|
|
to it are separate steps, so a vault can't accidentally be pushed
|
|
|
|
|
"open" to nobody or to a stale recipient list left over from a template.
|
|
|
|
|
|
|
|
|
|
## Rotation
|
|
|
|
|
|
|
|
|
|
`keep push <vault>` with new secret values:
|
|
|
|
|
|
|
|
|
|
1. Generate a fresh random symmetric key.
|
|
|
|
|
2. Encrypt the new payload under it.
|
|
|
|
|
3. Look up every current row in `vault_grants` for this vault.
|
|
|
|
|
4. Re-wrap the new symmetric key for every one of those recipients'
|
|
|
|
|
public keys (already have them via `recipients.public_key`).
|
|
|
|
|
5. Replace `vaults.ciphertext`/`nonce` and all `vault_grants.wrapped_key`
|
|
|
|
|
rows for this vault, atomically (single transaction).
|
|
|
|
|
|
|
|
|
|
The old symmetric key and old wrapped copies are simply gone —
|
|
|
|
|
overwritten, not versioned, in v1 (see Open Questions on whether
|
|
|
|
|
version history is worth adding).
|
|
|
|
|
|
|
|
|
|
## Revocation
|
|
|
|
|
|
|
|
|
|
Deleting one `vault_grants` row (`vault_key`, `recipient_id`) removes
|
|
|
|
|
that recipient's ability to `pull` — the very next pull attempt with
|
|
|
|
|
their identity finds no wrapped-key row and is rejected. No re-encryption
|
|
|
|
|
of the payload needed; every other recipient's access is untouched.
|
|
|
|
|
|
|
|
|
|
As stated in the threat model: this stops *future* pulls only. If
|
|
|
|
|
revocation is happening because a machine may have been compromised,
|
|
|
|
|
follow it with `keep push` to actually rotate the secret values, not just
|
|
|
|
|
the grant.
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
## CLI surface
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
```
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
keep identity init -- generate + persist a local Ed25519 keypair (~/.keep/identity.json)
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
keep identity show -- print this machine's public key, for registering as a recipient
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
keep identity set-id <recipient-id> -- record the id an admin assigned after registering this identity
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
keep push <vault> [--file .env] -- encrypt + upload; requires this identity to already be a grantee
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
keep pull <vault> [--format env|json] [--out <path>] -- fetch + decrypt; requires a grant for this identity
|
|
|
|
|
keep grant <vault> <recipient-id> -- add a new recipient to a vault you already have access to
|
|
|
|
|
keep log <vault> -- tail the access log for one vault
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
# admin operations — gated by ADMIN_PASSWORD, see below
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
keep recipient add --label "..." --pubkey <hex>
|
|
|
|
|
keep recipient list
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
keep recipient remove <recipient-id>
|
|
|
|
|
keep revoke <vault> <recipient-id>
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Admin operations need their own authorization story
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
Adding/removing recipients and revoking vault access is a different trust
|
|
|
|
|
level than pushing/pulling a specific vault you already have a grant for.
|
|
|
|
|
Options considered, in rough order of how much new infrastructure they
|
|
|
|
|
cost:
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
1. **Bootstrap admin identity**: the first recipient ever registered is
|
|
|
|
|
implicitly the admin; admin operations require a signature from that
|
|
|
|
|
identity. Cheapest — no new credential type — but doesn't scale past
|
|
|
|
|
"it's just me" without more thought.
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
2. **Separate `ADMIN_PASSWORD`** — a shared password distinct from any
|
|
|
|
|
recipient identity, gating a small admin HTTP surface. This is what
|
|
|
|
|
got built: same shape as a password-gated admin panel, disabled
|
|
|
|
|
entirely (503) rather than boot-failing when unset.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
3. **Per-vault admin grants** — a `vault_grants.can_admin` boolean
|
|
|
|
|
alongside read access, so admin authority is scoped per-vault instead
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
of global. More correct, more to build; not v1.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
Went with (2) — a global admin password is an acceptable trust model for
|
|
|
|
|
"one person or small team running their own homelab," which is the
|
|
|
|
|
actual scale this is built for.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
## Deploy integration
|
|
|
|
|
|
|
|
|
|
The actual motivating use case — a deploy script pulling secrets instead
|
|
|
|
|
of assuming a hand-copied `.env` already exists on the target machine:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
keep pull myapp/production --format env --out .env
|
|
|
|
|
# ...rest of an existing deploy script, unchanged, now consuming a
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
# freshly-pulled .env instead of one that was manually placed there
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Requires the deploy machine to have a `keep` identity already registered
|
|
|
|
|
and granted access to the relevant vault — a one-time setup step per
|
|
|
|
|
machine (`keep identity init` once, then an admin grants that machine's
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
public key access to whichever vaults it needs). See
|
|
|
|
|
[INTEGRATION.md](./INTEGRATION.md) for where this pattern does and
|
|
|
|
|
doesn't fit an existing deploy setup.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
## Security considerations
|
|
|
|
|
|
|
|
|
|
- Every point already covered under Threat Model applies; this section
|
|
|
|
|
covers implementation-level details that aren't strictly part of the
|
|
|
|
|
threat model but matter for correctness.
|
|
|
|
|
- **Grant/revoke and push must not race.** If a push and a grant/revoke
|
|
|
|
|
happen concurrently, the re-wrap step in `push` (which reads current
|
|
|
|
|
`vault_grants` and re-wraps for all of them) must see a consistent
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
snapshot — the read-grants-then-write-wrapped-keys sequence runs inside
|
|
|
|
|
a single SQLite transaction.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
- **The access log is genuinely useful, not just decorative** — the
|
|
|
|
|
README's whole pitch versus a static `age`-encrypted file in git is
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
"you can tell who actually pulled what, when." `pull` failures
|
|
|
|
|
(wrong/no grant) are logged too, not just successes — a spike of failed
|
|
|
|
|
pulls from an unexpected identity is exactly the kind of signal this
|
|
|
|
|
feature exists to surface.
|
|
|
|
|
- **Recipient private keys are the actual crown jewels.** `keep identity
|
|
|
|
|
init` persists the local keypair at `~/.keep/identity.json`
|
|
|
|
|
(mode `0o600`, directory `0o700`) — not committed anywhere, not
|
|
|
|
|
transmitted anywhere except the public half.
|
|
|
|
|
|
|
|
|
|
## What was verified end-to-end
|
|
|
|
|
|
|
|
|
|
Two independent local identities against a real running server (and
|
|
|
|
|
separately against the built Docker image): registration, a push,
|
|
|
|
|
pulling as the pusher, pulling as an ungranted second identity (correctly
|
|
|
|
|
rejected), granting the second identity access without re-pushing,
|
|
|
|
|
pulling as the now-granted second identity (correctly decrypts the same
|
|
|
|
|
underlying secret via its own sealed key copy), admin revocation, a
|
|
|
|
|
subsequent rotation confirming the revoked identity is excluded from the
|
|
|
|
|
new wrap set, and rejection of both missing and malformed signed-request
|
|
|
|
|
auth.
|
|
|
|
|
|
|
|
|
|
One real implementation bug caught during this process, worth recording:
|
|
|
|
|
Express's `req.path` inside a sub-router is relative to that router's
|
|
|
|
|
mount point (e.g. `/vaults/x/pull` instead of `/api/vaults/x/pull`),
|
|
|
|
|
which would have silently mismatched a client that signs the full request
|
|
|
|
|
path. Fixed by verifying against `req.originalUrl` (path portion only)
|
|
|
|
|
instead, which stays consistent regardless of router nesting.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
|
|
|
|
|
## Open questions
|
|
|
|
|
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
- **Version history**: keep only the latest payload per vault (what got
|
|
|
|
|
built), or retain N previous versions for `keep pull --version=N`
|
|
|
|
|
rollback? Skipped for v1, revisit if a real rollback need shows up.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
- **Push authorization**: does having a *read* grant (`vault_grants` row)
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
imply push rights too, or is push a separate capability? Went with
|
|
|
|
|
"read implies write" for v1 — a `can_write` flag on `vault_grants` is
|
|
|
|
|
the natural extension if that turns out to be wrong.
|
Initial design: self-hosted E2E encrypted secrets sync
README covers the pitch and how this differs from Vault/Doppler (too
heavy) and a static age/sops-encrypted file in git (no live rotation,
no access log). IMPLEMENTATION covers the multi-recipient key-wrapping
scheme (age/sops-style, adapted to libsodium primitives already used
in flit/waste-go), the flat vault/recipient/grant/access-log schema,
rotation and revocation semantics (including the standard "revoke
doesn't retroactively unread already-decrypted secrets" caveat), CLI
surface, and deploy-script integration.
Motivated by a real, already-felt annoyance: every repo in this
project family (goonk, wisp, npm-statuspage, flit, waste-go) has its
own hand-copied .env today, with no rotation story and no record of
which machine has which secret.
No code yet — design stage. Admin auth mechanism flagged as needing a
decision before implementation starts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00
|
|
|
- **Per-secret-key granularity**: v1 treats a vault as one opaque
|
Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping
per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/
access_log. Two auth paths: ADMIN_PASSWORD header for recipient
management and revoke (pure metadata operations), signed-request
auth (Ed25519 signature over method+path+timestamp+body-hash) for
push/pull/grant, mirroring the spirit of this project family's other
signed-handshake patterns without naming them.
CLI: identity init/show/set-id, push/pull/grant/log, admin recipient
add/list/remove and revoke. Grant is a client-side crypto operation
(the granter unwraps the vault's current key locally and reseals it
for the new recipient) rather than a server-side operation, since the
server never holds an unwrapped key to grant with.
Verified end-to-end with two independent local identities against a
live server and separately against the built Docker image: register,
push, pull (granted and ungranted), grant without re-pushing, admin
revoke, a subsequent rotation confirming the revoked recipient stays
excluded, and rejection of missing/malformed signed-request auth.
Two real bugs caught during verification, not just written up:
- libsodium-wrappers' published ESM build does a relative import only
resolvable under bundler-style resolution — broken under plain Node
ESM. Fixed via createRequire to force the CJS build.
- Express's req.path inside a sub-router is relative to the mount
point, which would have silently mismatched a client signing the
full request path. Fixed by verifying against req.originalUrl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:38:24 +02:00
|
|
|
`.env`-shaped blob (matches the actual pain point this was built for:
|
|
|
|
|
whole files get copied around, not individual keys). If a use case
|
|
|
|
|
emerges for "grant access to just one value, not the whole bundle,"
|
|
|
|
|
that's a genuinely different data model (per-key rows, each separately
|
|
|
|
|
wrapped) — don't half-build it speculatively.
|