Merge branch 'fix/g09' into fix/integrated
This commit is contained in:
@@ -32,17 +32,28 @@ type fakeKeyIPC struct {
|
||||
unlockCalls int
|
||||
wrapCalls int
|
||||
unlockErr error
|
||||
wrapExplicit bool
|
||||
// opensWith, when set, is the only secret Unlock accepts. It stands in
|
||||
// for a wrapped blob on disk: everything else gets unlockErr.
|
||||
opensWith []byte
|
||||
}
|
||||
|
||||
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
|
||||
f.unlockCalls++
|
||||
f.unlockSecret = bytes.Clone(secret)
|
||||
if f.opensWith != nil {
|
||||
if bytes.Equal(secret, f.opensWith) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("unwrap key: decrypt failed (wrong credential?)")
|
||||
}
|
||||
return f.unlockErr
|
||||
}
|
||||
|
||||
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
|
||||
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte, explicit bool) error {
|
||||
f.wrapCalls++
|
||||
f.wrapSecret = bytes.Clone(secret)
|
||||
f.wrapExplicit = explicit
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,6 +148,13 @@ func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
|
||||
// assert drives POST /assert/finish with a valid assertion and the given
|
||||
// base64url PRF result.
|
||||
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return a.assertExplicit(t, h, prf, false)
|
||||
}
|
||||
|
||||
// assertExplicit is assert with control over the explicit flag the rewrite
|
||||
// button sets.
|
||||
func (a *prfAuthenticator) assertExplicit(t *testing.T, h *PasskeyHandle, prf string, explicit bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
_, chal, err := h.rp.AssertionOptions()
|
||||
if err != nil {
|
||||
@@ -152,6 +170,7 @@ func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *h
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"challenge": chal,
|
||||
"prf": prf,
|
||||
"explicit": explicit,
|
||||
"credential": map[string]any{
|
||||
"id": b64u(a.credID),
|
||||
"type": "public-key",
|
||||
@@ -253,8 +272,8 @@ func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 1 {
|
||||
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||
if key.unlockCalls == 0 {
|
||||
t.Error("unlock was never attempted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,3 +310,100 @@ func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 the legacy retry that box stays locked forever
|
||||
// while a perfectly good passkey is asserted at it.
|
||||
func TestLegacyV1BlobStillColdStarts(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
pub, _, err := h.store.Lookup(b64u(auth.credID))
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
// The daemon only opens under the public key — a v1 blob.
|
||||
key.opensWith = pub
|
||||
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 2 {
|
||||
t.Fatalf("unlock attempted %d times, want 2 (PRF, then the legacy public key)", key.unlockCalls)
|
||||
}
|
||||
if !bytes.Equal(key.unlockSecret, pub) {
|
||||
t.Fatal("the legacy retry did not send the credential public key, so a v1 box can never cold-start again")
|
||||
}
|
||||
}
|
||||
|
||||
// The PRF secret is tried first and, when it works, the public key is never
|
||||
// sent. The legacy retry is a one-way door out of v1, not a fallback offered
|
||||
// to every assertion.
|
||||
func TestPRFUnlockNeverFallsBackWhenItWorks(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{7}, 32)
|
||||
key := &fakeKeyIPC{opensWith: secret}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 1 {
|
||||
t.Fatalf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping the at-rest key is an explicit act, never a side effect of a
|
||||
// step-up. A page POSTing a substituted prf on a routine assertion must not
|
||||
// make the daemon re-wrap the database key under it.
|
||||
func TestPlainAssertionAsksForNoRewrite(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assert(t, h, b64u(bytes.Repeat([]byte{5}, 32))); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.wrapCalls != 1 {
|
||||
t.Fatalf("wrapCalls = %d, want 1", key.wrapCalls)
|
||||
}
|
||||
if key.wrapExplicit {
|
||||
t.Fatal("a plain step-up asked the daemon to rewrite the cold-start key")
|
||||
}
|
||||
}
|
||||
|
||||
// The rewrite button, and only the rewrite button, sets explicit.
|
||||
func TestRewriteButtonAsksForAnExplicitWrap(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assertExplicit(t, h, b64u(bytes.Repeat([]byte{6}, 32)), true); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !key.wrapExplicit {
|
||||
t.Fatal("the explicit flag did not reach the daemon, so the rewrite button cannot work")
|
||||
}
|
||||
}
|
||||
|
||||
// The page is the only place the explicit flag originates. If the button or
|
||||
// the field goes away, rewriting a cold-start key becomes impossible with
|
||||
// nothing failing.
|
||||
func TestPasskeyPageHasTheRewriteButton(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"rewrite cold-start key",
|
||||
"explicit:!!explicit",
|
||||
"async function rewrapKey()",
|
||||
} {
|
||||
if !strings.Contains(passkeyPageHTML, want) {
|
||||
t.Errorf("the passkey page no longer contains %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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