4eca20bd94
Cold-start unlock wrapped the database key under the credential *public* key.
A public key is public: mavweb writes it verbatim to passkeys.json, normally in
the same state dir as db_key.wrapped, so anyone holding both files recovered the
database key offline with no authenticator involved. The wrapped blob was a
plaintext key with extra steps.
The secret is now the WebAuthn PRF extension output — 32 bytes the authenticator
computes over a fixed salt and never stores anywhere. The blob gains a version:
v2: "MVNKW2\x00" || salt || nonce || AES-256-GCM(key), magic as AAD
v1: salt || nonce || AES-256-GCM(key) (read-only)
v1 still opens so an existing deployment is not bricked, and reports itself so
the daemon can log a SECURITY line telling him to re-enroll. Nothing writes v1.
The magic is authenticated, so a v2 blob cannot be stripped and re-read as v1.
Four other defects on the same path:
- The locked-boot store was opened on an IPC goroutine inside UnlockFn and
never closed. Close is what re-encrypts the tmpfs working copy back over
the ciphertext, so every write of a cold-started session was lost silently
on the next boot. daemonLock now owns the store and seals it at shutdown.
- MethodUnlock was reachable by anything on the box; the socket is same-uid
and cannot authenticate its caller. It now requires a passkey assertion
that mavweb verified first.
- Concurrent unlocks would each open a store and wire a daemon. One at a
time, and never a second one.
- The hand-rolled HKDF keyed the expand step with the salt instead of the
PRK. Replaced with crypto/hkdf.
Key wrapping moves from enrolment to the first assertion, because create() does
not produce a PRF result on most authenticators — only a support flag. An
authenticator without PRF now writes no wrapped file at all rather than one
that looks protected and is not, and the page says so.
Verified: make build, make test. New tests cover the v2 round trip, a wrong
secret, every single-bit tamper, truncation, the v1 downgrade attempt, legacy
v1 reads, non-32-byte and all-zero secrets, the ipc wire field, locked-mode
default-deny, a forged assertion never reaching the unlock path, seal-on-
shutdown after a cold start, and that nothing in the state dir contains the
plaintext key. The PRF round trip against real hardware is a QA step.
Vikunja #14
191 lines
5.4 KiB
Go
191 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
"github.com/kami/maven/internal/webauthn"
|
|
)
|
|
|
|
func randBytes(t *testing.T, n int) []byte {
|
|
t.Helper()
|
|
b := make([]byte, n)
|
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
b[0] |= 1
|
|
return b
|
|
}
|
|
|
|
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
|
|
dl := newDaemonLock(true)
|
|
if !dl.isLocked() {
|
|
t.Fatal("newDaemonLock(true) is not locked")
|
|
}
|
|
dl.unlock(nil)
|
|
if dl.isLocked() {
|
|
t.Fatal("still locked after unlock")
|
|
}
|
|
if newDaemonLock(false).isLocked() {
|
|
t.Fatal("newDaemonLock(false) reports locked")
|
|
}
|
|
}
|
|
|
|
// closeStore must be safe on a daemon that never unlocked and safe twice —
|
|
// shutdown runs it unconditionally.
|
|
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
|
|
dl := newDaemonLock(true)
|
|
if err := dl.closeStore(); err != nil {
|
|
t.Fatalf("closeStore with no store: %v", err)
|
|
}
|
|
if err := dl.closeStore(); err != nil {
|
|
t.Fatalf("second closeStore: %v", err)
|
|
}
|
|
}
|
|
|
|
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
|
|
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
|
|
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
|
|
// the ciphertext file — so every write of a cold-started session vanished.
|
|
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "maven.db")
|
|
tmpfs := filepath.Join(dir, "work")
|
|
key := randBytes(t, 32)
|
|
// Store.Close zeroes the key slice it was handed (encState.key is the
|
|
// caller's backing array), so the next boot needs its own copy — exactly
|
|
// as mavend keeps envKeyBytes separate from the config's key.
|
|
nextBoot := bytes.Clone(key)
|
|
ctx := context.Background()
|
|
|
|
// Cold start: locked, no store.
|
|
dl := newDaemonLock(true)
|
|
|
|
// ... unlock arrives, opens the store and hands it over.
|
|
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
|
if err != nil {
|
|
t.Fatalf("OpenEncrypted: %v", err)
|
|
}
|
|
dl.unlock(st)
|
|
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
|
|
t.Fatalf("WriteNote: %v", err)
|
|
}
|
|
|
|
// Shutdown.
|
|
if err := dl.closeStore(); err != nil {
|
|
t.Fatalf("closeStore: %v", err)
|
|
}
|
|
if err := dl.closeStore(); err != nil {
|
|
t.Fatalf("second closeStore after a real store: %v", err)
|
|
}
|
|
|
|
// Next boot with the same key must see the write.
|
|
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
defer st2.Close()
|
|
notes, err := st2.RecentNotes(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("RecentNotes: %v", err)
|
|
}
|
|
if len(notes) != 1 {
|
|
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
|
|
}
|
|
}
|
|
|
|
// The whole point of the wrapped blob: what sits in the state dir must not let
|
|
// anyone open the database. Nothing written there may contain the key, and the
|
|
// ciphertext must not be readable with a wrong one.
|
|
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "maven.db")
|
|
tmpfs := filepath.Join(dir, "work")
|
|
wrappedPath := filepath.Join(dir, "db_key.wrapped")
|
|
key := randBytes(t, 32)
|
|
secret := randBytes(t, 32)
|
|
ctx := context.Background()
|
|
|
|
blob, err := webauthn.WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
|
|
t.Fatalf("write wrapped key: %v", err)
|
|
}
|
|
|
|
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
|
if err != nil {
|
|
t.Fatalf("OpenEncrypted: %v", err)
|
|
}
|
|
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
|
|
t.Fatalf("WriteNote: %v", err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
// Walk everything in the state dir; none of it may contain the key.
|
|
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return err
|
|
}
|
|
b, rerr := os.ReadFile(p)
|
|
if rerr != nil {
|
|
return nil // unreadable is not a leak
|
|
}
|
|
if bytes.Contains(b, key) {
|
|
t.Errorf("%s contains the plaintext encryption key", p)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
// The wrapped file must have owner-only permissions.
|
|
fi, err := os.Stat(wrappedPath)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
|
t.Errorf("wrapped key file mode = %o, want 600", perm)
|
|
}
|
|
|
|
// A wrong passkey must not open the store.
|
|
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
|
|
t.Fatal("a wrong PRF secret unwrapped the key")
|
|
}
|
|
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
|
|
t.Fatal("the encrypted store opened under a wrong key")
|
|
}
|
|
|
|
// And the right one round-trips back to a readable database.
|
|
got, version, err := webauthn.UnwrapKey(blob, secret)
|
|
if err != nil {
|
|
t.Fatalf("UnwrapKey: %v", err)
|
|
}
|
|
if version != webauthn.BlobV2 {
|
|
t.Errorf("blob version = %v, want v2", version)
|
|
}
|
|
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
|
|
if err != nil {
|
|
t.Fatalf("reopen with the unwrapped key: %v", err)
|
|
}
|
|
defer st2.Close()
|
|
notes, err := st2.RecentNotes(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("RecentNotes: %v", err)
|
|
}
|
|
if len(notes) != 1 {
|
|
t.Fatalf("got %d notes, want 1", len(notes))
|
|
}
|
|
}
|