Files
Maven/cmd/mavweb/webauthn.go
T
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

275 lines
11 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
// assertIPC — satisfies the AssertStepUp caller shape. The only implementation
// is *ipc.Client; in-process CoreAPI adapters return ErrUnknownMethod.
type assertIPC interface {
AssertStepUp(ctx context.Context) error
}
// keyIPC — satisfies the key-wrap and unlock methods. The only implementation
// 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
Unlock(ctx context.Context, secret []byte) error
}
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
// store, and the IPC client used to assert step-up and to wrap/unwrap the
// daemon's encryption key. It serves the four WebAuthn HTTP endpoints
// (register/begin, register/finish, assert/begin, assert/finish).
//
// Credentials are kept in-memory only (a single-user daemon restarts
// infrequently, and re-enrolling after restart is acceptable). A future
// version may persist them to disk.
type PasskeyHandle struct {
rp *webauthn.RP
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
encryptFn keyIPC // *ipc.Client when connected; nil ⇒ key wrap/unlock disabled
store *credentialStore
session *webauthn.PasskeySession
}
type localCred struct {
PublicKey []byte
SignCount int64
}
func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, session *webauthn.PasskeySession) (*PasskeyHandle, error) {
var af assertIPC
if c, ok := core.(assertIPC); ok {
af = c
}
var ek keyIPC
if c, ok := core.(keyIPC); ok {
ek = c
}
store, err := newCredentialStore(storePath)
if err != nil {
return nil, fmt.Errorf("credential store: %w", err)
}
return &PasskeyHandle{
rp: webauthn.NewRP(cfg),
assertFn: af,
encryptFn: ek,
store: store,
session: session,
}, nil
}
// Page serves the passkey enrollment + step-up UI. It's the only surface that
// can perform a WebAuthn gesture, so it's the gate the /tools enable depends
// on: assert here (bumps the daemon session to L3 for the assertion TTL), then
// enable a tool on /tools within that window.
func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
passkeyTmpl.Execute(w, nil)
}
// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom.
const passkeyPageHTML = `{{template "shellTop" "passkey"}}
<h1>Passkey</h1>
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
<div class=flex gap-2>
<button class=btn onclick=enroll()>enroll passkey</button>
<button class=btn onclick=assert()>assert (step-up)</button>
<a href=/tools><button class=btn-primary>→ tools</button></a>
</div>
<div id=msg></div>
{{template "shellBottom"}}
<script>
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
async function enroll(){try{
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
options.challenge=ub64(options.challenge);
options.user.id=ub64(options.user.id);
const c=await navigator.credentials.create({publicKey:options});
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)}}})});
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,prf,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);
}catch(e){say('assert error: '+e,false);}}
</script>`
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")
if err != nil {
log.Printf("webauthn: register begin: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"challenge": challenge, "options": opts})
}
func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Challenge string `json:"challenge"`
Credential map[string]any `json:"credential"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
save := func(id string, publicKey []byte, _ []byte, _ string) error {
return h.store.Save(id, publicKey)
}
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
if err != nil {
log.Printf("webauthn: register finish: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("webauthn: registered 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})
}
func (h *PasskeyHandle) AssertBegin(w http.ResponseWriter, r *http.Request) {
opts, challenge, err := h.rp.AssertionOptions()
if err != nil {
log.Printf("webauthn: assert begin: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"challenge": challenge, "options": opts})
}
func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
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)
return
}
lookup := func(id string) ([]byte, int64, error) {
return h.store.Lookup(id)
}
update := func(id string, count int64) error {
return h.store.UpdateSignCount(id, count)
}
credID, err := h.rp.FinishAssertion(lookup, update, body.Challenge, body.Credential)
if err != nil {
log.Printf("webauthn: assert finish: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Assert step-up on the IPC (mavend) side so subsequent EnableTool calls
// see L3. Best-effort: if IPC fails (no -core or mavend unreachable), the
// user still sees success but the enable will fail with AuthStepUp.
if h.assertFn != nil {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := h.assertFn.AssertStepUp(ctx); err != nil {
log.Printf("webauthn: assert step-up: %v", err)
http.Error(w, "step-up assertion failed", http.StatusBadGateway)
return
}
}
// 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 {
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, 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)
}
}
}
// Assert the in-process session so the POST /tools handler sees step-up.
if h.session != nil {
h.session.Assert(r.Context(), auth.Scope{})
}
log.Printf("webauthn: asserted credential %s", credID)
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
}