Derive the cold-start unlock key from the passkey PRF, not the public key (#14) #77
Closed
claude
wants to merge 1 commits from
overnight/coldstart-unlock into overnight/voice-barge-in
pull from: overnight/coldstart-unlock
merge into: kami:overnight/voice-barge-in
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/email-imap
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/coldstart-unlock"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What changed
Cold-start unlock (passkey → L3 → encrypted data at rest) wrapped the database key under the credential public key. mavweb writes that key verbatim to
passkeys.json, normally in the same state dir asdb_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 wrapping secret is now the WebAuthn PRF extension output — 32 bytes the authenticator computes over a fixed salt and never stores. Versioned blob:
v1 still opens (no bricked deployment) and reports itself, so
mavendlogs aSECURITY:line telling him to re-enroll. Nothing writes v1. The magic is GCM additional data, so a v2 blob cannot be header-stripped and re-read as v1.Four other defects on the same path:
UnlockFnand was never closed;Closeis what re-encrypts the tmpfs working copy back over the ciphertext file.daemonLocknow owns the store and seals it at shutdown.MethodUnlockwas reachable by anything on the box. The socket is same-uid and cannot authenticate its caller. Unlock now requires a passkey assertion mavweb verified cryptographically first.crypto/hkdf.Key wrapping moved from enrolment to the first assertion:
create()does not produce a PRF result on most authenticators, only a support flag. An authenticator without PRF now writes no wrapped file rather than one that looks protected and is not, and the passkey page says so out loud instead of failing open.Both configurations behave: with no wrapped key file the daemon boots normally and the key IPC answers
ErrUnknownMethod; with one and no env key it boots locked, default-denies every method exceptAssertStepUp/Unlock, and comes up on assertion.Files
internal/webauthn/keywrap.go— rewritten; v2 format,BlobVersion,ErrSecretLen, stdlib HKDFinternal/webauthn/prf.go— new;PRFSalt,DecodePRFResult,ErrNoPRFinternal/webauthn/webauthn.go— both option builders request theprfextensioninternal/ipc/{api,client,server}.go— wire fieldpublic_key→secretcmd/mavend/main.go— store ownership indaemonLock, seal on shutdown,unlockMu, assertion requiredcmd/mavweb/webauthn.go— PRF posted with the assertion; wrap+unlock keyed on it; page JS readsgetClientExtensionResults().prf.results.firstNo config keys added.
-wrapped-key-file(orDefaultWrappedKeyPath) is unchanged.How verified
make buildandmake testboth exit 0. New tests:internal/webauthn/keywrap_test.go— v2 round trip, non-determinism, wrong secret, every single-bit flip in the blob, truncation, v2→v1 downgrade attempt, legacy v1 read, non-32-byte / all-zero / COSE-sized secrets refused on both pathsinternal/webauthn/prf_test.go— salt stability and non-aliasing, padded/unpadded decode,ErrNoPRF, unusable results refused, both option builders request PRF with the right saltinternal/ipc/unlock_test.go— wire carriessecretand notpublic_key, secret reaches the hook byte-for-byte, refusals propagate, unwired =ErrUnknownMethod, locked-mode default-deny with only the two unlock methods allowedcmd/mavweb/passkey_prf_test.go— software authenticator; the PRF secret (not the public key) is what goes over IPC, no PRF means no unlock attempt at all, a failed unlock does not fail the assertion, a forged assertion never reaches the key IPC, and the page still asks for and posts the PRFcmd/mavend/coldstart_test.go— seal-on-shutdown after a cold start survives a reboot (this fails without the fix),closeStoresafe when never unlocked and safe twice, nothing in the state dir contains the plaintext key, wrong key does not open the storeNot verified here, and cannot be: the PRF round trip against real hardware. There is no authenticator on this box. Left as QA steps on the task.
Vikunja #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 #14The core change is right and the reasoning in the
keywrap.goheader is the clearest statement of the old bug I have read. Three details earn their keep.secretLen = 32as a structural refusal, so a 77-byte COSE public key cannot be passed here again by accident. The magic authenticated as GCM additional data, so a v2 blob cannot be stripped and re-read as v1. Replacing the hand-rolled HKDF that keyed expand with the salt instead of the PRK. ThedaemonLock.sthandoff is a separate real bug caught in passing. A cold-started daemon never calledstore.Close. Every write of that session was lost on the next boot, silently.Then the failure modes.
1. Any box enrolled before this PR can never cold-start again
The header says v1 blobs stay readable "so an existing deployment opens and can be re-wrapped". Trace who supplies the v1 secret.
UnwrapKeyreads a v1 blob withwantSecretLen = 0, so it accepts the credential public key. The only caller isAssertFinish, and it now sendswebauthn.DecodePRFResult(body.PRF). The public key is never sent again.h.store.Lookup(credID)was deleted from that path.So on a box with a v1 blob:
Unlockwith it.UnwrapKeysees no magic, takes the v1 branch, derives underwrapInfoV1from the PRF secret rather than the public key, and GCM open fails.unwrap key: decrypt failed (wrong credential?). The daemon stays locked.There is no second attempt with the public key. The v1 read path is dead code from its only caller, and the
SECURITY:warning inUnlockFnis unreachable. The escape hatch is gone too. Re-wrapping needsWrapKeyFn, wired onlyif envKeyBytes != nil. A locked boot is by definition the mode with no env key. The recovery path is to putMAVEN_DB_KEYback in the environment, which is the thing cold-start unlock exists to avoid.If the deployed box has a v1 blob, this PR bricks its cold start. It needs one of two fixes before merge. Retry the unwrap with the public key inside
AssertFinishwhen the PRF attempt fails. Or wireWrapKeyFnin locked mode after a successful unlock, so the unlock that used the v1 key rewrites the blob as v2.2. Every assertion rewrites the blob, so only the last authenticator can cold-start
StoreEncryptionKey(ctx, secret)runs on every successful assertion, unconditionally, andWrapKeyFndoesos.WriteFile(wp, blob, 0o600). Two consequences.Last credential wins.
AssertionOptionssendsallowCredentials: [], andh.store.Savekeeps more than one credential. Enrol a phone and a hardware key. Assert with the phone: the blob is wrapped under the phone's PRF output. Assert with the hardware key next week: the blob is rewritten under a completely different secret. The phone can no longer open the database. Nothing warns, and the log line saysencryption key wrapped for credential <id>either way. The backup authenticator he enrolled for exactly this situation is the one thing that stops working.Non-atomic rewrite of the only thing that opens the database.
os.WriteFiletruncates in place. A power cut or an OOM kill between the truncate and the write leaves a zero-length or half-written blob. The previous contents are gone. This is now on the path of every routine step-up, not only enrolment. Write to a temp file in the same directory,fsync, thenrename. And skip the write entirely when a valid v2 blob already opens under this same secret.3. The wrapping secret is whatever the browser says it is
body.PRFis 32 bytes chosen by the client. WebAuthn client extension outputs are not covered by the assertion signature. Nothing binds the PRF value to the credential just verified.AssertFinishdecodes it, checks length and non-zero, and hands it toStoreEncryptionKey.A page-level compromise of
/auth/webauthnthen converts one legitimate touch into permanent offline recovery of the database key. The script substitutes 32 bytes it knows. The daemon re-wraps the at-rest key under them. The attacker needs only the blob file afterwards. No authenticator, no second gesture. The real passkey is locked out at the same moment, so the failure is loud, but by then the key is gone.TestForgedAssertionNeverUnlockscovers the forged-signature case. It does not cover a valid assertion carrying a substitutedprf. Gating the rewrite as in finding 2 closes most of this. Write the blob only when no working v2 blob exists. A substituted secret then gets one shot at enrolment, not one per assertion.4. The
IsStepUpguard is not the boundary its comment claimsUnlockFnsays:auth.Requirement(ipc.MethodAssertStepUp)returnsAuthRead. Any process that can open the same-uid socket callsassert_step_up, getspasskeySessflipped, and then callsunlock.MethodUnlockis still reachable by anything on the box. What stops a local attacker is the 32-byte PRF output they do not have. That was already true before the guard.The guard is worth keeping as depth. Say what it does. It stops an accidental unlock attempt from an unrelated local caller.
Smaller notes
UnlockFnis nil, so every assertion logswebauthn: unlock via credential <id>: unknown method. In locked mode after the first unlock,UnlockFnreturns nil early, so every later assertion logsdaemon unlocked via credential <id>when nothing happened. Both lines say the wrong thing on the common path.UnwrapKey's v2 branch checks the secret length throughwantSecretLenbut not the all-zero case thatcheckSecretrejects on the wrap side.DecodePRFResultcovers it for the one caller today, which makes the asymmetry harmless and easy to lose later.WrapKeyis documented as taking "the 32-byte WebAuthn PRF output for the enrolled credential". Nothing ininternal/webauthnor mavend can tell which credential a secret came from. That is finding 3 restated at the API boundary.Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed