Files
Maven/internal/ipc/unlock_test.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

125 lines
4.2 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
srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil }
srv.WrapKeyFn = func(_ context.Context, s []byte) error { gotWrap = bytes.Clone(s); 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); err != nil {
t.Fatalf("StoreEncryptionKey: %v", err)
}
if !bytes.Equal(gotWrap, secret) {
t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret)
}
}
// 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)); 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) 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)); 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")
}
}