Files
Warp/server/internal/syncapi/token.go
T
tajniak81andClaude Opus 5.5 fc49f56339 Phase 7: E2EE support
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>
2026-10-17 10:34:08 +02:00

144 lines
4.7 KiB
Go

package syncapi
import (
"fmt"
"strconv"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Token is a position in every stream a sync response carries.
//
// A sync response is assembled from streams that move independently: room
// events, read receipts, to-device messages, account data and presence. A
// client that has seen receipt 7 but only event 4 has to be able to say so
// in the one opaque string the API gives it, so the token names a position
// in each.
//
// The wire form is
//
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}
//
// with trailing zeroes left off, so a client that has only ever seen room
// events carries "s12" rather than "s12_0_0_0_0_0". The device list
// position came with Phase 7 and was added at the end, which is what lets
// every token handed out before it go on parsing. The leading "s" is there
// to make a token recognisable in a log and to leave room for a second
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
// own — without any ambiguity about which is which.
//
// The spec says only that the token is opaque, and clients treat it that
// way. It is still worth being readable: a token is the first thing in
// every bug report about /sync.
type Token struct {
PDU int64
Receipt int64
ToDevice int64
AccountData int64
Presence int64
DeviceList int64
}
// tokenPositions is how many positions a token carries.
const tokenPositions = 6
// tokenPrefix marks a classic /sync token.
const tokenPrefix = "s"
// IsInitial reports whether the token is the start of every stream, which
// is what a sync with no ?since= means.
func (t Token) IsInitial() bool { return t == Token{} }
// String renders the wire form.
func (t Token) String() string {
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList}
// Trailing zeroes carry no information, and leaving them off keeps the
// common token short.
last := 0
for i, p := range parts {
if p != 0 {
last = i
}
}
var b strings.Builder
b.WriteString(tokenPrefix)
for i, p := range parts[:last+1] {
if i > 0 {
b.WriteByte('_')
}
b.WriteString(strconv.FormatInt(p, 10))
}
return b.String()
}
// ParseToken reads a token a client sent back.
//
// An empty string is the initial token: /sync without ?since= and
// /messages without ?from= both mean "from the beginning", and refusing
// the empty string would make every caller special-case it.
//
// A token with fewer fields than this build writes is accepted and the
// missing ones are zero. That is what lets a token stored by a client
// before a stream existed go on working: the stream it did not know about
// reads as "seen nothing", so the client is sent that stream in full once
// and is in step from then on.
func ParseToken(raw string) (Token, error) {
if raw == "" {
return Token{}, nil
}
body, ok := strings.CutPrefix(raw, tokenPrefix)
if !ok {
return Token{}, fmt.Errorf("%q is not a sync token: it must start with %q", raw, tokenPrefix)
}
fields := strings.Split(body, "_")
if len(fields) > tokenPositions {
return Token{}, fmt.Errorf("%q is not a sync token: it has %d positions, this server writes at most %d",
raw, len(fields), tokenPositions)
}
var positions [tokenPositions]int64
for i, field := range fields {
value, err := strconv.ParseInt(field, 10, 64)
// The PDU position alone may be negative: /messages hands out
// positions inside history backfilled from other servers, which
// is numbered downwards from -1. Every other stream starts at 0.
if err != nil || (value < 0 && i > 0) {
return Token{}, fmt.Errorf("%q is not a sync token: position %d is %q", raw, i+1, field)
}
positions[i] = value
}
return Token{
PDU: positions[0],
Receipt: positions[1],
ToDevice: positions[2],
AccountData: positions[3],
Presence: positions[4],
DeviceList: positions[5],
}, nil
}
// tokenAt is the token naming the top of every stream, which is what a
// sync response hands back when it has drained all of them.
func tokenAt(positions storage.StreamPositions) Token {
return Token{
PDU: positions.PDU,
Receipt: positions.Receipt,
ToDevice: positions.ToDevice,
AccountData: positions.AccountData,
Presence: positions.Presence,
DeviceList: positions.DeviceList,
}
}
// pduToken is the token for a position in the room event stream alone.
//
// It is what /messages and a room's prev_batch hand out: both are about
// room events only, and a token from either has to be usable as a ?since=
// for /sync, where the other streams then read as unseen. That is the
// conservative direction — the client is sent those streams again rather
// than having them skipped.
func pduToken(pos int64) Token { return Token{PDU: pos} }