Files
keeporg/internal/secret/keyring_test.go
T
ReactorcoremeltdownandClaude Opus 5.5 4ffc434b1b Keep server data in an encrypted database, and harden every part
Storage. The server keeps its data in SQLite (a file in the data
directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or
-database). Every row, the KDBX versions included, is sealed with
AES-256-GCM under a key derived from the master key and named by an HMAC.
The master key is required to start and a different one is refused. A data
directory from 0.10 or earlier is imported on first start. Several servers
may share one PostgreSQL database. Go goes to 1.26 for the pure-Go
drivers (modernc.org/sqlite, jackc/pgx).

Hardening, after an audit of the server, both clients and the deployment:
- Single sign-on approves a device only after an explicit Approve in the
  browser that started it, bound by a cookie and PKCE; identities are never
  linked by username, by verified email only when allowed, never to admins.
- Sign-in attempts are limited per address and per account, passwords are
  checked outside the store lock with uniform timing, one-time codes work
  once, and starting two-factor needs the password.
- Merge vaults bound KDF parameters, payload size and XML shape, check
  pushes before storing them, and run at most two merges at once. A version
  is written in the same transaction as its metadata.
- Tokens in URLs only on the event stream, which ends on revocation; CSP,
  frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases
  without access; new trusted_proxies.
- Clients open only the server's approval page, keep case-variant and
  retired file names apart, never overwrite a save made during a pull,
  require https except locally, send the stream token in a header, bound
  their reads and escape notifications and terminal output.
- The .deb creates the master key, systemd is sandboxed further, nginx sets
  modern TLS and HSTS, and CI deploys from main only.

Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:38:32 +01:00

252 lines
7.1 KiB
Go

package secret
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func TestKeyringWrapRoundTrip(t *testing.T) {
k, err := NewKeyring(randBytes(32))
if err != nil {
t.Fatal(err)
}
wrapped, err := k.Wrap("vault-1", "the-vault-password")
if err != nil {
t.Fatal(err)
}
if wrapped == "the-vault-password" {
t.Fatal("the secret was stored in the clear")
}
got, err := k.Unwrap("vault-1", wrapped)
if err != nil || got != "the-vault-password" {
t.Fatalf("unwrap = %q, %v", got, err)
}
}
func TestWrappedSecretIsBoundToItsVault(t *testing.T) {
k, _ := NewKeyring(randBytes(32))
wrapped, _ := k.Wrap("vault-1", "secret")
if _, err := k.Unwrap("vault-2", wrapped); err == nil {
t.Fatal("a secret wrapped for one vault opened under another id")
}
}
func TestAnotherMasterKeyCannotUnwrap(t *testing.T) {
a, _ := NewKeyring(randBytes(32))
b, _ := NewKeyring(randBytes(32))
wrapped, _ := a.Wrap("v", "secret")
if _, err := b.Unwrap("v", wrapped); err == nil {
t.Fatal("a different master key opened the secret")
}
}
func TestLoadKeyringCreatesThenReusesTheKeyFile(t *testing.T) {
t.Setenv(MasterKeyEnv, "")
path := filepath.Join(t.TempDir(), "keys", "master.key")
first, err := LoadKeyring(path)
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("master key file mode = %v", fi.Mode().Perm())
}
wrapped, _ := first.Wrap("v", "secret")
second, err := LoadKeyring(path)
if err != nil {
t.Fatal(err)
}
if got, err := second.Unwrap("v", wrapped); err != nil || got != "secret" {
t.Fatalf("reloaded keyring: %q, %v", got, err)
}
}
func TestLoadKeyringPrefersTheEnvironment(t *testing.T) {
key := randBytes(32)
t.Setenv(MasterKeyEnv, base64.StdEncoding.EncodeToString(key))
path := filepath.Join(t.TempDir(), "master.key")
k, err := LoadKeyring(path)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("a key file was written although the environment supplied the key")
}
direct, _ := NewKeyring(key)
wrapped, _ := direct.Wrap("v", "secret")
if got, _ := k.Unwrap("v", wrapped); got != "secret" {
t.Fatal("the environment key was not the one used")
}
}
func TestLoadKeyringRejectsAShortKey(t *testing.T) {
t.Setenv(MasterKeyEnv, base64.StdEncoding.EncodeToString([]byte("too short")))
if _, err := LoadKeyring(""); err == nil {
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)
}
}
}
func TestDerivedKeysAreSeparate(t *testing.T) {
k, err := NewKeyring(randBytes(32))
if err != nil {
t.Fatal(err)
}
a, b := k.Derive("records"), k.Derive("blobs")
sealed := a.Seal([]byte("row-1"), []byte("hello"))
if got, err := a.Open([]byte("row-1"), sealed); err != nil || string(got) != "hello" {
t.Fatalf("open = %q, %v", got, err)
}
if _, err := b.Open([]byte("row-1"), sealed); err == nil {
t.Fatal("a ciphertext opened under another purpose's key")
}
if _, err := a.Open([]byte("row-2"), sealed); err == nil {
t.Fatal("a ciphertext opened under another row's name")
}
if a.MAC("x") != k.Derive("records").MAC("x") || a.MAC("x") == b.MAC("x") {
t.Fatal("MAC is not deterministic per purpose")
}
}