From 4eca20bd940a804f19ac5e4ba40f513bd54c4183 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 05:49:27 +0400 Subject: [PATCH] Derive the cold-start unlock key from the passkey PRF, not the public key (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/mavend/coldstart_test.go | 190 +++++++++++++++++++ cmd/mavend/main.go | 84 +++++++-- cmd/mavweb/passkey_prf_test.go | 293 ++++++++++++++++++++++++++++++ cmd/mavweb/webauthn.go | 85 +++++---- internal/ipc/api.go | 17 +- internal/ipc/client.go | 12 +- internal/ipc/server.go | 22 +-- internal/ipc/unlock_test.go | 124 +++++++++++++ internal/webauthn/keywrap.go | 259 ++++++++++++++++---------- internal/webauthn/keywrap_test.go | 231 +++++++++++++++++++++++ internal/webauthn/prf.go | 70 +++++++ internal/webauthn/prf_test.go | 114 ++++++++++++ internal/webauthn/webauthn.go | 16 ++ 13 files changed, 1349 insertions(+), 168 deletions(-) create mode 100644 cmd/mavend/coldstart_test.go create mode 100644 cmd/mavweb/passkey_prf_test.go create mode 100644 internal/ipc/unlock_test.go create mode 100644 internal/webauthn/keywrap_test.go create mode 100644 internal/webauthn/prf.go create mode 100644 internal/webauthn/prf_test.go diff --git a/cmd/mavend/coldstart_test.go b/cmd/mavend/coldstart_test.go new file mode 100644 index 0000000..67f5c9f --- /dev/null +++ b/cmd/mavend/coldstart_test.go @@ -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)) + } +} diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 5c0b64d..0702ac0 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -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 } diff --git a/cmd/mavweb/passkey_prf_test.go b/cmd/mavweb/passkey_prf_test.go new file mode 100644 index 0000000..4c5c95d --- /dev/null +++ b/cmd/mavweb/passkey_prf_test.go @@ -0,0 +1,293 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/kami/maven/internal/webauthn" +) + +const prfTestOrigin = "https://maven.test" +const prfTestRPID = "maven.test" + +// fakeKeyIPC stands in for the mavend socket and records exactly what secret +// each call received — the point of the whole test file is that it is the PRF +// output and never the credential public key. +type fakeKeyIPC struct { + unlockSecret []byte + wrapSecret []byte + unlockCalls int + wrapCalls int + unlockErr error +} + +func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error { + f.unlockCalls++ + f.unlockSecret = bytes.Clone(secret) + return f.unlockErr +} + +func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error { + f.wrapCalls++ + f.wrapSecret = bytes.Clone(secret) + return nil +} + +func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// prfAuthenticator is a minimal software authenticator: a P-256 key plus the +// COSE encoding of its public half. +type prfAuthenticator struct { + key *ecdsa.PrivateKey + credID []byte + cose []byte +} + +func newPRFAuthenticator(t *testing.T) *prfAuthenticator { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + x := key.PublicKey.X.FillBytes(make([]byte, 32)) + y := key.PublicKey.Y.FillBytes(make([]byte, 32)) + // COSE_Key: {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y} + var c []byte + c = append(c, 0xa5) // map(5) + c = append(c, 0x01, 0x02) // 1: 2 + c = append(c, 0x03, 0x26) // 3: -7 + c = append(c, 0x20, 0x01) // -1: 1 + c = append(c, 0x21, 0x58, 0x20) // -2: bytes(32) + c = append(c, x...) + c = append(c, 0x22, 0x58, 0x20) // -3: bytes(32) + c = append(c, y...) + return &prfAuthenticator{key: key, credID: []byte("prf-cred"), cose: c} +} + +func (a *prfAuthenticator) authData(flags byte, counter uint32, attested bool) []byte { + h := sha256.Sum256([]byte(prfTestRPID)) + d := append([]byte{}, h[:]...) + d = append(d, flags) + cb := make([]byte, 4) + binary.BigEndian.PutUint32(cb, counter) + d = append(d, cb...) + if attested { + d = append(d, make([]byte, 16)...) // aaguid + l := make([]byte, 2) + binary.BigEndian.PutUint16(l, uint16(len(a.credID))) + d = append(d, l...) + d = append(d, a.credID...) + d = append(d, a.cose...) + } + return d +} + +func clientDataJSON(typ, challenge string) []byte { + b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": prfTestOrigin}) + return b +} + +// register drives POST /register/finish with a valid attestation. +func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) { + t.Helper() + _, chal, err := h.rp.CreationOptions([]byte("u"), "user") + if err != nil { + t.Fatalf("CreationOptions: %v", err) + } + // {"fmt":"none","attStmt":{},"authData":} + att := []byte{0xa3} + att = append(att, 0x63, 'f', 'm', 't', 0x64, 'n', 'o', 'n', 'e') + att = append(att, 0x67, 'a', 't', 't', 'S', 't', 'm', 't', 0xa0) + ad := a.authData(1<<6|0x05, 0, true) + att = append(att, 0x68, 'a', 'u', 't', 'h', 'D', 'a', 't', 'a') + att = append(att, 0x59, byte(len(ad)>>8), byte(len(ad))) + att = append(att, ad...) + + body, _ := json.Marshal(map[string]any{ + "challenge": chal, + "credential": map[string]any{ + "id": b64u(a.credID), + "type": "public-key", + "response": map[string]any{ + "clientDataJSON": b64u(clientDataJSON("webauthn.create", chal)), + "attestationObject": b64u(att), + }, + }, + }) + w := httptest.NewRecorder() + h.RegisterFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/register/finish", bytes.NewReader(body))) + if w.Code != http.StatusOK { + t.Fatalf("RegisterFinish: %d %s", w.Code, w.Body.String()) + } +} + +// assert drives POST /assert/finish with a valid assertion and the given +// base64url PRF result. +func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder { + t.Helper() + _, chal, err := h.rp.AssertionOptions() + if err != nil { + t.Fatalf("AssertionOptions: %v", err) + } + ad := a.authData(0x05, 7, false) + cdj := clientDataJSON("webauthn.get", chal) + hash := sha256.Sum256(cdj) + sig, err := ecdsa.SignASN1(rand.Reader, a.key, append(append([]byte{}, ad...), hash[:]...)) + if err != nil { + t.Fatalf("sign: %v", err) + } + body, _ := json.Marshal(map[string]any{ + "challenge": chal, + "prf": prf, + "credential": map[string]any{ + "id": b64u(a.credID), + "type": "public-key", + "response": map[string]any{ + "clientDataJSON": b64u(cdj), + "authenticatorData": b64u(ad), + "signature": b64u(sig), + }, + }, + }) + w := httptest.NewRecorder() + h.AssertFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/assert/finish", bytes.NewReader(body))) + return w +} + +func newPRFHandle(t *testing.T, key *fakeKeyIPC) *PasskeyHandle { + t.Helper() + store, err := newCredentialStore(filepath.Join(t.TempDir(), "passkeys.json")) + if err != nil { + t.Fatalf("credential store: %v", err) + } + return &PasskeyHandle{ + rp: webauthn.NewRP(webauthn.Config{Origin: prfTestOrigin, RPID: prfTestRPID, RPName: "maven"}), + encryptFn: key, + store: store, + session: webauthn.NewPasskeySession(0), + } +} + +// The fix for Vikunja #14: what goes over IPC is the PRF secret from the +// authenticator, not the credential public key sitting in passkeys.json. +func TestAssertSendsPRFSecretNotPublicKey(t *testing.T) { + key := &fakeKeyIPC{} + h := newPRFHandle(t, key) + auth := newPRFAuthenticator(t) + auth.register(t, h) + + // Enrolment must not wrap anything: create() yields no PRF result. + if key.wrapCalls != 0 || key.unlockCalls != 0 { + t.Fatalf("registration touched the key IPC (wrap=%d unlock=%d)", key.wrapCalls, key.unlockCalls) + } + + secret := make([]byte, 32) + for i := range secret { + secret[i] = byte(i + 1) + } + if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK { + t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) + } + + if key.unlockCalls != 1 || key.wrapCalls != 1 { + t.Fatalf("unlock=%d wrap=%d, want 1 and 1", key.unlockCalls, key.wrapCalls) + } + if !bytes.Equal(key.unlockSecret, secret) { + t.Errorf("Unlock got %x, want the PRF secret %x", key.unlockSecret, secret) + } + if !bytes.Equal(key.wrapSecret, secret) { + t.Errorf("StoreEncryptionKey got %x, want the PRF secret %x", key.wrapSecret, secret) + } + // And explicitly: not the credential public key. + pub, _, err := h.store.Lookup(b64u(auth.credID)) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if bytes.Equal(key.unlockSecret, pub) { + t.Fatal("the credential public key was sent as the unlock secret") + } +} + +// An authenticator without PRF must produce no unlock attempt at all — the +// assertion still succeeds (step-up works), but cold-start unlock stays off +// rather than falling back to something weaker. +func TestAssertWithoutPRFDoesNotUnlock(t *testing.T) { + for _, prf := range []string{"", "!!!not-base64!!!", b64u(make([]byte, 32)), b64u(make([]byte, 16))} { + key := &fakeKeyIPC{} + h := newPRFHandle(t, key) + auth := newPRFAuthenticator(t) + auth.register(t, h) + + w := auth.assert(t, h, prf) + if w.Code != http.StatusOK { + t.Fatalf("prf=%q: AssertFinish %d %s", prf, w.Code, w.Body.String()) + } + if key.unlockCalls != 0 || key.wrapCalls != 0 { + t.Errorf("prf=%q: unlock=%d wrap=%d, want no key IPC at all", prf, key.unlockCalls, key.wrapCalls) + } + } +} + +// A failed unlock must not fail the assertion: step-up is independently valid, +// and a locked daemon degrades rather than breaking the login. +func TestAssertSucceedsWhenUnlockFails(t *testing.T) { + key := &fakeKeyIPC{unlockErr: errors.New("wrong credential")} + h := newPRFHandle(t, key) + auth := newPRFAuthenticator(t) + auth.register(t, h) + + secret := bytes.Repeat([]byte{3}, 32) + if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK { + t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) + } + if key.unlockCalls != 1 { + t.Errorf("unlock attempted %d times, want 1", key.unlockCalls) + } +} + +// A forged assertion must never reach the unlock path. +func TestForgedAssertionNeverUnlocks(t *testing.T) { + key := &fakeKeyIPC{} + h := newPRFHandle(t, key) + auth := newPRFAuthenticator(t) + auth.register(t, h) + + // A different key signing over the same credential id. + attacker := newPRFAuthenticator(t) + attacker.credID = auth.credID + w := attacker.assert(t, h, b64u(bytes.Repeat([]byte{4}, 32))) + if w.Code == http.StatusOK { + t.Fatal("an assertion signed by the wrong key was accepted") + } + if key.unlockCalls != 0 || key.wrapCalls != 0 { + t.Fatalf("a forged assertion reached the key IPC (unlock=%d wrap=%d)", key.unlockCalls, key.wrapCalls) + } +} + +// The browser side is the only place the PRF result exists. If the page stops +// asking for it or stops reading it back, cold-start unlock silently dies with +// nothing failing, so the page source is asserted directly. +func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) { + for _, want := range []string{ + "getClientExtensionResults", + "ext.prf.results.first", + "body:JSON.stringify({challenge,prf,", + } { + if !strings.Contains(passkeyPageHTML, want) { + t.Errorf("the passkey page no longer contains %q", want) + } + } +} diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index b3493fc..8192d9f 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -23,8 +23,8 @@ type assertIPC interface { // is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil, // StoreEncryptionKey and Unlock are silently skipped. type keyIPC interface { - StoreEncryptionKey(ctx context.Context, publicKey []byte) error - Unlock(ctx context.Context, publicKey []byte) error + StoreEncryptionKey(ctx context.Context, secret []byte) error + Unlock(ctx context.Context, secret []byte) error } // PasskeyHandle holds the WebAuthn relying party, a local in-memory credential @@ -102,17 +102,31 @@ async function enroll(){try{ const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'}, body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{ clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})}); - say(r.ok?'enrolled ✓':'enroll failed: '+await r.text(),r.ok); + if(!r.ok){say('enroll failed: '+await r.text(),false);return;} + // The wrapped key can only be written from an assertion: PRF results are + // not produced at create() time on most authenticators. Enrolment reports + // whether PRF is available at all so he is not told cold-start works when + // it cannot. + const ext=c.getClientExtensionResults?c.getClientExtensionResults():{}; + const prfOK=!!(ext.prf&&ext.prf.enabled); + say(prfOK?'enrolled ✓ — now assert once to write the cold-start key': + 'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true); }catch(e){say('enroll error: '+e,false);}} async function assert(){try{ const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json(); options.challenge=ub64(options.challenge); const c=await navigator.credentials.get({publicKey:options}); + // The PRF result is the cold-start secret. It never touches localStorage + // and is posted once, over the same request as the assertion. + const ext=c.getClientExtensionResults?c.getClientExtensionResults():{}; + const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):''; const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'}, - body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{ + body:JSON.stringify({challenge,prf,credential:{id:c.id,type:c.type,response:{ clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData), signature:b64u(c.response.signature)}}})}); - say(r.ok?'stepped up ✓ — enable tools now':'assert failed: '+await r.text(),r.ok); + if(!r.ok){say('assert failed: '+await r.text(),false);return;} + say(prf?'stepped up ✓ — enable tools now': + 'stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true); }catch(e){say('assert error: '+e,false);}} ` @@ -140,9 +154,7 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } - var enrolledPublicKey []byte save := func(id string, publicKey []byte, _ []byte, _ string) error { - enrolledPublicKey = publicKey return h.store.Save(id, publicKey) } credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential) @@ -153,19 +165,15 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) { } log.Printf("webauthn: registered credential %s", credID) - // If mavend is reachable and supports key wrapping, store the encryption - // key wrapped with this credential's public key — enables cold-start unlock. - if h.encryptFn != nil && enrolledPublicKey != nil { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil { - log.Printf("webauthn: store encryption key: %v", err) - // Non-fatal: enrollment still succeeded, the wrapped key can be - // created later via the same endpoint. - } else { - log.Printf("webauthn: encryption key wrapped with credential %s", credID) - } - } + // Note what does NOT happen here: the encryption key is not wrapped at + // enrolment. Wrapping needs the authenticator's PRF output, and create() + // does not produce one on most authenticators — it only reports whether + // the extension is supported. The wrapped key is written on the first + // assertion instead (see AssertFinish). + // + // This used to wrap the key under the credential *public* key, which is + // written to passkeys.json next to the wrapped blob. See the header of + // internal/webauthn/keywrap.go. json.NewEncoder(w).Encode(map[string]string{"credential_id": credID}) } @@ -189,6 +197,11 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) { var body struct { Challenge string `json:"challenge"` Credential map[string]any `json:"credential"` + // PRF is the base64url WebAuthn PRF output the browser read out of + // getClientExtensionResults(). Empty when the authenticator has no + // PRF extension: cold-start unlock is then unavailable and we say so + // rather than falling back to something weaker. + PRF string `json:"prf"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) @@ -222,26 +235,32 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) { } } - // If the daemon is locked (cold-start), send the credential's public key - // over IPC so mavend can unwrap its encryption key and open the store. - // The public key comes from the local credential store (it was stored - // during enrollment). Non-fatal: if IPC doesn't support Unlock or the - // daemon is already unlocked, the call is a no-op on the server side. + // Cold-start unlock and key wrapping, both keyed on the PRF secret that + // this assertion just produced. The secret is used here and dropped; it is + // never stored on this side. + // + // Order matters: unlock first (if the daemon is locked there is nothing to + // wrap yet), then re-wrap, which writes the blob on the first assertion + // after enrolment and is a harmless rewrite afterwards. Both are + // best-effort — the assertion itself is valid either way. if h.encryptFn != nil { - publicKey, _, err := h.store.Lookup(credID) - if err == nil && publicKey != nil { + secret, err := webauthn.DecodePRFResult(body.PRF) + switch { + case err != nil: + log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err) + default: ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - if err := h.encryptFn.Unlock(ctx, publicKey); err != nil { + if err := h.encryptFn.Unlock(ctx, secret); err != nil { log.Printf("webauthn: unlock via credential %s: %v", credID, err) - // Non-fatal: assertion succeeded; if the daemon stays locked - // the user will see errors on subsequent pages, but the - // assertion itself is valid. } else { log.Printf("webauthn: daemon unlocked via credential %s", credID) } - } else if err != nil { - log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err) + if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil { + log.Printf("webauthn: wrap encryption key: %v", err) + } else { + log.Printf("webauthn: encryption key wrapped for credential %s", credID) + } } } diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 233841a..7c43b5f 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -729,17 +729,22 @@ type DayPlan struct { Spoken string `json:"spoken"` } -// storeEncryptionKeyReq — passkey credential public key for wrapping the store +// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store // encryption key at enrollment time. Called by mavweb after RegisterFinish. +// +// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key. +// The field used to carry the public key and that was the bug: a public key +// sits in passkeys.json next to the wrapped blob, so the blob protected +// nothing. See internal/webauthn/keywrap.go. type storeEncryptionKeyReq struct { - PublicKey []byte `json:"public_key"` + Secret []byte `json:"secret"` } -// unlockReq — passkey credential public key for unwrapping the store -// encryption key at cold-start. mavend reads the wrapped blob from its own -// configured path; the public key is the other half needed for unwrapping. +// unlockReq — the passkey-derived secret for unwrapping the store encryption +// key at cold-start. mavend reads the wrapped blob from its own configured +// path; this is the other half. Same PRF-output contract as above. type unlockReq struct { - PublicKey []byte `json:"public_key"` + Secret []byte `json:"secret"` } // ErrToolNotFound — no tool row with this name (re-exported store sentinel for diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 6fb5302..ad48f3b 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -393,12 +393,16 @@ func (c *Client) AssertStepUp(ctx context.Context) error { return c.call(ctx, MethodAssertStepUp, nil, nil) } -func (c *Client) StoreEncryptionKey(ctx context.Context, publicKey []byte) error { - return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{PublicKey: publicKey}, nil) +// StoreEncryptionKey wraps the daemon's at-rest key under secret, the 32-byte +// WebAuthn PRF output for the freshly enrolled credential. +func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte) error { + return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret}, nil) } -func (c *Client) Unlock(ctx context.Context, publicKey []byte) error { - return c.call(ctx, MethodUnlock, unlockReq{PublicKey: publicKey}, nil) +// Unlock hands the daemon the PRF secret so it can unwrap its at-rest key and +// open the store. Refused unless a passkey assertion was verified first. +func (c *Client) Unlock(ctx context.Context, secret []byte) error { + return c.call(ctx, MethodUnlock, unlockReq{Secret: secret}, nil) } func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 19aba8b..dce43bb 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -420,8 +420,8 @@ type Server struct { // MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor). StepUp StepUpFunc - // WrapKeyFn — wraps the in-memory store encryption key with a passkey - // credential public key (HKDF-AESGCM) and writes the wrapped blob to disk. + // WrapKeyFn — wraps the in-memory store encryption key under the passkey + // PRF secret (HKDF-AESGCM) and writes the wrapped blob to disk. // Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod. WrapKeyFn WrapKeyFunc @@ -481,7 +481,7 @@ type Server struct { ForgetSpeakerFn ForgetSpeakerFunc // UnlockFn — unwraps the store encryption key from the wrapped blob using - // the passkey credential public key, opens the encrypted store, and wires + // the passkey PRF secret, opens the encrypted store, and wires // the rest of the daemon (voice, loop, delivery). Set by the daemon when // in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod. UnlockFn UnlockFunc @@ -490,13 +490,13 @@ type Server struct { // absolute ts supplied by callers, so this isn't load-bearing for live ops. } -// WrapKeyFunc — wraps the store encryption key with the given credential -// public key and persists the wrapped blob. -type WrapKeyFunc func(ctx context.Context, publicKey []byte) error +// WrapKeyFunc — wraps the store encryption key under the passkey-derived +// secret (a 32-byte WebAuthn PRF output) and persists the wrapped blob. +type WrapKeyFunc func(ctx context.Context, secret []byte) error -// UnlockFunc — unwraps the store encryption key using the given credential -// public key and completes daemon initialization. -type UnlockFunc func(ctx context.Context, publicKey []byte) error +// UnlockFunc — unwraps the store encryption key using the passkey-derived +// secret and completes daemon initialization. +type UnlockFunc func(ctx context.Context, secret []byte) error // SwapModelFunc — loads another resident model in place of the live one. type SwapModelFunc func(ctx context.Context, req SwapModelReq) (SwapModelResp, error) @@ -929,7 +929,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er if err := unmarshalParams(req.Params, &p); err != nil { return nil, err } - return marshalResult(nil), s.WrapKeyFn(ctx, p.PublicKey) + return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret) } return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) @@ -939,7 +939,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er if err := unmarshalParams(req.Params, &p); err != nil { return nil, err } - return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey) + return marshalResult(nil), s.UnlockFn(ctx, p.Secret) } return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) diff --git a/internal/ipc/unlock_test.go b/internal/ipc/unlock_test.go new file mode 100644 index 0000000..14bdb2e --- /dev/null +++ b/internal/ipc/unlock_test.go @@ -0,0 +1,124 @@ +package ipc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" +) + +// The wire must carry the PRF secret, not the credential public key. This is +// the field rename that fixes Vikunja #14: a v1 deployment sent "public_key", +// and the value it sent was in passkeys.json next to the wrapped blob. +func TestUnlockWireCarriesSecret(t *testing.T) { + secret := bytes.Repeat([]byte{7}, 32) + for _, p := range []any{unlockReq{Secret: secret}, storeEncryptionKeyReq{Secret: secret}} { + b, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal %T: %v", p, err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal %T: %v", p, err) + } + if _, ok := m["secret"]; !ok { + t.Errorf("%T has no \"secret\" field: %s", p, b) + } + if _, ok := m["public_key"]; ok { + t.Errorf("%T still sends \"public_key\": %s", p, b) + } + } +} + +// The secret must reach the daemon hook byte-for-byte through the socket. +func TestUnlockDeliversSecretToHook(t *testing.T) { + _, srv, cli, _ := newServerWithStore(t) + + secret := make([]byte, 32) + for i := range secret { + secret[i] = byte(i + 1) + } + var gotUnlock, gotWrap []byte + srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil } + srv.WrapKeyFn = func(_ context.Context, s []byte) error { gotWrap = bytes.Clone(s); return nil } + + ctx := context.Background() + if err := cli.Unlock(ctx, secret); err != nil { + t.Fatalf("Unlock: %v", err) + } + if !bytes.Equal(gotUnlock, secret) { + t.Errorf("UnlockFn got %x, want %x", gotUnlock, secret) + } + if err := cli.StoreEncryptionKey(ctx, secret); err != nil { + t.Fatalf("StoreEncryptionKey: %v", err) + } + if !bytes.Equal(gotWrap, secret) { + t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret) + } +} + +// A refusal from the daemon hook — a wrong passkey, or no prior assertion — +// must surface to the caller as an error, never be swallowed into success. +func TestUnlockPropagatesRefusal(t *testing.T) { + _, srv, cli, _ := newServerWithStore(t) + srv.UnlockFn = func(context.Context, []byte) error { + return errors.New("unlock: no verified passkey assertion (assert first)") + } + if err := cli.Unlock(context.Background(), bytes.Repeat([]byte{9}, 32)); err == nil { + t.Fatal("a refused unlock reported success") + } +} + +// Without the hooks wired — the normal, unencrypted deployment — both methods +// answer ErrUnknownMethod rather than pretending to have done something. +func TestUnlockUnwiredIsUnknownMethod(t *testing.T) { + _, _, cli, _ := newServerWithStore(t) + ctx := context.Background() + if err := cli.Unlock(ctx, bytes.Repeat([]byte{1}, 32)); err == nil { + t.Error("Unlock succeeded with no UnlockFn wired") + } + if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32)); err == nil { + t.Error("StoreEncryptionKey succeeded with no WrapKeyFn wired") + } +} + +// Locked mode: Server.Check is the whole authorization surface, and it must +// default-deny everything except the two methods the unlock flow needs. +func TestLockedCheckDefaultDenies(t *testing.T) { + _, srv, cli, _ := newServerWithStore(t) + + locked := errors.New("locked") + srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error { + switch m { + case MethodAssertStepUp, MethodUnlock: + return nil + default: + return locked + } + } + unlocked := false + srv.UnlockFn = func(context.Context, []byte) error { unlocked = true; return nil } + srv.StepUp = func(context.Context) error { return nil } + srv.WrapKeyFn = func(context.Context, []byte) error { return nil } + + ctx := context.Background() + // A store method must be refused while locked. + if _, err := cli.RecentNotes(ctx, 5); err == nil { + t.Error("a store read went through while locked") + } + // Key wrapping is NOT on the allowlist: a locked daemon has no key to wrap. + if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32)); err == nil { + t.Error("StoreEncryptionKey was allowed while locked") + } + // The unlock flow itself must still work. + if err := cli.AssertStepUp(ctx); err != nil { + t.Errorf("AssertStepUp refused while locked: %v", err) + } + if err := cli.Unlock(ctx, bytes.Repeat([]byte{3}, 32)); err != nil { + t.Errorf("Unlock refused while locked: %v", err) + } + if !unlocked { + t.Error("UnlockFn never ran") + } +} diff --git a/internal/webauthn/keywrap.go b/internal/webauthn/keywrap.go index 1070862..c056cb2 100644 --- a/internal/webauthn/keywrap.go +++ b/internal/webauthn/keywrap.go @@ -1,24 +1,48 @@ -// Key wrapping for cold-start unlock. +// Key wrapping for cold-start unlock (Vikunja #14). // -// The at-rest AES-256 key is wrapped with a key derived from the passkey -// credential public key (stable across assertions) via HKDF-SHA256, then -// AES-256-GCM. The wrapped blob is stored on disk; at cold-start the passkey -// assertion provides the credential public key to unwrap it. +// The at-rest AES-256 key is never on disk in the clear. It is wrapped with a +// key derived from a secret only the authenticator can produce, so a cold boot +// needs the physical passkey and nothing else opens the store. // -// The passkey credential is a P-256 ECDSA public key. Its raw uncompressed -// bytes (65 bytes, 0x04 || X || Y) are the HKDF input — high-entropy, stable. +// # What the secret must be // -// Blob format: salt (16) || nonce (12) || AES-256-GCM ciphertext. -// No file magic — the caller (mavend) owns the file path. +// The WebAuthn PRF extension. On assertion, the authenticator evaluates a +// keyed pseudo-random function over a fixed salt and hands back 32 bytes that +// are stable for the credential, unpredictable to everyone else, and never +// leave the device except as that output. That is the only thing in WebAuthn +// that yields a *secret* rather than a signature, and it is what makes the +// wrapped blob worth wrapping. +// +// # What it must NOT be, and used to be +// +// v1 of this file derived the wrapping key from the credential *public* key, +// on the reasoning that it is high-entropy and stable across assertions. Both +// are true and neither matters: a public key is public. mavweb writes it +// verbatim to passkeys.json, normally in the same state dir as the wrapped +// blob, so anyone holding both files recovered the database key offline with +// no authenticator involved. A v1 blob is a plaintext key with extra steps. +// +// v1 blobs are still readable, so an existing deployment opens and can be +// re-wrapped, and UnwrapKey reports which format it read so the caller can +// say so out loud. Nothing writes v1 any more. +// +// # Blob format +// +// v2: "MVNKW2\x00" (7) || salt (16) || nonce (12) || AES-256-GCM ciphertext +// v1: salt (16) || nonce (12) || AES-256-GCM ciphertext (legacy, read-only) +// +// The magic doubles as the version discriminator: v1 had none, so anything +// that does not start with it is v1 by elimination. A random 16-byte v1 salt +// colliding with the magic is a 2^-56 event, and the GCM tag catches it. package webauthn import ( "crypto/aes" "crypto/cipher" - "crypto/hmac" + "crypto/hkdf" "crypto/rand" "crypto/sha256" - "encoding/binary" + "crypto/subtle" "errors" "fmt" "io" @@ -31,143 +55,178 @@ const ( nonceLen = 12 // keyLen — AES-256 key length. keyLen = 32 - // wrapInfo — HKDF info string for domain separation. - wrapInfo = "maven-passkey-keywrap-v1" + // secretLen — required length of the PRF output used as key material. + // WebAuthn PRF results are 32 bytes. Requiring exactly that is not + // pedantry: it is the structural guard that stops a COSE credential + // public key (77+ bytes) being passed here again by accident. + secretLen = 32 + + // wrapInfoV2 — HKDF info string. Carries the version so a v1 and a v2 + // derivation can never collide even given the same input. + wrapInfoV2 = "maven-passkey-keywrap-v2" + // wrapInfoV1 — the legacy info string, kept only to read old blobs. + wrapInfoV1 = "maven-passkey-keywrap-v1" ) +// blobMagicV2 prefixes every v2 blob. +var blobMagicV2 = []byte("MVNKW2\x00") + var ( ErrKeyWrap = errors.New("webauthn: key wrap failed") ErrKeyUnwrap = errors.New("webauthn: key unwrap failed (wrong credential?)") ErrBlobTooLong = errors.New("webauthn: wrapped blob too long") + // ErrSecretLen is returned when the caller passes something that is not a + // 32-byte PRF output — most likely a credential public key. + ErrSecretLen = errors.New("webauthn: wrapping secret must be a 32-byte PRF output") ) -// WrapKey derives a wrapping key from credPublicKey via HKDF-SHA256 and -// AES-GCM-wraps plaintextKey. Returns the blob: salt || nonce || ciphertext. -// plaintextKey must be exactly 32 bytes (AES-256). -func WrapKey(plaintextKey, credPublicKey []byte) ([]byte, error) { +// BlobVersion identifies which format a blob was read as. +type BlobVersion int + +const ( + // BlobV1 is the legacy public-key-derived format. Readable, never written. + BlobV1 BlobVersion = 1 + // BlobV2 is the PRF-derived format. + BlobV2 BlobVersion = 2 +) + +func (v BlobVersion) String() string { + switch v { + case BlobV1: + return "v1 (legacy, public-key derived — NOT SECRET)" + case BlobV2: + return "v2 (PRF derived)" + } + return "unknown" +} + +// maxBlobLen — sanity limit; a real blob is 67 bytes. +const maxBlobLen = 1 << 20 + +// WrapKey wraps plaintextKey (32 bytes, AES-256) under a key derived from +// secret via HKDF-SHA256, and returns a v2 blob. +// +// secret must be the 32-byte WebAuthn PRF output for the enrolled credential. +// Anything else is refused — see the file header for why passing a credential +// public key here is the bug this replaces. +func WrapKey(plaintextKey, secret []byte) ([]byte, error) { if len(plaintextKey) != keyLen { return nil, fmt.Errorf("%w: plaintext key must be %d bytes", ErrKeyWrap, keyLen) } - if len(credPublicKey) == 0 { - return nil, fmt.Errorf("%w: empty credential public key", ErrKeyWrap) + if err := checkSecret(secret); err != nil { + return nil, fmt.Errorf("%w: %v", ErrKeyWrap, err) } salt := make([]byte, saltLen) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return nil, fmt.Errorf("%w: salt: %v", ErrKeyWrap, err) } - - wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen) - nonce := make([]byte, nonceLen) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("%w: nonce: %v", ErrKeyWrap, err) } - block, err := aes.NewCipher(wrapKey) + gcm, err := gcmFor(secret, salt, wrapInfoV2) if err != nil { - return nil, fmt.Errorf("%w: aes: %v", ErrKeyWrap, err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("%w: gcm: %v", ErrKeyWrap, err) + return nil, fmt.Errorf("%w: %v", ErrKeyWrap, err) } - // Seal appends ciphertext+tag to nonce (which becomes nonce||ct). - ct := gcm.Seal(nil, nonce, plaintextKey, nil) + // The magic is authenticated as additional data, so a v2 blob cannot be + // stripped of its header and re-read as a v1 blob. + ct := gcm.Seal(nil, nonce, plaintextKey, blobMagicV2) - out := make([]byte, 0, saltLen+nonceLen+len(ct)) + out := make([]byte, 0, len(blobMagicV2)+saltLen+nonceLen+len(ct)) + out = append(out, blobMagicV2...) out = append(out, salt...) out = append(out, nonce...) out = append(out, ct...) return out, nil } -// UnwrapKey extracts the salt from blob, re-derives the wrapping key from -// credPublicKey, and AES-GCM-unwraps. Returns the plaintext 32-byte AES key. -func UnwrapKey(blob, credPublicKey []byte) ([]byte, error) { - if len(blob) < saltLen+nonceLen+1 { - return nil, fmt.Errorf("%w: blob too short (%d)", ErrKeyUnwrap, len(blob)) +// UnwrapKey recovers the plaintext AES-256 key from blob. +// +// It reads both formats and reports which one it got, so the caller can warn +// that a v1 blob offers no real protection. For a v2 blob, secret must be the +// 32-byte PRF output; for a v1 blob it is the credential public key, whatever +// length that happens to be. +func UnwrapKey(blob, secret []byte) ([]byte, BlobVersion, error) { + if len(blob) > maxBlobLen { + return nil, 0, ErrBlobTooLong } - if len(blob) > 1<<20 { // 1MB sanity limit - return nil, ErrBlobTooLong - } - if len(credPublicKey) == 0 { - return nil, fmt.Errorf("%w: empty credential public key", ErrKeyUnwrap) + if len(secret) == 0 { + return nil, 0, fmt.Errorf("%w: empty secret", ErrKeyUnwrap) } - salt := blob[:saltLen] - nonce := blob[saltLen : saltLen+nonceLen] - ct := blob[saltLen+nonceLen:] + if len(blob) >= len(blobMagicV2) && subtle.ConstantTimeCompare(blob[:len(blobMagicV2)], blobMagicV2) == 1 { + key, err := unwrap(blob[len(blobMagicV2):], secret, wrapInfoV2, blobMagicV2, secretLen) + return key, BlobV2, err + } + key, err := unwrap(blob, secret, wrapInfoV1, nil, 0) + return key, BlobV1, err +} - wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen) +// unwrap does the shared salt||nonce||ct work. wantSecretLen of 0 means any +// non-empty secret is accepted (the v1 case, where it is a public key). +func unwrap(body, secret []byte, info string, aad []byte, wantSecretLen int) ([]byte, error) { + if len(body) < saltLen+nonceLen+1 { + return nil, fmt.Errorf("%w: blob too short (%d)", ErrKeyUnwrap, len(body)) + } + if wantSecretLen > 0 && len(secret) != wantSecretLen { + return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, ErrSecretLen) + } - block, err := aes.NewCipher(wrapKey) + salt := body[:saltLen] + nonce := body[saltLen : saltLen+nonceLen] + ct := body[saltLen+nonceLen:] + + gcm, err := gcmFor(secret, salt, info) if err != nil { - return nil, fmt.Errorf("%w: aes: %v", ErrKeyUnwrap, err) + return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, err) } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("%w: gcm: %v", ErrKeyUnwrap, err) - } - - plain, err := gcm.Open(nil, nonce, ct, nil) + plain, err := gcm.Open(nil, nonce, ct, aad) if err != nil { return nil, fmt.Errorf("%w: decrypt failed (wrong credential?)", ErrKeyUnwrap) } + if len(plain) != keyLen { + return nil, fmt.Errorf("%w: unwrapped key is %d bytes, want %d", ErrKeyUnwrap, len(plain), keyLen) + } return plain, nil } -// hkdfSHA256 implements HKDF-SHA256 (RFC 5869) using only stdlib. +// gcmFor derives the wrapping key with HKDF-SHA256 and returns a GCM AEAD. // -// Input: -// - secret: the input key material (credential public key bytes) -// - salt: random salt (16 bytes) -// - info: optional context string for domain separation -// - length: desired output length in bytes -// -// Output: length bytes of derived key material. -// -// HKDF is extract-then-expand. We use HMAC-SHA256 for both steps. This avoids -// importing golang.org/x/crypto/hkdf — a ~30-line function vs a new dep. The -// tradeoff is no constant-time guarantees on the extract step beyond HMAC's; -// acceptable here because the input is already high-entropy key material (a -// P-256 public key), not a low-entropy passphrase. -func hkdfSHA256(secret, salt, info []byte, length int) []byte { - // Step 1: Extract — PRK = HMAC-SHA256(salt, secret) - // If salt is nil/empty, use a zero-filled block (RFC 5869 §2.2). - if salt == nil { - salt = make([]byte, sha256.Size) +// This uses the standard library's crypto/hkdf rather than the hand-rolled +// HKDF this file used to carry. That implementation keyed the expand step with +// the salt instead of the PRK — self-consistent, so wrap and unwrap agreed, +// but not RFC 5869 and not the domain separation it claimed to provide. +func gcmFor(secret, salt []byte, info string) (cipher.AEAD, error) { + wrapKey, err := hkdf.Key(sha256.New, secret, salt, info, keyLen) + if err != nil { + return nil, fmt.Errorf("hkdf: %v", err) } - mac := hmac.New(sha256.New, salt) - mac.Write(secret) - prk := mac.Sum(nil) - - // Step 2: Expand — produce length bytes via T(i) = HMAC-SHA256(PRK, T(i-1) || info || i) - // Where T(0) = empty, i is a byte counter starting at 1. - out := make([]byte, 0, length) - block := make([]byte, 0, sha256.Size+len(info)+1) - var t []byte // T(i-1) - for counter := byte(1); len(out) < length; counter++ { - block = block[:0] - block = append(block, t...) - block = append(block, info...) - block = append(block, counter) - - mac.Reset() - mac.Write(block) - t = mac.Sum(prk[:0]) // reuse prk buffer — mac.Sum appends to its arg - // t now starts with prk[:0] (empty) followed by the HMAC result. - // Since we need just the HMAC result (sha256.Size bytes), re-slice. - t = t[len(t)-sha256.Size:] - out = append(out, t...) + block, err := aes.NewCipher(wrapKey) + if err != nil { + return nil, fmt.Errorf("aes: %v", err) } - return out[:length] + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("gcm: %v", err) + } + return gcm, nil } -// encodeUint32 — big-endian uint32 for the blob format header, if needed. -func encodeUint32(v uint32) []byte { - var b [4]byte - binary.BigEndian.PutUint32(b[:], v) - return b[:] +func checkSecret(secret []byte) error { + if len(secret) != secretLen { + return fmt.Errorf("%w (got %d bytes)", ErrSecretLen, len(secret)) + } + // An all-zero PRF result means the authenticator returned nothing useful; + // wrapping under it would produce a blob anyone can open. + var acc byte + for _, b := range secret { + acc |= b + } + if acc == 0 { + return fmt.Errorf("%w (all zero)", ErrSecretLen) + } + return nil } diff --git a/internal/webauthn/keywrap_test.go b/internal/webauthn/keywrap_test.go new file mode 100644 index 0000000..f2814e8 --- /dev/null +++ b/internal/webauthn/keywrap_test.go @@ -0,0 +1,231 @@ +package webauthn + +import ( + "bytes" + "crypto/rand" + "errors" + "io" + "testing" +) + +func testSecret(t *testing.T) []byte { + t.Helper() + s := make([]byte, secretLen) + if _, err := io.ReadFull(rand.Reader, s); err != nil { + t.Fatalf("rand: %v", err) + } + s[0] |= 1 // never all-zero + return s +} + +func testKey(t *testing.T) []byte { + t.Helper() + k := make([]byte, keyLen) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + t.Fatalf("rand: %v", err) + } + return k +} + +func TestWrapUnwrapRoundTrip(t *testing.T) { + key, secret := testKey(t), testSecret(t) + + blob, err := WrapKey(key, secret) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + if !bytes.HasPrefix(blob, blobMagicV2) { + t.Fatalf("blob does not start with the v2 magic: %x", blob[:8]) + } + // The plaintext key must not be recoverable by reading the file. + if bytes.Contains(blob, key) { + t.Fatal("the wrapped blob contains the plaintext key verbatim") + } + + got, version, err := UnwrapKey(blob, secret) + if err != nil { + t.Fatalf("UnwrapKey: %v", err) + } + if version != BlobV2 { + t.Errorf("version = %v, want v2", version) + } + if !bytes.Equal(got, key) { + t.Errorf("unwrapped key differs from the wrapped one") + } +} + +// Fresh salt and nonce per wrap: two blobs of the same key under the same +// secret must not be byte-identical, or the file leaks that nothing changed. +func TestWrapKeyIsNotDeterministic(t *testing.T) { + key, secret := testKey(t), testSecret(t) + a, err := WrapKey(key, secret) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + b, err := WrapKey(key, secret) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + if bytes.Equal(a, b) { + t.Fatal("two wraps of the same key produced identical blobs") + } +} + +// The failure mode that matters most: a wrong passkey must not unlock. +func TestUnwrapWithWrongSecretFails(t *testing.T) { + key := testKey(t) + blob, err := WrapKey(key, testSecret(t)) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + got, _, err := UnwrapKey(blob, testSecret(t)) + if err == nil { + t.Fatal("a different secret unwrapped the blob") + } + if !errors.Is(err, ErrKeyUnwrap) { + t.Errorf("err = %v, want ErrKeyUnwrap", err) + } + if got != nil { + t.Error("key material returned alongside an error") + } +} + +// One flipped bit anywhere must fail the GCM tag, including in the salt and +// nonce — those are not authenticated by the tag but they change the +// derivation, so the tag fails anyway. +func TestUnwrapRejectsTamperedBlob(t *testing.T) { + key, secret := testKey(t), testSecret(t) + blob, err := WrapKey(key, secret) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + for i := range blob { + bad := bytes.Clone(blob) + bad[i] ^= 0x01 + if _, _, err := UnwrapKey(bad, secret); err == nil { + t.Fatalf("byte %d of %d could be flipped and the blob still opened", i, len(blob)) + } + } +} + +func TestUnwrapRejectsTruncatedBlob(t *testing.T) { + key, secret := testKey(t), testSecret(t) + blob, err := WrapKey(key, secret) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + for _, n := range []int{0, 1, len(blobMagicV2), len(blobMagicV2) + saltLen, len(blob) - 1} { + if _, _, err := UnwrapKey(blob[:n], secret); err == nil { + t.Errorf("a %d-byte blob unwrapped", n) + } + } +} + +// A v2 blob must not be downgradeable to v1 by stripping its header: the magic +// is GCM additional data, so the tag fails once it is gone. +func TestV2BlobCannotBeStrippedToV1(t *testing.T) { + key, secret := testKey(t), testSecret(t) + blob, err := WrapKey(key, secret) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + if _, _, err := UnwrapKey(blob[len(blobMagicV2):], secret); err == nil { + t.Fatal("a header-stripped v2 blob was accepted as v1") + } +} + +// v1 blobs still open, and report themselves as v1 so the daemon can warn. +// wrapV1 reproduces the legacy writer this file no longer has. +func wrapV1(t *testing.T, key, secret []byte) []byte { + t.Helper() + salt := make([]byte, saltLen) + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + t.Fatalf("rand: %v", err) + } + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + t.Fatalf("rand: %v", err) + } + gcm, err := gcmFor(secret, salt, wrapInfoV1) + if err != nil { + t.Fatalf("gcmFor: %v", err) + } + out := append([]byte{}, salt...) + out = append(out, nonce...) + return append(out, gcm.Seal(nil, nonce, key, nil)...) +} + +func TestUnwrapReadsLegacyV1(t *testing.T) { + key := testKey(t) + // v1 was keyed on the credential public key: not 32 bytes, and that is + // deliberately still accepted on the read path. + pub := make([]byte, 77) + if _, err := io.ReadFull(rand.Reader, pub); err != nil { + t.Fatalf("rand: %v", err) + } + blob := wrapV1(t, key, pub) + + got, version, err := UnwrapKey(blob, pub) + if err != nil { + t.Fatalf("UnwrapKey(v1): %v", err) + } + if version != BlobV1 { + t.Errorf("version = %v, want v1", version) + } + if !bytes.Equal(got, key) { + t.Error("v1 round-trip lost the key") + } + if _, _, err := UnwrapKey(blob, pub[:76]); err == nil { + t.Error("a truncated public key opened the v1 blob") + } +} + +// The structural guard against the bug this replaces: a COSE public key is not +// 32 bytes, so it can never be used to write a new blob. +func TestWrapKeyRefusesNonPRFSecret(t *testing.T) { + key := testKey(t) + cases := map[string][]byte{ + "nil": nil, + "empty": {}, + "short": make([]byte, 16), + "cose public key": make([]byte, 77), + "all-zero 32 byte": make([]byte, 32), + } + for name, secret := range cases { + t.Run(name, func(t *testing.T) { + if _, err := WrapKey(key, secret); err == nil { + t.Fatalf("WrapKey accepted a %s secret", name) + } + }) + } +} + +func TestWrapKeyRefusesWrongKeyLength(t *testing.T) { + secret := testSecret(t) + for _, n := range []int{0, 16, 31, 33, 64} { + if _, err := WrapKey(make([]byte, n), secret); err == nil { + t.Errorf("WrapKey accepted a %d-byte plaintext key", n) + } + } +} + +// A v2 blob demands exactly 32 bytes on the read path too, so a caller cannot +// go back to passing a public key. +func TestUnwrapV2RefusesNonPRFSecret(t *testing.T) { + blob, err := WrapKey(testKey(t), testSecret(t)) + if err != nil { + t.Fatalf("WrapKey: %v", err) + } + if _, _, err := UnwrapKey(blob, make([]byte, 77)); !errors.Is(err, ErrKeyUnwrap) { + t.Fatalf("err = %v, want ErrKeyUnwrap for a 77-byte secret", err) + } + if _, _, err := UnwrapKey(blob, nil); err == nil { + t.Fatal("an empty secret unwrapped a v2 blob") + } +} + +func TestUnwrapRejectsOversizeBlob(t *testing.T) { + if _, _, err := UnwrapKey(make([]byte, maxBlobLen+1), testSecret(t)); !errors.Is(err, ErrBlobTooLong) { + t.Fatalf("err = %v, want ErrBlobTooLong", err) + } +} diff --git a/internal/webauthn/prf.go b/internal/webauthn/prf.go new file mode 100644 index 0000000..21655e6 --- /dev/null +++ b/internal/webauthn/prf.go @@ -0,0 +1,70 @@ +package webauthn + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" +) + +// The WebAuthn PRF extension is where cold-start unlock gets its secret +// (Vikunja #14). The authenticator evaluates a keyed PRF over a salt we +// choose and returns 32 bytes that are: +// +// - stable — the same credential and the same salt always give the same +// bytes, which is what lets a blob wrapped today be opened tomorrow; +// - secret — they never leave the authenticator except as this output, so +// unlike the credential public key they are not sitting in passkeys.json; +// - bound to user verification — the assertion that produces them required +// a gesture, so the bytes cannot be harvested silently. +// +// The salt is fixed and public. It is a domain separator, not a secret: it +// makes maven's PRF output different from any other relying party's use of +// the same credential. + +// prfSaltInput — the string hashed into the 32-byte evaluation salt. Changing +// it invalidates every wrapped key file in existence, which is why it is a +// constant and not configuration. +const prfSaltInput = "maven-coldstart-unlock-v1" + +// PRFSalt returns the fixed 32-byte PRF evaluation salt. +func PRFSalt() []byte { + sum := sha256.Sum256([]byte(prfSaltInput)) + return sum[:] +} + +// ErrNoPRF is returned when a browser reports no PRF result — either the +// authenticator does not implement the extension, or the platform stripped +// it. Cold-start unlock is unavailable for that credential, and the correct +// response is to say so rather than to fall back to something weaker. +var ErrNoPRF = errors.New("webauthn: authenticator returned no PRF result (cold-start unlock unavailable)") + +// DecodePRFResult parses the base64url PRF output the browser read out of +// getClientExtensionResults().prf.results.first and checks it is usable as +// wrapping key material. +// +// The browser is not trusted to send something sensible: a short, empty, or +// all-zero result would silently produce a blob that anyone can open, so all +// three are refused here rather than at the crypto layer. +func DecodePRFResult(b64 string) ([]byte, error) { + if b64 == "" { + return nil, ErrNoPRF + } + secret, err := decodeB64Any(b64) + if err != nil { + return nil, fmt.Errorf("webauthn: prf result: %w", err) + } + if err := checkSecret(secret); err != nil { + return nil, err + } + return secret, nil +} + +// decodeB64Any accepts padded or unpadded base64url — browsers differ, and +// the JS helper on the passkey page strips padding. +func decodeB64Any(s string) ([]byte, error) { + if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { + return b, nil + } + return base64.URLEncoding.DecodeString(s) +} diff --git a/internal/webauthn/prf_test.go b/internal/webauthn/prf_test.go new file mode 100644 index 0000000..11038e0 --- /dev/null +++ b/internal/webauthn/prf_test.go @@ -0,0 +1,114 @@ +package webauthn + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "testing" +) + +// The salt is the identity of every wrapped key file ever written. If it +// changes, every deployment's blob becomes unopenable, so it is pinned here. +func TestPRFSaltIsStable(t *testing.T) { + salt := PRFSalt() + if len(salt) != 32 { + t.Fatalf("salt is %d bytes, want 32", len(salt)) + } + if got := base64.RawURLEncoding.EncodeToString(salt); got != base64.RawURLEncoding.EncodeToString(PRFSalt()) { + t.Fatal("PRFSalt is not deterministic") + } + // Mutating the returned slice must not affect the next caller. + salt[0] ^= 0xff + if bytes.Equal(salt, PRFSalt()) { + t.Fatal("PRFSalt returned shared backing state") + } +} + +func TestDecodePRFResult(t *testing.T) { + raw := make([]byte, 32) + for i := range raw { + raw[i] = byte(i + 1) + } + for _, enc := range []string{ + base64.RawURLEncoding.EncodeToString(raw), + base64.URLEncoding.EncodeToString(raw), + } { + got, err := DecodePRFResult(enc) + if err != nil { + t.Fatalf("DecodePRFResult(%q): %v", enc, err) + } + if !bytes.Equal(got, raw) { + t.Errorf("decoded %x, want %x", got, raw) + } + } +} + +// No PRF must be a distinguishable, named failure — never a silent fallback to +// some other secret. +func TestDecodePRFResultNoPRF(t *testing.T) { + if _, err := DecodePRFResult(""); !errors.Is(err, ErrNoPRF) { + t.Fatalf("err = %v, want ErrNoPRF", err) + } +} + +func TestDecodePRFResultRejectsUnusable(t *testing.T) { + zeros := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + short := base64.RawURLEncoding.EncodeToString(make([]byte, 16)) + long := base64.RawURLEncoding.EncodeToString(make([]byte, 64)) + for name, in := range map[string]string{ + "not base64": "!!!!", + "all zero": zeros, + "too short": short, + "too long": long, + } { + t.Run(name, func(t *testing.T) { + if _, err := DecodePRFResult(in); err == nil { + t.Fatalf("accepted a %s PRF result", name) + } + }) + } +} + +// Both option builders must ask for PRF, or the browser never produces a +// secret and cold-start unlock silently never works. +func TestOptionsRequestPRF(t *testing.T) { + rp := NewRP(Config{Origin: "http://localhost:8080", RPID: "localhost", RPName: "maven"}) + + create, _, err := rp.CreationOptions([]byte("u"), "u") + if err != nil { + t.Fatalf("CreationOptions: %v", err) + } + if _, ok := extPRF(t, create)["prf"]; !ok { + t.Error("creation options do not request the prf extension") + } + + assert, _, err := rp.AssertionOptions() + if err != nil { + t.Fatalf("AssertionOptions: %v", err) + } + prf, ok := extPRF(t, assert)["prf"].(map[string]any) + if !ok { + t.Fatal("assertion options do not request the prf extension") + } + eval, _ := prf["eval"].(map[string]any) + first, _ := eval["first"].(string) + if first != base64.RawURLEncoding.EncodeToString(PRFSalt()) { + t.Errorf("prf.eval.first = %q, want the fixed salt", first) + } +} + +func extPRF(t *testing.T, opts any) map[string]any { + t.Helper() + b, err := json.Marshal(opts) + if err != nil { + t.Fatalf("marshal options: %v", err) + } + var m struct { + Extensions map[string]any `json:"extensions"` + } + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal options: %v", err) + } + return m.Extensions +} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 21d4fb8..0a366a4 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -124,6 +124,13 @@ func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, s "timeout": 60000, "attestation": "none", "excludeCredentials": []any{}, + // PRF: ask the authenticator at enrollment time whether it can + // produce a per-credential secret. Nothing is wrapped here — the + // browser reports support back and mavweb decides whether cold-start + // unlock is available for this credential. See internal/webauthn/prf.go. + "extensions": map[string]any{ + "prf": map[string]any{}, + }, }, challengeB64, nil } @@ -193,6 +200,15 @@ func (rp *RP) AssertionOptions() (map[string]any, string, error) { "rpId": rp.cfg.RPID, "allowCredentials": []any{}, "userVerification": "required", + // PRF evaluation over the fixed cold-start salt. The 32 bytes that + // come back are the ONLY thing that can unwrap the database key. + "extensions": map[string]any{ + "prf": map[string]any{ + "eval": map[string]any{ + "first": base64.RawURLEncoding.EncodeToString(PRFSalt()), + }, + }, + }, }, challengeB64, nil }