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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user