Compare commits

...

1 Commits

Author SHA1 Message Date
kami 4eca20bd94 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
2026-08-01 05:49:27 +04:00
13 changed files with 1349 additions and 168 deletions
+190
View File
@@ -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
View File
@@ -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
}
+293
View File
@@ -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":<bytes>}
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)
}
}
}
+52 -33
View File
@@ -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);}}
</script>`
@@ -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)
}
}
}
+11 -6
View File
@@ -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
+8 -4
View File
@@ -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) {
+11 -11
View File
@@ -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)
+124
View File
@@ -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")
}
}
+159 -100
View File
@@ -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
}
+231
View File
@@ -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)
}
}
+70
View File
@@ -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)
}
+114
View File
@@ -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
}
+16
View File
@@ -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
}