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
This commit is contained in:
+52
-33
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user