diff --git a/Dockerfile b/Dockerfile index ce25fa0..3c7fc40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ # to an OIDC provider, and nothing else. There is no shell in it: the binary # probes its own health. # -# podman build --format docker -t keeporgd:0.3.0 --build-arg VERSION=0.3.0 . -# docker build -t keeporgd:0.3.0 --build-arg VERSION=0.3.0 . +# podman build --format docker -t keeporgd: --build-arg VERSION= . +# docker build -t keeporgd: --build-arg VERSION= . # # podman needs --format docker, because the OCI image format has no # HEALTHCHECK field and drops it silently. @@ -55,14 +55,21 @@ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifica # KEEPORG_MASTER_KEY, the master key that unwraps the vault keys. They are # separate on purpose: whoever holds both can read every merge vault. # -# Give the master key somewhere durable, one of these two ways: +# Give the master key somewhere durable, one of these three ways: # -# -e KEEPORG_MASTER_KEY="$(head -c 32 /dev/urandom | base64)" (preferred) +# --secret keeporg_master_key -e KEEPORG_MASTER_KEY_FILE=/run/secrets/keeporg_master_key +# -e KEEPORG_MASTER_KEY="$(head -c 32 /dev/urandom | base64)" # -v keeporg-config:/etc/keeporg # -# With neither, the server mints a key into the container's own layer on -# first start and loses it on the next `run`. It says so loudly at startup, -# and merging is all that stops working: clients still open their vaults. +# The first is preferred: the key is never in the process environment and +# never on a disk this container can write. The file may hold base64 or 32 +# raw bytes. Neither environment variable is ever created by the server, so +# a secret that failed to mount is reported instead of being replaced. +# +# With none of the three, the server mints a key into the container's own +# layer on first start and loses it on the next `run`. It says so loudly at +# startup, and merging is all that stops working: clients still open their +# vaults. COPY --from=build --chown=65532:65532 /out/var/lib/keeporg /var/lib/keeporg COPY --from=build --chown=65532:65532 /out/etc/keeporg /etc/keeporg VOLUME ["/var/lib/keeporg", "/etc/keeporg"] diff --git a/README.md b/README.md index ced0f26..ad7528a 100644 --- a/README.md +++ b/README.md @@ -64,15 +64,20 @@ make help # every target Running the server in a container: ```bash +head -c 32 /dev/urandom | base64 > master.key +podman secret create keeporg_master_key master.key podman run -d -p 8765:8765 \ - -e KEEPORG_MASTER_KEY="$(head -c 32 /dev/urandom | base64)" \ + --secret keeporg_master_key \ + -e KEEPORG_MASTER_KEY_FILE=/run/secrets/keeporg_master_key \ -e KEEPORG_ADMIN_PASSWORD=change-me \ -v keeporg-data:/var/lib/keeporg \ keeporgd:latest -addr :8765 -data /var/lib/keeporg -url https://keeporg.example.com ``` Keep that master key: it unwraps the vault keys, and without it the server -can no longer merge. See [packaging/docker/compose.yaml](packaging/docker/compose.yaml). +can no longer merge. `KEEPORG_MASTER_KEY` takes the base64 key directly if +your secret store injects variables instead of files. See +[packaging/docker/compose.yaml](packaging/docker/compose.yaml). ## Documentation diff --git a/cmd/keeporgd/main.go b/cmd/keeporgd/main.go index 4675324..0b2d076 100644 --- a/cmd/keeporgd/main.go +++ b/cmd/keeporgd/main.go @@ -84,8 +84,9 @@ func main() { dataDir = flag.String("data", "", "data directory (overrides the config file)") extURL = flag.String("url", "", "externally reachable base URL (overrides the config file)") masterKey = flag.String("master-key-file", "", - "file holding the base64 master key that wraps the vault keys; "+ - "$"+secret.MasterKeyEnv+" takes precedence over it") + "file holding the master key that wraps the vault keys, created if "+ + "absent; $"+secret.MasterKeyEnv+" and $"+secret.MasterKeyFileEnv+ + " take precedence over it and are never created") showVer = flag.Bool("version", false, "print the version and exit") healthOnly = flag.Bool("healthcheck", false, "probe a running server on this address and exit 0 if it is healthy") diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 49c6b61..adccb94 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -234,11 +234,21 @@ Three different things are encrypted here, and they are worth keeping apart: returned by the API, never logged, and never leaves the process. 3. **Account passwords.** Not stored at all; only PBKDF2-SHA256 verifiers. -The master key comes from `KEEPORG_MASTER_KEY` (base64) or from -`master_key_file`, which defaults to `/etc/keeporg/master.key` and is created -with mode 0600 on first start. Without it the server still runs and still -serves every key-less strategy; only vault creation and merging are refused, -with a line in the log saying so. +The master key is looked for in three places, in order: + +| Source | Shape | Created if absent | +| --- | --- | --- | +| `$KEEPORG_MASTER_KEY` | base64 | no | +| `$KEEPORG_MASTER_KEY_FILE` | a path to a provisioned secret: base64, or 32 raw bytes | no | +| `master_key_file` / `-master-key-file`, default `/etc/keeporg/master.key` | base64 | yes, mode 0600 | + +Only the configured path is ever created, which is what makes a plain +package install work with no setup. The two environment variables name a key +that something else owns, so a missing one is reported rather than replaced: +minting a key in its place would open none of the existing vaults. Without +any of the three the server still runs and still serves every key-less +strategy; only vault creation and merging are refused, with a line in the +log saying so. What this buys and what it does not: @@ -425,9 +435,14 @@ Two directories matter, and the whole point is that they are separate: - `/var/lib/keeporg` — state and blobs. Mount a volume. - `/etc/keeporg` — `server.json` and, by default, the master key. -**Give the master key somewhere durable**, either `KEEPORG_MASTER_KEY` from -your secret store, or `-master-key-file` pointing at a mounted secret, or a -volume on `/etc/keeporg`. A container started with none of those mints a key +**Give the master key somewhere durable.** The best option is a provisioned +secret plus `KEEPORG_MASTER_KEY_FILE` naming where it landed, which is the +convention compose, swarm and Kubernetes all follow: +`--secret keeporg_master_key -e KEEPORG_MASTER_KEY_FILE=/run/secrets/keeporg_master_key`. +The key is then neither in the process environment nor on a disk the +container can write, and the file may hold base64 or 32 raw bytes. +`KEEPORG_MASTER_KEY` and a volume on `/etc/keeporg` also work. A container +started with none of those mints a key into its own layer and loses it on the next `run`. That is survivable but annoying: the vaults still open for their users, and only merging stops. The server says so at startup — diff --git a/internal/secret/keyring.go b/internal/secret/keyring.go index c6d9d0f..5cba776 100644 --- a/internal/secret/keyring.go +++ b/internal/secret/keyring.go @@ -15,37 +15,73 @@ import ( // a vault: it can decrypt a vault, so it holds that vault's key. // // The wrapped secrets live in the server's state document; the key that -// unwraps them does not. It comes from the environment or from a file -// outside the data directory, so a copy of the data directory alone yields -// ciphertext. Whoever holds both holds the vaults. +// unwraps them does not. It comes from the environment, from a secret file +// an orchestrator provisioned, or from a file outside the data directory, +// so a copy of the data directory alone yields ciphertext. Whoever holds +// both holds the vaults. type Keyring struct { aead cipher.AEAD } -// MasterKeyEnv names the environment variable holding a base64 master key, -// which takes precedence over the key file. -const MasterKeyEnv = "KEEPORG_MASTER_KEY" +// Environment variables that supply the master key. Both are read before +// any configured path. +const ( + // MasterKeyEnv holds the base64 key itself. + MasterKeyEnv = "KEEPORG_MASTER_KEY" + // MasterKeyFileEnv holds the path to a file containing it, which is how + // a container runtime or an orchestrator hands over a provisioned + // secret without putting it in the process environment. + MasterKeyFileEnv = "KEEPORG_MASTER_KEY_FILE" +) -// LoadKeyring reads the master key from the environment or from path, -// creating one at path if neither exists. +// keySize is the master key length in bytes. +const keySize = 32 + +// LoadKeyring finds the master key, in this order: +// +// 1. $KEEPORG_MASTER_KEY, the base64 key itself. +// 2. $KEEPORG_MASTER_KEY_FILE, a path to a provisioned secret. It must +// already exist: something else owns that file, so a missing one is a +// misconfiguration to report, not a reason to mint a new key that would +// fail to open any existing vault. +// 3. path, from -master-key-file or master_key_file in the configuration. +// This one is created on first run if it is absent, which is what makes +// a plain package install work with no setup. +// +// A file may hold the key as base64 text, with or without a trailing +// newline, or as exactly 32 raw bytes, because secret stores produce both. func LoadKeyring(path string) (*Keyring, error) { if v := strings.TrimSpace(os.Getenv(MasterKeyEnv)); v != "" { - key, err := base64.StdEncoding.DecodeString(v) + key, err := decodeKey([]byte(v)) if err != nil { - return nil, fmt.Errorf("secret: %s is not valid base64: %w", MasterKeyEnv, err) + return nil, fmt.Errorf("secret: $%s: %w", MasterKeyEnv, err) } return NewKeyring(key) } + + if p := strings.TrimSpace(os.Getenv(MasterKeyFileEnv)); p != "" { + b, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("secret: $%s points at %s: %w", + MasterKeyFileEnv, p, err) + } + key, err := decodeKey(b) + if err != nil { + return nil, fmt.Errorf("secret: $%s file %s: %w", MasterKeyFileEnv, p, err) + } + return NewKeyring(key) + } + if path == "" { - return nil, errors.New("secret: no master key: set " + MasterKeyEnv + - " or configure master_key_file") + return nil, errors.New("secret: no master key: set $" + MasterKeyEnv + + ", $" + MasterKeyFileEnv + ", or configure master_key_file") } b, err := os.ReadFile(path) switch { case err == nil: - key, derr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b))) + key, derr := decodeKey(b) if derr != nil { - return nil, fmt.Errorf("secret: master key file %s is not valid base64: %w", path, derr) + return nil, fmt.Errorf("secret: master key file %s: %w", path, derr) } return NewKeyring(key) case !os.IsNotExist(err): @@ -53,7 +89,7 @@ func LoadKeyring(path string) (*Keyring, error) { } // First run: mint one. It is written with owner-only permissions and is // the one file an operator must back up separately from the data. - key := randBytes(32) + key := randBytes(keySize) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } @@ -64,10 +100,28 @@ func LoadKeyring(path string) (*Keyring, error) { return NewKeyring(key) } +// decodeKey accepts the shapes a provisioned secret arrives in: exactly 32 +// raw bytes, or base64 text with or without padding and trailing newline. +func decodeKey(b []byte) ([]byte, error) { + if len(b) == keySize { + return b, nil + } + text := strings.TrimSpace(string(b)) + if key, err := base64.StdEncoding.DecodeString(text); err == nil { + return key, nil + } + if key, err := base64.RawStdEncoding.DecodeString(text); err == nil { + return key, nil + } + return nil, fmt.Errorf("expected %d raw bytes or a base64 key, got %d bytes "+ + "that are neither", keySize, len(b)) +} + // NewKeyring builds a keyring from a 32-byte key. func NewKeyring(key []byte) (*Keyring, error) { - if len(key) != 32 { - return nil, fmt.Errorf("secret: master key must be 32 bytes, got %d", len(key)) + if len(key) != keySize { + return nil, fmt.Errorf("secret: master key must be %d bytes, got %d", + keySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { diff --git a/internal/secret/keyring_test.go b/internal/secret/keyring_test.go index a9b5ae4..7af36a0 100644 --- a/internal/secret/keyring_test.go +++ b/internal/secret/keyring_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "os" "path/filepath" + "strings" "testing" ) @@ -90,3 +91,140 @@ func TestLoadKeyringRejectsAShortKey(t *testing.T) { t.Fatal("a short master key was accepted") } } + +// wrapWith is a helper: build a keyring from a known key and wrap a secret +// with it, so another keyring can be checked for being the same key. +func wrapWith(t *testing.T, key []byte) string { + t.Helper() + k, err := NewKeyring(key) + if err != nil { + t.Fatal(err) + } + wrapped, err := k.Wrap("v", "secret") + if err != nil { + t.Fatal(err) + } + return wrapped +} + +// sameKey reports whether k is the keyring that produced wrapped. +func sameKey(t *testing.T, k *Keyring, wrapped string) bool { + t.Helper() + got, err := k.Unwrap("v", wrapped) + return err == nil && got == "secret" +} + +func TestMasterKeyFileEnvReadsAProvisionedSecret(t *testing.T) { + key := randBytes(32) + wrapped := wrapWith(t, key) + + for _, tc := range []struct { + name string + content []byte + }{ + {"base64", []byte(base64.StdEncoding.EncodeToString(key))}, + {"base64 with a trailing newline", []byte(base64.StdEncoding.EncodeToString(key) + "\n")}, + {"base64 without padding", []byte(base64.RawStdEncoding.EncodeToString(key))}, + {"32 raw bytes", key}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(path, tc.content, 0o400); err != nil { + t.Fatal(err) + } + t.Setenv(MasterKeyEnv, "") + t.Setenv(MasterKeyFileEnv, path) + k, err := LoadKeyring("") + if err != nil { + t.Fatal(err) + } + if !sameKey(t, k, wrapped) { + t.Fatal("the key read from the secret file is not the one written") + } + }) + } +} + +// A secret that was supposed to be mounted and is not must be reported. A +// new key minted in its place would open none of the existing vaults. +func TestMissingSecretFileIsReportedAndMintsNothing(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "not-mounted") + configured := filepath.Join(dir, "master.key") + t.Setenv(MasterKeyEnv, "") + t.Setenv(MasterKeyFileEnv, missing) + + _, err := LoadKeyring(configured) + if err == nil { + t.Fatal("a missing secret file was accepted") + } + if !strings.Contains(err.Error(), MasterKeyFileEnv) { + t.Fatalf("the error does not name the variable: %v", err) + } + if _, err := os.Stat(configured); !os.IsNotExist(err) { + t.Fatal("a replacement key was minted at the configured path") + } + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Fatal("a key was written where the secret should have been mounted") + } +} + +func TestMasterKeyEnvWinsOverTheSecretFile(t *testing.T) { + envKey, fileKey := randBytes(32), randBytes(32) + wrappedByEnv := wrapWith(t, envKey) + + path := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(path, fileKey, 0o400); err != nil { + t.Fatal(err) + } + t.Setenv(MasterKeyEnv, base64.StdEncoding.EncodeToString(envKey)) + t.Setenv(MasterKeyFileEnv, path) + + k, err := LoadKeyring("") + if err != nil { + t.Fatal(err) + } + if !sameKey(t, k, wrappedByEnv) { + t.Fatal("the secret file took precedence over the environment key") + } +} + +func TestSecretFileWinsOverTheConfiguredPath(t *testing.T) { + secretKey := randBytes(32) + wrapped := wrapWith(t, secretKey) + dir := t.TempDir() + + secretPath := filepath.Join(dir, "provisioned") + if err := os.WriteFile(secretPath, secretKey, 0o400); err != nil { + t.Fatal(err) + } + configured := filepath.Join(dir, "master.key") + if err := os.WriteFile(configured, + []byte(base64.StdEncoding.EncodeToString(randBytes(32))), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(MasterKeyEnv, "") + t.Setenv(MasterKeyFileEnv, secretPath) + + k, err := LoadKeyring(configured) + if err != nil { + t.Fatal(err) + } + if !sameKey(t, k, wrapped) { + t.Fatal("the configured path took precedence over the provisioned secret") + } +} + +func TestSecretFileContentIsValidated(t *testing.T) { + for _, content := range []string{"", "not base64 at all !!", "c2hvcnQ="} { + path := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(path, []byte(content), 0o400); err != nil { + t.Fatal(err) + } + t.Setenv(MasterKeyEnv, "") + t.Setenv(MasterKeyFileEnv, path) + if _, err := LoadKeyring(""); err == nil { + t.Fatalf("content %q was accepted as a master key", content) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 97b8718..dcccc61 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -132,9 +132,10 @@ func (s *Server) checkVaultKeys() { case broken > 0: s.log.Printf("WARNING: %d of %d stored vault keys do not open with this "+ "master key. Those vaults cannot be merged until the original key is "+ - "restored (%s or %s). Nothing is lost: clients still open the vaults "+ - "with their own passwords.", - broken, total, secret.MasterKeyEnv, s.cfg.MasterKeyFile) + "restored ($%s, $%s, or %s). Nothing is lost: clients still open the "+ + "vaults with their own passwords.", + broken, total, secret.MasterKeyEnv, secret.MasterKeyFileEnv, + s.cfg.MasterKeyFile) } } diff --git a/internal/server/vault.go b/internal/server/vault.go index 9126c87..6e3fbed 100644 --- a/internal/server/vault.go +++ b/internal/server/vault.go @@ -18,8 +18,9 @@ import ( const MinVaultPasswordLen = 12 var errNoKeyring = errors.New( - "this server has no master key, so it cannot hold vault keys: set " + - secret.MasterKeyEnv + " or master_key_file and restart") + "this server has no master key, so it cannot hold vault keys: set $" + + secret.MasterKeyEnv + ", $" + secret.MasterKeyFileEnv + + ", or master_key_file, and restart") // createVault makes a new, empty KDBX database, encrypts it with the given // vault password and stores it as version 1. When the strategy needs the diff --git a/packaging/deb/server/changelog b/packaging/deb/server/changelog index 8129cbb..72b0678 100644 --- a/packaging/deb/server/changelog +++ b/packaging/deb/server/changelog @@ -1,3 +1,16 @@ +keeporgd (0.4.0) unstable; urgency=medium + + * Read the master key from the file named by KEEPORG_MASTER_KEY_FILE, the + convention compose, swarm and Kubernetes use for a provisioned secret. + The file may hold base64 or exactly 32 raw bytes. + * Neither master-key environment variable is ever created by the server: + a secret that failed to mount is reported at startup and vault creation + is refused, rather than replaced with a new key that would open none of + the existing vaults. Only the configured master_key_file is created on + first run. + + -- tiredsysadmin Fri, 30 Oct 2026 09:42:50 +0100 + keeporgd (0.3.0) unstable; urgency=medium * Container image: a scratch-based image of about 9 MB holding the static diff --git a/packaging/docker/compose.yaml b/packaging/docker/compose.yaml index 2fa2074..fff8e09 100644 --- a/packaging/docker/compose.yaml +++ b/packaging/docker/compose.yaml @@ -1,24 +1,27 @@ -# A deployment of the KeepOrg server, showing the two things that are easy -# to get wrong: where the master key comes from, and that it does not live -# in the data volume. +# A deployment of the KeepOrg server, showing the thing that is easiest to +# get wrong: where the master key comes from, and that it does not live in +# the data volume. # -# podman-compose up -d (or docker compose up -d) +# head -c 32 /dev/urandom | base64 > master.key # once; keep a copy safe +# chmod 600 master.key +# podman-compose up -d # or docker compose up -d # -# Generate the master key once, and keep a copy somewhere safe: -# head -c 32 /dev/urandom | base64 > master.key -# -# Without it the server still runs and still synchronizes; it only refuses -# to create or merge vaults. +# The key is provisioned as a secret and named to the server with +# KEEPORG_MASTER_KEY_FILE, so it is never in the process environment and +# never on a disk the container can write. Without a key the server still +# runs and still synchronizes; it only refuses to create or merge vaults. services: keeporgd: - # Override with KEEPORG_VERSION; `make docker` tags the version from - # versions.mk and :latest. - image: keeporgd:${KEEPORG_VERSION:-0.3.0} + # `make docker` tags both the version from versions.mk and :latest. + # Pin with KEEPORG_VERSION in production. + image: keeporgd:${KEEPORG_VERSION:-latest} build: context: ../.. args: - VERSION: "${KEEPORG_VERSION:-0.3.0}" + # Only used when compose builds the image itself; `make docker` + # stamps the real version. + VERSION: "${KEEPORG_VERSION:-dev}" restart: unless-stopped # Terminate TLS in front of this, and set external_url to the address a # browser sees: invite links, device approval and the OIDC redirect are @@ -29,23 +32,27 @@ services: - "-url=https://keeporg.example.com" ports: - "127.0.0.1:8765:8765" + secrets: + - keeporg_master_key environment: - # Preferred: the key never touches a disk the container can write. - # Read it from your secret store and pass it in. - KEEPORG_MASTER_KEY: "${KEEPORG_MASTER_KEY:?set KEEPORG_MASTER_KEY, see the comment above}" + KEEPORG_MASTER_KEY_FILE: /run/secrets/keeporg_master_key + # The alternative, when the key comes from a store that injects + # variables rather than files. One or the other, not both. + # KEEPORG_MASTER_KEY: "${KEEPORG_MASTER_KEY:-}" + # # Only read on the very first start, when no accounts exist yet. KEEPORG_ADMIN_PASSWORD: "${KEEPORG_ADMIN_PASSWORD:-}" volumes: - keeporg-data:/var/lib/keeporg - # Only needed instead of KEEPORG_MASTER_KEY above, or to mount a - # server.json. Keep it on different storage from keeporg-data. - # - keeporg-config:/etc/keeporg # The server writes one directory and needs no privileges at all. read_only: true cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] user: "65532:65532" +secrets: + keeporg_master_key: + file: ./master.key + volumes: keeporg-data: - # keeporg-config: diff --git a/versions.mk b/versions.mk index b294924..30090d7 100644 --- a/versions.mk +++ b/versions.mk @@ -1,6 +1,6 @@ # Single source of truth for all platform versions (semver). # Bump with: make bump PLATFORM= PART= -VERSION_SERVER := 0.3.0 +VERSION_SERVER := 0.4.0 VERSION_LINUX := 0.2.0 VERSION_MACOS := 0.2.0 VERSION_WINDOWS := 0.2.0