Derive the cold-start unlock key from the passkey PRF, not the public key (#14)
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
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
+70
-14
@@ -66,12 +66,19 @@ import (
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
||||
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
||||
// replaces the CoreAPI with the real store adapter and flips the flag.
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
// owns the store handle the unlock path creates.
|
||||
//
|
||||
// The store matters here because of who runs when. In locked mode there is no
|
||||
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
|
||||
// or days later. Shutdown runs on the main goroutine. Without a handoff the
|
||||
// main goroutine has nothing to close, and store.Close is what re-encrypts
|
||||
// the tmpfs working copy back over the ciphertext file — so a daemon that
|
||||
// cold-started lost every write of that session, silently, on the next boot.
|
||||
type daemonLock struct {
|
||||
mu sync.Mutex
|
||||
locked bool
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
func newDaemonLock(locked bool) *daemonLock {
|
||||
@@ -84,10 +91,25 @@ func (l *daemonLock) isLocked() bool {
|
||||
return l.locked
|
||||
}
|
||||
|
||||
func (l *daemonLock) unlock() {
|
||||
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
|
||||
func (l *daemonLock) unlock(st *store.Store) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.locked = false
|
||||
l.st = st
|
||||
}
|
||||
|
||||
// closeStore seals the store the unlock path opened, if any. Safe to call
|
||||
// when the daemon never unlocked, and safe to call twice.
|
||||
func (l *daemonLock) closeStore() error {
|
||||
l.mu.Lock()
|
||||
st := l.st
|
||||
l.st = nil
|
||||
l.mu.Unlock()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return st.Close()
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -155,6 +177,14 @@ func run(args []string) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
} else {
|
||||
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
|
||||
// opened, at shutdown, on this goroutine.
|
||||
defer func() {
|
||||
if err := dl.closeStore(); err != nil {
|
||||
log.Printf("mavend: seal store on shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
@@ -346,12 +376,16 @@ func run(args []string) error {
|
||||
wireSpeaker(srv, st, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||
// WrapKeyFn — wraps the env key under the passkey PRF secret and persists
|
||||
// the wrapped blob. Only wired when the daemon has the key in memory (env
|
||||
// key mode). Called by mavweb after passkey enrollment.
|
||||
//
|
||||
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||
// an authenticator without PRF support produces no wrapped file at all
|
||||
// rather than a file that looks protected and is not.
|
||||
if envKeyBytes != nil {
|
||||
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
||||
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
||||
srv.WrapKeyFn = func(ctx context.Context, secret []byte) error {
|
||||
blob, err := webauthn.WrapKey(envKeyBytes, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrap encryption key: %w", err)
|
||||
}
|
||||
@@ -367,20 +401,42 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
||||
// blob using the passkey credential public key, opens the store, wires all
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the
|
||||
// wrapped blob using the passkey PRF secret, opens the store, wires all
|
||||
// daemon components, and replaces the locked API.
|
||||
if locked {
|
||||
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
||||
var unlockMu sync.Mutex
|
||||
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
|
||||
// One unlock at a time, and never a second one. Without this a
|
||||
// concurrent pair of Unlock calls would each open a store and
|
||||
// wire a full daemon, and the loser's goroutines would run
|
||||
// against a store nobody closes.
|
||||
unlockMu.Lock()
|
||||
defer unlockMu.Unlock()
|
||||
if !dl.isLocked() {
|
||||
return nil // already unlocked; the caller does not need to know
|
||||
}
|
||||
|
||||
// The wire cannot authenticate its caller — the socket is
|
||||
// same-uid — so the unlock path requires a passkey assertion
|
||||
// that mavweb verified cryptographically first. Without this,
|
||||
// MethodUnlock is reachable by anything on the box.
|
||||
if !passkeySess.IsStepUp() {
|
||||
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||
}
|
||||
|
||||
wp := *wrappedKeyPath
|
||||
blob, err := os.ReadFile(wp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
key, err := webauthn.UnwrapKey(blob, publicKey)
|
||||
key, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unwrap key: %w", err)
|
||||
}
|
||||
if version == webauthn.BlobV1 {
|
||||
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Re-enroll the passkey on an authenticator that supports the PRF extension to rewrite it as v2.", wp, version)
|
||||
}
|
||||
// Open the store with the unwrapped key.
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||
if err != nil {
|
||||
@@ -543,7 +599,7 @@ func run(args []string) error {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
dl.unlock(st)
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user