Files
Warp/server/internal/keyserver/remotekeys_test.go
T
tajniak81andClaude Opus 5.5 74f7bc8805
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
keyserver: cache remote users' device and cross-signing keys
POST /keys/query for a remote user who shares a room with a local user is
now answered from a cache, fetched whole with GET /user/devices/{userId}
on a miss. Incoming m.device_list_update EDUs are applied when their
prev_id matches the cached stream_id; anything else marks the copy stale
and it is fetched again in the background, and a stale copy is never
served. m.signing_key_update replaces the cached cross-signing keys. A
user's copy is dropped once no local user shares a room with them, by a
keyserver listener on the roomserver's emit stage.

Migration 0009 adds keyserver_remote_users, keyserver_remote_device_keys
and keyserver_remote_cross_signing_keys for sqlite and postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:36:08 +02:00

310 lines
11 KiB
Go

package keyserver_test
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/keyserver"
)
// snapshotRemote is remote.example with Carol's device list in it, and a
// count of how often it was asked.
type snapshotRemote struct {
mu sync.Mutex
snapshot federation.UserDevicesResponse
fetches int
queries int
down bool
}
func (r *snapshotRemote) QueryUserKeys(context.Context, string, federation.UserKeysQueryRequest) (federation.UserKeysQueryResponse, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.queries++
return federation.UserKeysQueryResponse{}, nil
}
func (r *snapshotRemote) ClaimUserKeys(context.Context, string, federation.UserKeysClaimRequest) (federation.UserKeysClaimResponse, error) {
return federation.UserKeysClaimResponse{}, errors.New("unreachable")
}
func (r *snapshotRemote) UserDevices(_ context.Context, _, userID string) (federation.UserDevicesResponse, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.fetches++
if r.down {
return federation.UserDevicesResponse{}, &federation.HTTPError{Status: 502}
}
out := r.snapshot
out.UserID = userID
return out, nil
}
func (r *snapshotRemote) set(stream int64, devices ...federation.UserDevice) {
r.mu.Lock()
defer r.mu.Unlock()
r.snapshot.StreamID = stream
r.snapshot.Devices = devices
}
func (r *snapshotRemote) counts() (fetches, queries int) {
r.mu.Lock()
defer r.mu.Unlock()
return r.fetches, r.queries
}
func queryCarol(t *testing.T, h harness) keyserver.QueryResponse {
t.Helper()
resp, err := h.keys.QueryKeys(t.Context(), alice, keyserver.QueryRequest{DeviceKeys: map[string][]string{carol: nil}})
if err != nil {
t.Fatal(err)
}
return resp
}
func carolDevice(t *testing.T, deviceID string) federation.UserDevice {
t.Helper()
_, keys := device(t, carol, deviceID)
return federation.UserDevice{DeviceID: deviceID, Keys: keys, DeviceDisplayName: "Carol's " + deviceID}
}
func TestRemoteKeysAreCached(t *testing.T) {
remote := &snapshotRemote{}
master := newKey(t)
remote.snapshot.MasterKey = signed(t, carol, master, crossSigningKey(carol, "master", master))
remote.set(5, carolDevice(t, "C1"),
// A device whose keys name somebody else is not Carol's.
federation.UserDevice{DeviceID: "FAKE", Keys: json.RawMessage(`{"user_id":"@mallory:remote.example","device_id":"FAKE"}`)})
h := newHarness(t, remote)
first := queryCarol(t, h)
second := queryCarol(t, h)
if fetches, queries := remote.counts(); fetches != 1 || queries != 0 {
t.Errorf("two queries made %d fetches and %d key queries, want 1 and 0", fetches, queries)
}
for i, resp := range []keyserver.QueryResponse{first, second} {
raw, ok := resp.DeviceKeys[carol]["C1"]
if !ok {
t.Fatalf("query %d: device keys = %v", i, resp.DeviceKeys)
}
var keys struct {
Unsigned map[string]string `json:"unsigned"`
}
_ = json.Unmarshal(raw, &keys)
if keys.Unsigned["device_display_name"] != "Carol's C1" {
t.Errorf("query %d: C1 = %s, want its display name", i, raw)
}
if _, ok := resp.DeviceKeys[carol]["FAKE"]; ok {
t.Errorf("query %d: a device with another user's keys was served", i)
}
if resp.MasterKeys[carol] == nil {
t.Errorf("query %d: master keys = %v", i, resp.MasterKeys)
}
}
// Asking for one device is answered from the same copy.
resp, _ := h.keys.QueryKeys(t.Context(), alice, keyserver.QueryRequest{DeviceKeys: map[string][]string{carol: {"NOPE"}}})
if len(resp.DeviceKeys[carol]) != 0 {
t.Errorf("a query for a device Carol does not have = %v", resp.DeviceKeys[carol])
}
// A stranger is not cached: every query for them is a key query.
for range 2 {
_, _ = h.keys.QueryKeys(t.Context(), alice, keyserver.QueryRequest{DeviceKeys: map[string][]string{dave: nil}})
}
if fetches, queries := remote.counts(); fetches != 1 || queries != 2 {
t.Errorf("after two queries for a stranger: %d fetches, %d key queries; want 1 and 2", fetches, queries)
}
if _, ok, _ := h.store.RemoteKeys(t.Context(), dave); ok {
t.Error("a stranger's keys were cached")
}
}
func TestRemoteDeviceListUpdatesKeepTheCacheCurrent(t *testing.T) {
ctx := t.Context()
remote := &snapshotRemote{}
remote.set(5, carolDevice(t, "C1"))
h := newHarness(t, remote)
queryCarol(t, h)
// An update that follows on from the copy is applied to it.
c2 := carolDevice(t, "C2")
if err := h.keys.RemoteDeviceListUpdate(ctx, federation.DeviceListUpdateContent{
UserID: carol, DeviceID: "C2", StreamID: 6, PrevID: []int64{5}, Keys: c2.Keys,
}); err != nil {
t.Fatal(err)
}
if resp := queryCarol(t, h); resp.DeviceKeys[carol]["C2"] == nil || resp.DeviceKeys[carol]["C1"] == nil {
t.Errorf("after the update: %v, want C1 and C2", resp.DeviceKeys[carol])
}
if fetches, _ := remote.counts(); fetches != 1 {
t.Errorf("an update that followed on caused %d fetches, want 1", fetches)
}
pos, _ := h.store.DeviceListPosition(ctx, carol)
if pos == 0 {
t.Error("the update did not move Carol's device list")
}
// The same update again changes nothing, not even the stream.
if err := h.keys.RemoteDeviceListUpdate(ctx, federation.DeviceListUpdateContent{
UserID: carol, DeviceID: "C2", StreamID: 6, PrevID: []int64{5}, Keys: c2.Keys,
}); err != nil {
t.Fatal(err)
}
if again, _ := h.store.DeviceListPosition(ctx, carol); again != pos {
t.Errorf("a repeated update moved the device list from %d to %d", pos, again)
}
// A deletion that follows on.
if err := h.keys.RemoteDeviceListUpdate(ctx, federation.DeviceListUpdateContent{
UserID: carol, DeviceID: "C1", StreamID: 7, PrevID: []int64{6}, Deleted: true,
}); err != nil {
t.Fatal(err)
}
if resp := queryCarol(t, h); resp.DeviceKeys[carol]["C1"] != nil {
t.Errorf("a deleted device is still served: %v", resp.DeviceKeys[carol])
}
// An update after a gap marks the copy stale, and it is fetched again
// in the background.
remote.set(10, carolDevice(t, "C3"))
if err := h.keys.RemoteDeviceListUpdate(ctx, federation.DeviceListUpdateContent{
UserID: carol, DeviceID: "C3", StreamID: 10, PrevID: []int64{9}, Keys: json.RawMessage(`{}`),
}); err != nil {
t.Fatal(err)
}
h.keys.WaitIdle()
if fetches, _ := remote.counts(); fetches != 2 {
t.Errorf("after a gap there were %d fetches, want 2", fetches)
}
cached, ok, _ := h.store.RemoteKeys(ctx, carol)
if !ok || cached.Stale || cached.StreamID != 10 || len(cached.Devices) != 1 || cached.Devices[0].DeviceID != "C3" {
t.Errorf("after the resync the cache holds %+v", cached)
}
resp := queryCarol(t, h)
if resp.DeviceKeys[carol]["C3"] == nil || len(resp.DeviceKeys[carol]) != 1 {
t.Errorf("after the resync: %v, want C3 alone", resp.DeviceKeys[carol])
}
if fetches, _ := remote.counts(); fetches != 2 {
t.Errorf("a query after the resync fetched again: %d fetches", fetches)
}
}
func TestAStaleCopyIsNotServed(t *testing.T) {
ctx := t.Context()
remote := &snapshotRemote{}
remote.set(5, carolDevice(t, "C1"))
h := newHarness(t, remote)
queryCarol(t, h)
// With Carol's server down, the background fetch fails and the copy
// stays stale; a query then asks again rather than serve it.
remote.mu.Lock()
remote.down = true
remote.mu.Unlock()
if err := h.keys.RemoteDeviceListUpdate(ctx, federation.DeviceListUpdateContent{
UserID: carol, DeviceID: "C2", StreamID: 8, PrevID: []int64{7},
}); err != nil {
t.Fatal(err)
}
h.keys.WaitIdle()
resp := queryCarol(t, h)
if failure, ok := resp.Failures["remote.example"].(map[string]any); !ok || failure["status"] != 502 {
t.Errorf("failures = %v, want a 502 for remote.example", resp.Failures)
}
if fetches, _ := remote.counts(); fetches != 3 {
t.Errorf("%d fetches, want the first, the background one and the query's", fetches)
}
}
func TestRemoteSigningKeyUpdatesReplaceTheCrossSigningKeys(t *testing.T) {
ctx := t.Context()
remote := &snapshotRemote{}
oldMaster, newMaster, selfSigning := newKey(t), newKey(t), newKey(t)
remote.snapshot.MasterKey = signed(t, carol, oldMaster, crossSigningKey(carol, "master", oldMaster))
remote.snapshot.SelfSigningKey = signed(t, carol, oldMaster, crossSigningKey(carol, "self_signing", selfSigning))
remote.set(1, carolDevice(t, "C1"))
h := newHarness(t, remote)
queryCarol(t, h)
if err := h.keys.RemoteSigningKeyUpdate(ctx, federation.SigningKeyUpdateContent{
UserID: carol,
MasterKey: signed(t, carol, newMaster, crossSigningKey(carol, "master", newMaster)),
}); err != nil {
t.Fatal(err)
}
resp := queryCarol(t, h)
var master struct {
Keys map[string]string `json:"keys"`
}
_ = json.Unmarshal(resp.MasterKeys[carol], &master)
if master.Keys["ed25519:"+newMaster.PublicBase64()] == "" {
t.Errorf("master key after the update = %s, want the new one", resp.MasterKeys[carol])
}
if resp.SelfSigningKeys[carol] != nil {
t.Errorf("a self-signing key the update left out is still served: %s", resp.SelfSigningKeys[carol])
}
if fetches, _ := remote.counts(); fetches != 1 {
t.Errorf("a signing key update caused %d fetches, want 1", fetches)
}
if !h.notifier.wasWoken(alice) {
t.Error("alice was not told Carol's keys changed")
}
}
func TestCachedKeysOfStrangersAreDropped(t *testing.T) {
ctx := t.Context()
remote := &snapshotRemote{}
remote.set(1, carolDevice(t, "C1"))
h := newHarness(t, remote)
queryCarol(t, h)
// Alice leaving a room is not enough while Carol still shares another
// one with her.
h.keys.MembershipLeft(alice)
h.keys.WaitIdle()
if _, ok, _ := h.store.RemoteKeys(ctx, carol); !ok {
t.Fatal("the cache was dropped while Carol still shares a room with alice")
}
h.rooms.set(carol, carol)
h.keys.MembershipLeft(carol)
h.keys.WaitIdle()
if _, ok, _ := h.store.RemoteKeys(ctx, carol); ok {
t.Error("Carol's keys are still cached after she stopped sharing a room with anybody here")
}
if users, _ := h.store.RemoteKeyUsers(ctx); len(users) != 0 {
t.Errorf("users with cached keys = %v", users)
}
}
func TestCachedKeysAreDroppedWhenALocalUserLeaves(t *testing.T) {
ctx := t.Context()
remote := &snapshotRemote{}
remote.set(1, carolDevice(t, "C1"))
h := newHarness(t, remote)
queryCarol(t, h)
// Alice left the room she shared with Carol: everybody cached is
// checked, since it is not known who else was in it.
h.rooms.set(carol, carol)
h.keys.MembershipLeft(alice)
h.keys.WaitIdle()
if _, ok, _ := h.store.RemoteKeys(ctx, carol); ok {
t.Error("Carol's keys are still cached after the last local user left")
}
// And a query for her now passes through.
queryCarol(t, h)
if fetches, queries := remote.counts(); fetches != 1 || queries != 1 {
t.Errorf("%d fetches and %d key queries, want 1 and 1", fetches, queries)
}
if _, ok, _ := h.store.RemoteKeys(ctx, carol); ok {
t.Error("a stranger's keys were cached")
}
}