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") } }