coldstart: recover v1 boxes, and make key wrapping an explicit act
Three ways the cold-start path could lose the database. A box enrolled before the PRF change could never cold-start again. UnwrapKey still read v1 blobs, but the only caller stopped supplying the v1 secret: the assertion handler sends the PRF output and nothing looks up the credential public key any more. On such a box the daemon read the blob, took the v1 branch, failed to decrypt, and stayed locked while a valid passkey was asserted at it. The escape hatch was gone too, because WrapKeyFn was wired only in env-key mode and a locked boot is by definition the mode with no env key. The recovery was to put MAVEN_DB_KEY back in the environment, which is the thing cold-start unlock exists to avoid. AssertFinish now retries a failed PRF unwrap with the credential public key, and WrapKeyFn is wired in locked mode too, so the box that came up on a v1 blob can be moved to v2. Wrapping ran on every successful assertion. That made a routine step-up rewrite the one file that opens the database, under whatever 32 bytes the page posted. A compromised /auth/webauthn converted one legitimate touch into permanent offline recovery of the at-rest key, and a second enrolled authenticator silently locked out the first. Wrapping is now an act of its own: a plain assertion may write the blob only when none exists, and replacing one takes the rewrite button, which is the only caller that sets the new explicit flag. The daemon still refuses to overwrite a v2 blob that does not open under the presented secret. The write was os.WriteFile, which truncates in place. A power cut between the truncate and the write left a zero-length blob and no previous contents, on the path of every step-up. It is now a temp file in the same directory, fsync, rename, fsync of the directory. Two smaller things on the same path. The v2 unwrap checked the secret length but not the all-zero case the wrap side rejects, so the two ends disagreed about what a valid secret is. And the handler logged "daemon unlocked via credential" when an env-key daemon had answered unknown method, and again when an already-unlocked daemon had done nothing. Left alone deliberately: the PRF value is client-supplied and not covered by the assertion signature. That is inherent to PRF key wrapping, since the salt has to be fixed for the blob to open on the next boot. It is recorded as a known property where the secret enters the handler. Found in review of #77.
This commit is contained in:
+91
-25
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -23,7 +24,7 @@ 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, secret []byte) error
|
||||
StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error
|
||||
Unlock(ctx context.Context, secret []byte) error
|
||||
}
|
||||
|
||||
@@ -86,8 +87,10 @@ const passkeyPageHTML = `{{template "shellTop" "passkey"}}
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
@@ -112,7 +115,7 @@ async function enroll(){try{
|
||||
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{
|
||||
async function assert(explicit){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});
|
||||
@@ -121,13 +124,20 @@ async function assert(){try{
|
||||
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,prf,credential:{id:c.id,type:c.type,response:{
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
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);
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>`
|
||||
|
||||
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -201,7 +211,20 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
//
|
||||
// Known property, accepted deliberately: this value is supplied by
|
||||
// the client and is NOT covered by the assertion signature. WebAuthn
|
||||
// client extension outputs never are, and binding one would need a
|
||||
// per-assertion salt, which would make the wrapped blob unopenable on
|
||||
// the next boot. Nothing here can tell a real PRF output from 32
|
||||
// bytes a compromised page chose. What limits the damage is that the
|
||||
// daemon refuses to rewrite an existing blob unless the operator
|
||||
// asked for it — see Explicit below and cmd/mavend/keyfile.go.
|
||||
PRF string `json:"prf"`
|
||||
// Explicit marks the "rewrite cold-start key" button rather than a
|
||||
// plain step-up. Only then may the daemon replace a blob that is
|
||||
// already on disk.
|
||||
Explicit bool `json:"explicit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -235,32 +258,22 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Cold-start unlock and key wrapping, both keyed on the PRF secret this
|
||||
// assertion just produced. The secret is used here and dropped; it is
|
||||
// never stored on this side, and it must never be logged — unlike a
|
||||
// signature it does not expire, so one copy in a proxy log or a HAR file
|
||||
// is permanent access to the wrapped blob.
|
||||
//
|
||||
// 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.
|
||||
// wrap yet), then wrap. Both are best-effort, because the assertion itself
|
||||
// is valid either way.
|
||||
if h.encryptFn != nil {
|
||||
secret, err := webauthn.DecodePRFResult(body.PRF)
|
||||
switch {
|
||||
case err != nil:
|
||||
if secret, err := webauthn.DecodePRFResult(body.PRF); err != nil {
|
||||
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
|
||||
default:
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.encryptFn.Unlock(ctx, secret); err != nil {
|
||||
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
||||
} else {
|
||||
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
||||
}
|
||||
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)
|
||||
}
|
||||
h.coldStart(ctx, credID, secret, body.Explicit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,3 +285,56 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("webauthn: asserted credential %s", credID)
|
||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||
}
|
||||
|
||||
// coldStart unlocks a locked daemon with this assertion's PRF output and then
|
||||
// asks it to wrap the at-rest key. Never fatal: a locked or unreachable daemon
|
||||
// does not invalidate the step-up.
|
||||
//
|
||||
// # The legacy retry
|
||||
//
|
||||
// A box enrolled before Vikunja #14 has a v1 blob, wrapped under the
|
||||
// credential PUBLIC key. The PRF secret cannot open it, and this handler is
|
||||
// the only caller of Unlock, so without a second attempt that box could never
|
||||
// cold-start again: it would sit locked while a perfectly good passkey was
|
||||
// asserted, and the only way back in would be putting MAVEN_DB_KEY into the
|
||||
// environment — the exact thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// So a failed PRF unlock is retried with the public key from the credential
|
||||
// store. That is not a weaker fallback being offered to new deployments:
|
||||
// nothing writes v1 any more, and a v2 blob does not open under a public key
|
||||
// either. It is a one-way door out of the old format, and the operator is told
|
||||
// to walk through it.
|
||||
func (h *PasskeyHandle) coldStart(ctx context.Context, credID string, secret []byte, explicit bool) {
|
||||
legacy := false
|
||||
err := h.encryptFn.Unlock(ctx, secret)
|
||||
if err != nil && !errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
if pub, _, lerr := h.store.Lookup(credID); lerr == nil && len(pub) > 0 {
|
||||
if err2 := h.encryptFn.Unlock(ctx, pub); err2 == nil {
|
||||
err, legacy = nil, true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
// Env-key mode: the daemon was never locked and has no UnlockFn. Not
|
||||
// a failure, and the old code logged it as one on every assertion.
|
||||
case err != nil:
|
||||
log.Printf("webauthn: unlock via credential %s failed: %v", credID, err)
|
||||
case legacy:
|
||||
log.Printf("SECURITY: webauthn: daemon unlocked from a LEGACY v1 wrapped key using credential %s. That blob is derived from the credential public key, which sits in passkeys.json beside it, so it protects nothing. Press \"rewrite cold-start key\" on this page to replace it with a v2 blob.", credID)
|
||||
default:
|
||||
log.Printf("webauthn: daemon reports unlocked, credential %s", credID)
|
||||
}
|
||||
|
||||
// explicit=false means "write the blob only if there is none". The daemon
|
||||
// enforces that; sending the flag is the whole of this side's part in it.
|
||||
switch err := h.encryptFn.StoreEncryptionKey(ctx, secret, explicit); {
|
||||
case err == nil && explicit:
|
||||
log.Printf("webauthn: cold-start key rewritten under credential %s", credID)
|
||||
case err == nil:
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
// No key to wrap: a plaintext dev store, or a daemon still locked.
|
||||
default:
|
||||
log.Printf("webauthn: wrap encryption key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user