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") } } // A locked daemon has to be able to say it is alive. Every CoreAPI method is // refused before unlock, so a health check built on one of those cannot tell a // daemon waiting for a passkey apart from a daemon that failed to start. That // is what turned a good update into the manual-recovery case in // internal/update. MethodPing reaches no store, so it answers either way. func TestPingAnswersWhileLocked(t *testing.T) { _, srv, cli, _ := newServerWithStore(t) srv.LockedFn = func() bool { return true } locked := errors.New("daemon locked") srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error { switch m { case MethodAssertStepUp, MethodUnlock, MethodPing: return nil default: return locked } } ctx := context.Background() p, err := cli.Ping(ctx) if err != nil { t.Fatalf("Ping while locked: %v", err) } if !p.Alive || !p.Locked { t.Errorf("Ping = %+v; want alive and locked", p) } // And the read it replaces is still refused, which is the whole point. if _, err := cli.Presence(ctx); err == nil { t.Error("Presence answered while locked") } srv.LockedFn = func() bool { return false } if p, err := cli.Ping(ctx); err != nil || p.Locked { t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err) } }