Files
Maven/internal/ipc/unlock_test.go
T
kami 7ab9b48259 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.
2026-08-01 14:05:13 +04:00

138 lines
4.7 KiB
Go

package ipc
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
)
// The wire must carry the PRF secret, not the credential public key. This is
// the field rename that fixes Vikunja #14: a v1 deployment sent "public_key",
// and the value it sent was in passkeys.json next to the wrapped blob.
func TestUnlockWireCarriesSecret(t *testing.T) {
secret := bytes.Repeat([]byte{7}, 32)
for _, p := range []any{unlockReq{Secret: secret}, storeEncryptionKeyReq{Secret: secret}} {
b, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal %T: %v", p, err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal %T: %v", p, err)
}
if _, ok := m["secret"]; !ok {
t.Errorf("%T has no \"secret\" field: %s", p, b)
}
if _, ok := m["public_key"]; ok {
t.Errorf("%T still sends \"public_key\": %s", p, b)
}
}
}
// The secret must reach the daemon hook byte-for-byte through the socket.
func TestUnlockDeliversSecretToHook(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
secret := make([]byte, 32)
for i := range secret {
secret[i] = byte(i + 1)
}
var gotUnlock, gotWrap []byte
var gotExplicit bool
srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil }
srv.WrapKeyFn = func(_ context.Context, s []byte, explicit bool) error { gotWrap = bytes.Clone(s); gotExplicit = explicit; return nil }
ctx := context.Background()
if err := cli.Unlock(ctx, secret); err != nil {
t.Fatalf("Unlock: %v", err)
}
if !bytes.Equal(gotUnlock, secret) {
t.Errorf("UnlockFn got %x, want %x", gotUnlock, secret)
}
if err := cli.StoreEncryptionKey(ctx, secret, true); err != nil {
t.Fatalf("StoreEncryptionKey: %v", err)
}
if !bytes.Equal(gotWrap, secret) {
t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret)
}
// The explicit flag rides the same request. Without it the daemon cannot
// tell "he asked for the cold-start key to be rewritten" from "a passkey
// was asserted", and rewrites the blob on every step-up.
if !gotExplicit {
t.Error("WrapKeyFn got explicit=false, want the flag to cross the wire")
}
if err := cli.StoreEncryptionKey(ctx, secret, false); err != nil {
t.Fatalf("StoreEncryptionKey: %v", err)
}
if gotExplicit {
t.Error("WrapKeyFn got explicit=true for an implicit wrap")
}
}
// A refusal from the daemon hook — a wrong passkey, or no prior assertion —
// must surface to the caller as an error, never be swallowed into success.
func TestUnlockPropagatesRefusal(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
srv.UnlockFn = func(context.Context, []byte) error {
return errors.New("unlock: no verified passkey assertion (assert first)")
}
if err := cli.Unlock(context.Background(), bytes.Repeat([]byte{9}, 32)); err == nil {
t.Fatal("a refused unlock reported success")
}
}
// Without the hooks wired — the normal, unencrypted deployment — both methods
// answer ErrUnknownMethod rather than pretending to have done something.
func TestUnlockUnwiredIsUnknownMethod(t *testing.T) {
_, _, cli, _ := newServerWithStore(t)
ctx := context.Background()
if err := cli.Unlock(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
t.Error("Unlock succeeded with no UnlockFn wired")
}
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32), false); err == nil {
t.Error("StoreEncryptionKey succeeded with no WrapKeyFn wired")
}
}
// Locked mode: Server.Check is the whole authorization surface, and it must
// default-deny everything except the two methods the unlock flow needs.
func TestLockedCheckDefaultDenies(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
locked := errors.New("locked")
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
switch m {
case MethodAssertStepUp, MethodUnlock:
return nil
default:
return locked
}
}
unlocked := false
srv.UnlockFn = func(context.Context, []byte) error { unlocked = true; return nil }
srv.StepUp = func(context.Context) error { return nil }
srv.WrapKeyFn = func(context.Context, []byte, bool) error { return nil }
ctx := context.Background()
// A store method must be refused while locked.
if _, err := cli.RecentNotes(ctx, 5); err == nil {
t.Error("a store read went through while locked")
}
// Key wrapping is NOT on the allowlist: a locked daemon has no key to wrap.
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32), false); err == nil {
t.Error("StoreEncryptionKey was allowed while locked")
}
// The unlock flow itself must still work.
if err := cli.AssertStepUp(ctx); err != nil {
t.Errorf("AssertStepUp refused while locked: %v", err)
}
if err := cli.Unlock(ctx, bytes.Repeat([]byte{3}, 32)); err != nil {
t.Errorf("Unlock refused while locked: %v", err)
}
if !unlocked {
t.Error("UnlockFn never ran")
}
}