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
- webauthn.go: keyIPC interface for StoreEncryptionKey/Unlock, wired
through PasskeyHandle. RegisterFinish calls StoreEncryptionKey with
the credential's public key after successful enrollment. AssertFinish
calls Unlock with the stored public key after assertion (alongside
existing AssertStepUp call).
- server.go: fix data race on s.api by switching from bare CoreAPI field
to atomic.Value. SetAPI uses Store(), dispatch uses Load(). No more
race-flagged tests.
- make test green (303+, -race)
- Rewrote ui.css with design tokens, card/btn/badge/dot components
- All pages wrapped in <main class=page> with max-width container
- Replaced inline <style> blocks with ui.css classes
- passkey page now uses shared nav.site template
- PWA voice page unified under shared nav.site (no more separate tab nav)
- Inline lang toggle moved from nav to voice page body
One theme (the PWA's dark palette) for dash/history/trace/notifications/
tools/passkey via static/ui.css; shared nav template with active-page
highlight; tables wrapped in .scroll so they pan on phones; PWA nav no
longer clips the RU/EN toggle; dash 'updated' timestamp fixed (selector
matched nothing). AGENTS.md documents the local preview/screenshot recipe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASstMtsZWLSRcD1Tq8T68Q
Add a local PasskeySession that handleTools checks before processing
any POST action (enable/disable). If the session hasn't been asserted
within the 5-minute TTL, return 403 Forbidden.
Changes:
- webauthn/session.go: add IsStepUp() convenience method (nil-safe)
- webauthn.go: PasskeyHandle holds a *PasskeySession; AssertFinish
calls session.Assert() after IPC step-up
- main.go: create stepUpSession, pass to handleTools and
newPasskeyHandle; handleTools returns 403 if !session.IsStepUp()
- handlers_test.go: update TestEnableTool_NoInProcessAuthGate to
expect 403; add TestEnableTool_WithAuthGate_RequiresStepUp for
the happy path with asserted session; update all 10 call sites
- New credentialStore type in credentials.go loads/saves
map[id]localCred to a JSON file. Thread-safe with sync.RWMutex,
writes to disk on every mutation.
- PasskeyHandle replaces sync.RWMutex+map with *credentialStore.
Inline save/lookip/update closures delegate to store methods.
- newPasskeyHandle now takes a storePath parameter and returns an
error; callers updated.
- New -passkey-file flag (default ./passkeys.json) configures the
credential store path in main.go.
- Tests use os.CreateTemp in t.TempDir() so each test gets an
isolated, auto-cleaned store file.
Completes the three in-flight open items and fixes the away-fallthrough bug.
Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
decays after TTL). Drop the RS256 offer we can't verify (register-ok/
assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
the step-up gesture. Round-trip test with negative cases (tampered sig,
missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
a WebAuthn gesture) + the four begin/finish endpoints. Without this the
daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
MethodAssertStepUp at AuthRead.
Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.
Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.
Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.
Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>