Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.
internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.
Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.
Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.
Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.
Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
5.1 KiB
Go
163 lines
5.1 KiB
Go
package keyserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/federation"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// The writing side of device list tracking: telling everyone who shares a
|
|
// room with a user that the user's keys changed.
|
|
//
|
|
// Locally that is a wake-up. The change is already in the device list
|
|
// stream, moved in the same transaction as the keys themselves, and every
|
|
// sync works out from the stream whose changes concern it; all a sync
|
|
// blocked in a long poll needs is to be told to look. Remotely it is an
|
|
// EDU to every server with a user in one of those rooms, which is how a
|
|
// server that has the user's keys cached knows to drop them.
|
|
|
|
// sharers is who shares a room with a user, the user included.
|
|
func (k *API) sharers(ctx context.Context, userID string) []string {
|
|
out := []string{userID}
|
|
if k.rooms == nil {
|
|
return out
|
|
}
|
|
users, err := k.rooms.UsersSharingRooms(ctx, userID)
|
|
if err != nil {
|
|
// A missed wake-up costs a client one sync timeout, and a missed
|
|
// EDU costs a remote server one stale key until the next change;
|
|
// neither is worth failing the upload that caused it.
|
|
k.log.Warn("could not read who shares a room with a user", "user_id", userID, "error", err)
|
|
return out
|
|
}
|
|
for _, u := range users {
|
|
if !slices.Contains(out, u) {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// changed reacts to a move of a user's device list: it wakes the local
|
|
// syncs it concerns and returns the other servers that have to hear of
|
|
// it.
|
|
func (k *API) changed(ctx context.Context, userID string) []string {
|
|
users := k.sharers(ctx, userID)
|
|
|
|
var (
|
|
local []string
|
|
servers []string
|
|
)
|
|
for _, u := range users {
|
|
server, err := id.ServerNameOf(u)
|
|
switch {
|
|
case err != nil:
|
|
continue
|
|
case server == k.serverName:
|
|
local = append(local, u)
|
|
case !slices.Contains(servers, server):
|
|
servers = append(servers, server)
|
|
}
|
|
}
|
|
if k.notifier != nil {
|
|
k.notifier.NotifyUsers(local)
|
|
}
|
|
slices.Sort(servers)
|
|
return servers
|
|
}
|
|
|
|
// federating reports whether there is a running queue to send EDUs to.
|
|
func (k *API) federating() bool {
|
|
return k.outbound != nil && k.outbound.Running()
|
|
}
|
|
|
|
// announceDevices tells everyone about moves of a local user's devices:
|
|
// keys uploaded, changed or deleted. keys holds the current keys of the
|
|
// devices that still have them; a device missing from it is announced as
|
|
// deleted.
|
|
func (k *API) announceDevices(ctx context.Context, userID string, updates []storage.DeviceListUpdate, keys map[string]json.RawMessage) {
|
|
if len(updates) == 0 {
|
|
return
|
|
}
|
|
servers := k.changed(ctx, userID)
|
|
if len(servers) == 0 || !k.federating() {
|
|
return
|
|
}
|
|
for _, u := range updates {
|
|
content := federation.DeviceListUpdateContent{
|
|
UserID: userID,
|
|
DeviceID: u.DeviceID,
|
|
StreamID: u.StreamPos,
|
|
}
|
|
if u.PrevPos > 0 {
|
|
content.PrevID = []int64{u.PrevPos}
|
|
}
|
|
if raw, ok := keys[u.DeviceID]; ok {
|
|
content.Keys = raw
|
|
} else {
|
|
content.Deleted = true
|
|
}
|
|
encoded, err := json.Marshal(content)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
k.outbound.QueueEDU(ctx, servers, federation.EDU{Type: federation.EDUDeviceListUp, Content: encoded})
|
|
}
|
|
}
|
|
|
|
// announceSigningKeys tells everyone a local user's cross-signing keys
|
|
// changed: the master key, or a signature on it, or the self-signing key.
|
|
// The user-signing key is not announced — nobody but its owner ever sees
|
|
// it.
|
|
func (k *API) announceSigningKeys(ctx context.Context, userID string) {
|
|
servers := k.changed(ctx, userID)
|
|
if len(servers) == 0 || !k.federating() {
|
|
return
|
|
}
|
|
master, selfSigning, _, err := k.crossSigningKeys(ctx, userID, "")
|
|
if err != nil {
|
|
k.log.Warn("could not read cross-signing keys to announce them", "user_id", userID, "error", err)
|
|
return
|
|
}
|
|
encoded, err := json.Marshal(federation.SigningKeyUpdateContent{
|
|
UserID: userID, MasterKey: master, SelfSigningKey: selfSigning,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
k.outbound.QueueEDU(ctx, servers, federation.EDU{Type: federation.EDUSigningKeys, Content: encoded})
|
|
}
|
|
|
|
// RemoteKeysChanged takes in an m.device_list_update or
|
|
// m.signing_key_update from another server: one of its users' keys
|
|
// changed.
|
|
//
|
|
// Warp does not cache other servers' keys — a query for them is answered
|
|
// by asking their server — so there is nothing to invalidate. What there
|
|
// is to do is tell local clients, which do cache them, by moving the
|
|
// user's position in the device list stream. That only happens when a
|
|
// local user shares a room with them: an update about a stranger concerns
|
|
// nobody here, and recording it would let any server grow the table with
|
|
// users of its own invention.
|
|
func (k *API) RemoteKeysChanged(ctx context.Context, userID string) error {
|
|
if k.isLocal(userID) || k.rooms == nil {
|
|
return nil
|
|
}
|
|
users, err := k.rooms.UsersSharingRooms(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !slices.ContainsFunc(users, k.isLocal) {
|
|
return nil
|
|
}
|
|
if _, err := k.store.MarkDeviceListsChanged(ctx, []string{userID}); err != nil {
|
|
return err
|
|
}
|
|
k.changed(ctx, userID)
|
|
return nil
|
|
}
|