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
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>
382 lines
16 KiB
Go
382 lines
16 KiB
Go
package setup_test
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
|
"github.com/dariuszjaniak/warp/server/internal/setup"
|
|
)
|
|
|
|
// The Phase 7 tests: end-to-end encryption's server half, through the
|
|
// whole stack.
|
|
|
|
// sendJSON sends a JSON body with any method.
|
|
func sendJSON(t *testing.T, srv *setup.Server, method, path string, body any, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return send(t, srv, method, path, "application/json", raw, token, nil)
|
|
}
|
|
|
|
func mustOK(t *testing.T, rec *httptest.ResponseRecorder, what string) map[string]any {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d: %s", what, rec.Code, rec.Body.String())
|
|
}
|
|
return decode[map[string]any](t, rec)
|
|
}
|
|
|
|
func deviceIDOf(t *testing.T, srv *setup.Server, token string) string {
|
|
t.Helper()
|
|
whoami := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", token), "whoami")
|
|
deviceID, _ := whoami["device_id"].(string)
|
|
return deviceID
|
|
}
|
|
|
|
// signedObject signs a JSON object as userID with an ed25519 key named
|
|
// keyName.
|
|
func signedObject(t *testing.T, userID, keyName string, priv ed25519.PrivateKey, obj map[string]any) map[string]any {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(obj)
|
|
out, err := signing.SignJSON(userID, &signing.KeyPair{ID: keyName, Private: priv}, raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var signed map[string]any
|
|
_ = json.Unmarshal(out, &signed)
|
|
return signed
|
|
}
|
|
|
|
func newEd25519(t *testing.T) (string, ed25519.PrivateKey) {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.RawStdEncoding.EncodeToString(pub), priv
|
|
}
|
|
|
|
// uploadDeviceKeys uploads a device's identity keys and some one-time
|
|
// keys, and returns the response.
|
|
func uploadDeviceKeys(t *testing.T, srv *setup.Server, token, userID, deviceID string, otks int) map[string]any {
|
|
t.Helper()
|
|
pub, priv := newEd25519(t)
|
|
deviceKeys := signedObject(t, userID, deviceID, priv, map[string]any{
|
|
"user_id": userID,
|
|
"device_id": deviceID,
|
|
"algorithms": []string{"m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"},
|
|
"keys": map[string]string{
|
|
"ed25519:" + deviceID: pub,
|
|
"curve25519:" + deviceID: "curve" + deviceID,
|
|
},
|
|
})
|
|
oneTime := map[string]any{}
|
|
for i := range otks {
|
|
oneTime["signed_curve25519:"+deviceID+string(rune('a'+i))] = map[string]any{"key": "otk" + string(rune('a'+i))}
|
|
}
|
|
return mustOK(t, post(t, srv, "/_matrix/client/v3/keys/upload", map[string]any{
|
|
"device_keys": deviceKeys,
|
|
"one_time_keys": oneTime,
|
|
"fallback_keys": map[string]any{"signed_curve25519:fb" + deviceID: map[string]any{"key": "fb", "fallback": true}},
|
|
}, token), "keys/upload")
|
|
}
|
|
|
|
// syncResponse is the part of a sync these tests read.
|
|
type syncResponse struct {
|
|
NextBatch string `json:"next_batch"`
|
|
DeviceLists struct {
|
|
Changed []string `json:"changed"`
|
|
Left []string `json:"left"`
|
|
} `json:"device_lists"`
|
|
OTKCounts map[string]int `json:"device_one_time_keys_count"`
|
|
UnusedFallback *[]string `json:"device_unused_fallback_key_types"`
|
|
}
|
|
|
|
func syncOnce(t *testing.T, srv *setup.Server, token, since string) syncResponse {
|
|
t.Helper()
|
|
path := "/_matrix/client/v3/sync?timeout=0"
|
|
if since != "" {
|
|
path += "&since=" + url.QueryEscape(since)
|
|
}
|
|
rec := getWithToken(t, srv, path, token)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("sync: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
return decode[syncResponse](t, rec)
|
|
}
|
|
|
|
func createPublicRoom(t *testing.T, srv *setup.Server, token string) string {
|
|
t.Helper()
|
|
resp := mustOK(t, post(t, srv, "/_matrix/client/v3/createRoom", map[string]any{"preset": "public_chat"}, token), "createRoom")
|
|
roomID, _ := resp["room_id"].(string)
|
|
return roomID
|
|
}
|
|
|
|
func TestEndToEndEncryptionThroughTheAPI(t *testing.T) {
|
|
srv := newSigningServer(t, nil)
|
|
aliceToken := registerThrough(t, srv, "alice")
|
|
bobToken := registerThrough(t, srv, "bob")
|
|
const alice, bob = "@alice:warp.example", "@bob:warp.example"
|
|
aliceDevice := deviceIDOf(t, srv, aliceToken)
|
|
|
|
roomID := createPublicRoom(t, srv, aliceToken)
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", map[string]any{}, bobToken), "join")
|
|
bobSince := syncOnce(t, srv, bobToken, "").NextBatch
|
|
|
|
// Alice uploads her keys, and is told how many one-time keys she has.
|
|
counts, _ := uploadDeviceKeys(t, srv, aliceToken, alice, aliceDevice, 3)["one_time_key_counts"].(map[string]any)
|
|
if counts["signed_curve25519"] != float64(3) {
|
|
t.Errorf("one_time_key_counts = %v", counts)
|
|
}
|
|
|
|
// Bob's next sync says Alice's keys changed; Alice's carries her key
|
|
// counts, and both carry the fallback key list even when it is empty.
|
|
bobSync := syncOnce(t, srv, bobToken, bobSince)
|
|
if !contains(bobSync.DeviceLists.Changed, alice) {
|
|
t.Errorf("bob's device_lists.changed = %v, want alice", bobSync.DeviceLists.Changed)
|
|
}
|
|
if bobSync.UnusedFallback == nil || len(*bobSync.UnusedFallback) != 0 {
|
|
t.Errorf("bob's device_unused_fallback_key_types = %v, want []", bobSync.UnusedFallback)
|
|
}
|
|
if bobSync.OTKCounts["signed_curve25519"] != 0 {
|
|
t.Errorf("bob's one-time key count = %v", bobSync.OTKCounts)
|
|
}
|
|
aliceSync := syncOnce(t, srv, aliceToken, "")
|
|
if aliceSync.OTKCounts["signed_curve25519"] != 3 || aliceSync.UnusedFallback == nil || len(*aliceSync.UnusedFallback) != 1 {
|
|
t.Errorf("alice's key counts = %v, fallback %v", aliceSync.OTKCounts, aliceSync.UnusedFallback)
|
|
}
|
|
|
|
changes := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/keys/changes?from="+url.QueryEscape(bobSince), bobToken), "keys/changes")
|
|
if changed, _ := changes["changed"].([]any); len(changed) != 1 || changed[0] != alice {
|
|
t.Errorf("keys/changes = %v", changes)
|
|
}
|
|
|
|
// Bob fetches Alice's keys and claims one of her one-time keys.
|
|
query := mustOK(t, post(t, srv, "/_matrix/client/v3/keys/query", map[string]any{
|
|
"device_keys": map[string]any{alice: []string{}},
|
|
}, bobToken), "keys/query")
|
|
devices, _ := query["device_keys"].(map[string]any)[alice].(map[string]any)
|
|
if _, ok := devices[aliceDevice]; !ok {
|
|
t.Fatalf("keys/query = %v", query)
|
|
}
|
|
claim := mustOK(t, post(t, srv, "/_matrix/client/v3/keys/claim", map[string]any{
|
|
"one_time_keys": map[string]any{alice: map[string]string{aliceDevice: "signed_curve25519"}},
|
|
}, bobToken), "keys/claim")
|
|
if keys, _ := claim["one_time_keys"].(map[string]any)[alice].(map[string]any)[aliceDevice].(map[string]any); len(keys) != 1 {
|
|
t.Errorf("keys/claim = %v", claim)
|
|
}
|
|
|
|
// Cross-signing: the first upload needs no password, replacing the
|
|
// master key does.
|
|
masterPub, masterPriv := newEd25519(t)
|
|
sskPub, _ := newEd25519(t)
|
|
crossSigning := func(master string, priv ed25519.PrivateKey, ssk string) map[string]any {
|
|
return map[string]any{
|
|
"master_key": map[string]any{"user_id": alice, "usage": []string{"master"},
|
|
"keys": map[string]string{"ed25519:" + master: master}},
|
|
"self_signing_key": signedObject(t, alice, master, priv, map[string]any{
|
|
"user_id": alice, "usage": []string{"self_signing"},
|
|
"keys": map[string]string{"ed25519:" + ssk: ssk}}),
|
|
}
|
|
}
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/keys/device_signing/upload", crossSigning(masterPub, masterPriv, sskPub), aliceToken),
|
|
"first device_signing/upload")
|
|
|
|
newMaster, newMasterPriv := newEd25519(t)
|
|
body := crossSigning(newMaster, newMasterPriv, sskPub)
|
|
rec := post(t, srv, "/_matrix/client/v3/keys/device_signing/upload", body, aliceToken)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("replacing the master key without auth = %d, want 401: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body["auth"] = map[string]any{
|
|
"type": "m.login.password", "session": decode[map[string]any](t, rec)["session"],
|
|
"identifier": map[string]string{"type": "m.id.user", "user": "alice"}, "password": "sUp3rs3kr1t",
|
|
}
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/keys/device_signing/upload", body, aliceToken), "device_signing/upload with auth")
|
|
query = mustOK(t, post(t, srv, "/_matrix/client/v3/keys/query", map[string]any{
|
|
"device_keys": map[string]any{alice: []string{}},
|
|
}, bobToken), "keys/query after cross-signing")
|
|
if master, _ := query["master_keys"].(map[string]any)[alice].(map[string]any); master == nil ||
|
|
master["keys"].(map[string]any)["ed25519:"+newMaster] != newMaster {
|
|
t.Errorf("master_keys = %v, want the replacement", query["master_keys"])
|
|
}
|
|
|
|
// Key backup, version and keys.
|
|
version := mustOK(t, post(t, srv, "/_matrix/client/v3/room_keys/version", map[string]any{
|
|
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2", "auth_data": map[string]any{"public_key": "abc"},
|
|
}, aliceToken), "create backup")["version"].(string)
|
|
put := mustOK(t, sendJSON(t, srv, http.MethodPut, "/_matrix/client/v3/room_keys/keys/"+url.PathEscape(roomID)+"/sess1?version="+version,
|
|
map[string]any{"first_message_index": 0, "forwarded_count": 0, "is_verified": false,
|
|
"session_data": map[string]any{"ciphertext": "c"}}, aliceToken), "put room key")
|
|
if put["count"] != float64(1) {
|
|
t.Errorf("put room key = %v", put)
|
|
}
|
|
backup := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/room_keys/version", aliceToken), "get backup")
|
|
if backup["version"] != version || backup["count"] != float64(1) {
|
|
t.Errorf("backup = %v", backup)
|
|
}
|
|
keys := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/room_keys/keys?version="+version, aliceToken), "get room keys")
|
|
if rooms, _ := keys["rooms"].(map[string]any); rooms[roomID] == nil {
|
|
t.Errorf("room keys = %v", keys)
|
|
}
|
|
rec = sendJSON(t, srv, http.MethodPut, "/_matrix/client/v3/room_keys/keys?version=999", map[string]any{"rooms": map[string]any{}}, aliceToken)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("writing to an unknown backup version = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := getWithToken(t, srv, "/_matrix/client/v3/room_keys/version", bobToken); rec.Code != http.StatusNotFound {
|
|
t.Errorf("a user with no backup = %d, want 404", rec.Code)
|
|
}
|
|
|
|
// Logging in a second device, uploading its keys and deleting it: the
|
|
// keys go with the device, and Bob is told.
|
|
login := mustOK(t, post(t, srv, "/_matrix/client/v3/login", map[string]any{
|
|
"type": "m.login.password", "identifier": map[string]string{"type": "m.id.user", "user": "alice"},
|
|
"password": "sUp3rs3kr1t", "device_id": "PHONE",
|
|
}, ""), "login")
|
|
uploadDeviceKeys(t, srv, login["access_token"].(string), alice, "PHONE", 1)
|
|
bobSince = syncOnce(t, srv, bobToken, "").NextBatch
|
|
|
|
rec = sendJSON(t, srv, http.MethodDelete, "/_matrix/client/v3/devices/PHONE", map[string]any{}, aliceToken)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("delete device = %d, want 401", rec.Code)
|
|
}
|
|
mustOK(t, sendJSON(t, srv, http.MethodDelete, "/_matrix/client/v3/devices/PHONE", map[string]any{"auth": map[string]any{
|
|
"type": "m.login.password", "session": decode[map[string]any](t, rec)["session"],
|
|
"identifier": map[string]string{"type": "m.id.user", "user": "alice"}, "password": "sUp3rs3kr1t",
|
|
}}, aliceToken), "delete device with auth")
|
|
|
|
if got := syncOnce(t, srv, bobToken, bobSince); !contains(got.DeviceLists.Changed, alice) {
|
|
t.Errorf("after the device was deleted, bob's changed = %v", got.DeviceLists.Changed)
|
|
}
|
|
query = mustOK(t, post(t, srv, "/_matrix/client/v3/keys/query", map[string]any{
|
|
"device_keys": map[string]any{alice: []string{}},
|
|
}, bobToken), "keys/query after deletion")
|
|
if _, ok := query["device_keys"].(map[string]any)[alice].(map[string]any)["PHONE"]; ok {
|
|
t.Error("a deleted device's keys are still served")
|
|
}
|
|
|
|
// Bob leaving the only room they share puts him in Alice's left list.
|
|
aliceSince := syncOnce(t, srv, aliceToken, "").NextBatch
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", map[string]any{}, bobToken), "leave")
|
|
if got := syncOnce(t, srv, aliceToken, aliceSince); !contains(got.DeviceLists.Left, bob) {
|
|
t.Errorf("after bob left, alice's device_lists = %+v", got.DeviceLists)
|
|
}
|
|
}
|
|
|
|
func TestKeysOverFederation(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("starts two servers")
|
|
}
|
|
nodes := newFederation(t, "hs-a.example", "hs-b.example")
|
|
a, b := nodes["hs-a.example"].srv, nodes["hs-b.example"].srv
|
|
const alice, bob = "@alice:hs-a.example", "@bob:hs-b.example"
|
|
|
|
// Count the key requests that reach A, which is how the test tells a
|
|
// query B answered from its cache from one it passed on.
|
|
var keyRequests atomic.Int64
|
|
inner := *nodes["hs-a.example"].handler.Load()
|
|
counting := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/user/devices/") ||
|
|
r.URL.Path == "/_matrix/federation/v1/user/keys/query" {
|
|
keyRequests.Add(1)
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}))
|
|
nodes["hs-a.example"].handler.Store(&counting)
|
|
|
|
aliceToken := registerThrough(t, a, "alice")
|
|
bobToken := registerThrough(t, b, "bob")
|
|
aliceDevice := deviceIDOf(t, a, aliceToken)
|
|
|
|
roomID := createPublicRoom(t, a, aliceToken)
|
|
mustOK(t, post(t, b, "/_matrix/client/v3/join/"+url.PathEscape(roomID)+"?server_name=hs-a.example",
|
|
map[string]any{}, bobToken), "remote join")
|
|
bobSince := syncOnce(t, b, bobToken, "").NextBatch
|
|
|
|
// Alice's upload reaches Bob's server as an m.device_list_update, and
|
|
// Bob's next sync says so.
|
|
uploadDeviceKeys(t, a, aliceToken, alice, aliceDevice, 2)
|
|
eventually(t, "alice in bob's device_lists.changed", func() bool {
|
|
got := syncOnce(t, b, bobToken, bobSince)
|
|
return contains(got.DeviceLists.Changed, alice)
|
|
})
|
|
|
|
// Bob's first query is answered by Alice's server, and cached: the
|
|
// second does not leave B.
|
|
queryAlice := func(what string) map[string]any {
|
|
t.Helper()
|
|
query := mustOK(t, post(t, b, "/_matrix/client/v3/keys/query", map[string]any{
|
|
"device_keys": map[string]any{alice: []string{}},
|
|
}, bobToken), what)
|
|
devices, _ := query["device_keys"].(map[string]any)[alice].(map[string]any)
|
|
return devices
|
|
}
|
|
if devices := queryAlice("remote keys/query"); devices[aliceDevice] == nil {
|
|
t.Fatalf("remote keys/query = %v", devices)
|
|
}
|
|
if n := keyRequests.Load(); n != 1 {
|
|
t.Errorf("the first query made %d key requests to A, want 1", n)
|
|
}
|
|
if devices := queryAlice("second remote keys/query"); devices[aliceDevice] == nil {
|
|
t.Fatalf("second remote keys/query = %v", devices)
|
|
}
|
|
if n := keyRequests.Load(); n != 1 {
|
|
t.Errorf("the second query reached A: %d key requests, want 1", n)
|
|
}
|
|
|
|
// A new device of Alice's reaches B as an m.device_list_update that
|
|
// follows on from the cached copy, which B applies: Bob sees the new
|
|
// device, and A is still not asked.
|
|
login := mustOK(t, post(t, a, "/_matrix/client/v3/login", map[string]any{
|
|
"type": "m.login.password", "identifier": map[string]string{"type": "m.id.user", "user": "alice"},
|
|
"password": "sUp3rs3kr1t", "device_id": "PHONE",
|
|
}, ""), "login")
|
|
uploadDeviceKeys(t, a, login["access_token"].(string), alice, "PHONE", 1)
|
|
eventually(t, "alice's new device in bob's query", func() bool {
|
|
devices := queryAlice("keys/query after the update")
|
|
return devices["PHONE"] != nil && devices[aliceDevice] != nil
|
|
})
|
|
if n := keyRequests.Load(); n != 1 {
|
|
t.Errorf("the update was not applied from the EDU: %d key requests to A, want 1", n)
|
|
}
|
|
claim := mustOK(t, post(t, b, "/_matrix/client/v3/keys/claim", map[string]any{
|
|
"one_time_keys": map[string]any{alice: map[string]string{aliceDevice: "signed_curve25519"}},
|
|
}, bobToken), "remote keys/claim")
|
|
if keys, _ := claim["one_time_keys"].(map[string]any)[alice].(map[string]any)[aliceDevice].(map[string]any); len(keys) != 1 {
|
|
t.Errorf("remote keys/claim = %v", claim)
|
|
}
|
|
counts := syncOnce(t, a, aliceToken, "").OTKCounts
|
|
if counts["signed_curve25519"] != 1 {
|
|
t.Errorf("after a remote claim alice has %v one-time keys, want 1", counts)
|
|
}
|
|
|
|
// With A's federation off, B reports A as a failure instead of
|
|
// hanging.
|
|
if err := a.Settings().SetFederationEnabled(t.Context(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The cached copy is still B's to serve.
|
|
if devices := queryAlice("keys/query with A's federation off"); devices["PHONE"] == nil {
|
|
t.Errorf("with A's federation off, B no longer serves its cached copy: %v", devices)
|
|
}
|
|
// A user B has nothing cached for is asked of A, and A is reported as
|
|
// a failure instead of hanging.
|
|
query := mustOK(t, post(t, b, "/_matrix/client/v3/keys/query", map[string]any{
|
|
"device_keys": map[string]any{"@stranger:hs-a.example": []string{}}, "timeout": 5000,
|
|
}, bobToken), "keys/query for a stranger with A's federation off")
|
|
if failures, _ := query["failures"].(map[string]any); failures["hs-a.example"] == nil {
|
|
t.Errorf("failures = %v, want hs-a.example", query["failures"])
|
|
}
|
|
}
|