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:
kami
2026-08-01 14:05:13 +04:00
parent 7f42cc73be
commit 7ab9b48259
13 changed files with 654 additions and 65 deletions
+119 -3
View File
@@ -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)
}
}
}