diff --git a/README.md b/README.md index d409f71..10ab788 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ what exists today. ## Status +**Phase 2 — the event core. Complete.** The library that every later phase +is built on: canonical JSON, the event model with its hashes and +identifiers, the redaction algorithm, event signing, the room version +registry, the authorisation rules and state resolution v2. It is pure +library code under `server/internal/matrix`, verified against the spec's +own test vectors, and nothing serves it over HTTP yet — rooms are Phase 3. +See [docs/event-core.md](docs/event-core.md). + **Phase 1 — accounts and sessions. Complete.** A client can register, log in, manage its devices and set its profile. There are no rooms and no real `/sync` yet: those are Phases 3 and 4. @@ -156,7 +164,14 @@ server/ the homeserver (Go module) clientapi/ the Client-Server API handlers adminapi/ /_warp/admin/v1 setup/ wiring: storage, routes, graceful shutdown - matrix/signing/ ed25519 server keys + matrix/ the event core (docs/event-core.md) + canonicaljson/ the canonical JSON encoding + signing/ ed25519 server keys, Signing JSON + id/ identifier grammars as they appear in events + roomversion/ the room version registry + event/ PDUs: hashes, event IDs, redaction, signing + auth/ the authorisation rules and power levels + stateres/ state resolution v2 storage/ interfaces + driver registry migrations/ embedded migration runner sqlstore/ shared database/sql implementation diff --git a/docs/README.md b/docs/README.md index 6dc7d70..a996e49 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ |---|---| | [configuration.md](configuration.md) | every setting, and its environment variable | | [development.md](development.md) | building, testing, Complement, adding a migration | +| [event-core.md](event-core.md) | `internal/matrix`: canonical JSON, events, auth rules, state resolution | | [admin-api.md](admin-api.md) | `/_warp/admin/v1`: the operator API behind the admin panel | The design and roadmap are in [PLAN.md](../PLAN.md) at the repository root. diff --git a/docs/development.md b/docs/development.md index 242b963..67c7bee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -108,6 +108,11 @@ says why. that was written but never registered. - Storage tests run against every driver that is available: SQLite always, PostgreSQL when `WARP_TEST_POSTGRES_DSN` is set. +- The event core under `internal/matrix` is tested against the spec's own + cryptographic and canonical JSON test vectors, and has fuzz targets for + the two functions that read bytes straight off the network. `make test` + runs the unit tests; the fuzzers are opt-in. See + [event-core.md](event-core.md). - CI runs `gofmt -l`, `go mod tidy` (as a diff check), `go vet`, the tests on Linux and Windows, the race detector on Linux, `golangci-lint`, and a smoke test that boots the real binary and curls it. diff --git a/docs/event-core.md b/docs/event-core.md new file mode 100644 index 0000000..ee29d2f --- /dev/null +++ b/docs/event-core.md @@ -0,0 +1,111 @@ +# The event core + +`server/internal/matrix` is the heart of the homeserver: the operations +whose results every server in a room must agree on, byte for byte. It is +pure library code — no HTTP, no database, no clock — and that is deliberate. +Everything here is deterministic and therefore testable against the spec's +own vectors, which is the only way to be confident a room's history will +still verify on somebody else's server in five years. + +It corresponds to [PLAN.md](../PLAN.md) Phase 2. + +## The packages + +| Package | What | +|---|---| +| `canonicaljson` | the canonical JSON encoding: sorted keys, no whitespace, integers only, minimal escaping | +| `signing` | ed25519 server keys (Phase 0) plus the Signing JSON algorithm | +| `id` | the identifier grammars as they appear *inside events*, which are laxer than the ones for new accounts | +| `roomversion` | the registry of room versions and of what each one changes | +| `event` | the PDU model: parsing, validation, content and reference hashes, event IDs, redaction, signing | +| `auth` | the authorisation rules, power levels, and the auth events selection algorithm | +| `stateres` | state resolution v2, and version 12's amendment to it | + +They stack in that order. `canonicaljson` depends on nothing, and +`stateres` depends on everything. + +## Reading the code against the spec + +The authorisation rules in `auth` are written in the order and the shape +the spec gives them, and the comments name the rule numbers from the +[room version 11](https://spec.matrix.org/latest/rooms/v11/) and +[version 12](https://spec.matrix.org/latest/rooms/v12/) pages, so the two +can be read side by side. That is worth more here than concision: a rule +that is subtly wrong does not fail a test, it forks a room. + +Where the room versions differ, the difference is a field on +`roomversion.Rules` rather than a comparison on the version string. Adding +a room version should mean adding one entry to the table in +`roomversion.go` and nothing else. + +## What the tests check + +The spec publishes test vectors, and they are the backbone of the suite: + +- `canonicaljson` runs the worked examples from the appendices. +- `signing` runs the two JSON signing vectors, byte for byte. +- `event` runs the two event signing vectors, byte for byte — the content + hash, the redaction, and the signature over the redacted form. +- `stateres` ports several scenarios from Synapse's state resolution + tests, including the topic reset that the algorithm is known to produce. + +Beyond the vectors, the suite leans on properties rather than examples +where it can: canonical JSON is idempotent, an event's ID survives +redaction, a signature survives a rewritten `unsigned`, and state +resolution does not depend on the order the state sets arrive in. Both +`canonicaljson.Encode` and `event.Parse` have fuzz targets, since both +read bytes straight off the network. + +```sh +cd server +go test ./internal/matrix/... +go test ./internal/matrix/canonicaljson -fuzz FuzzEncode -fuzztime 60s +go test ./internal/matrix/event -fuzz FuzzParse -fuzztime 60s +``` + +## Room versions + +Versions 3 to 12 are implemented. `createRoom` will default to 11: +the spec says servers SHOULD default to 12 but asks them to stay on 11 for +a while after the Matrix 1.16 release so that clients and bridges catch up, +and we follow that advice. + +Versions 1 and 2 are in the registry but marked unsupported, so +`roomversion.Get` refuses them with a clear error. They carry the event ID +inside the event, write `prev_events` and `auth_events` as `[id, hashes]` +tuples rather than plain lists, and version 1 uses state resolution v1 — +enough of a separate event format to be its own piece of work. They are +only needed to join rooms created before 2018 on other servers, so they +wait for federation in Phase 5. + +## Known gaps + +- **Out-of-range integers in old rooms.** Room versions 1 to 5 may contain + integers outside the range canonical JSON permits, and the spec asks + servers to cope where they can. Warp rejects such an event whatever its + room version. Handling it means a second, lenient encoder; it can wait + until federation makes those rooms reachable. +- **Signature checking during state resolution.** The restricted join rule + asks for a signature from the server authorising the join. + `stateres` does not re-check it, on the grounds that the event was + checked when it was accepted into the room, and that state resolution + must not do network I/O. Synapse draws the line in the same place. The + consequence is that event intake must never be skipped — see + `intakeVerified` in `stateres.go`. +- **State resolution v1** is not implemented, for the same reason room + versions 1 and 2 are not. + +## What is not here + +Two decisions that look like they belong to the authorisation rules do not: + +- **Whether a redaction is applied.** The rules allow nearly every + `m.room.redaction` event; whether the redaction takes effect is + `auth.ShouldApplyRedaction`, which the roomserver runs once it holds both + events. +- **Soft failure.** An event can pass the rules against its own auth + events and still be denied a place among the room's forward extremities + because it fails against the *current* state. That is the roomserver's + business, in Phase 3. + +Neither needs the event core, and both need storage. diff --git a/server/internal/clientapi/capabilities.go b/server/internal/clientapi/capabilities.go index 2197631..60f7a97 100644 --- a/server/internal/clientapi/capabilities.go +++ b/server/internal/clientapi/capabilities.go @@ -4,15 +4,29 @@ import ( "net/http" "github.com/dariuszjaniak/warp/server/internal/httputil" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" "github.com/dariuszjaniak/warp/server/internal/userapi" ) -// DefaultRoomVersion is what this server creates rooms at. +// DefaultRoomVersion is what this server creates rooms at. The room +// version registry decides which one that is, and why. +const DefaultRoomVersion = string(roomversion.Default) + +// availableRoomVersions is the m.room_versions.available map. // -// PLAN.md's decision is to implement the latest stable room version first -// and backfill older ones only as federation needs them, so the registry -// here has exactly one entry until the event core lands in Phase 2. -const DefaultRoomVersion = "11" +// Every version Warp implements is marked stable, because every version +// the spec still lists is stable — the "unstable" marking is for versions +// under development, which we do not implement. Versions 1 and 2 are in +// the registry but not supported, and so are left out: a client offered a +// version it cannot be given would get an error on createRoom. +func availableRoomVersions() map[string]string { + supported := roomversion.Supported() + out := make(map[string]string, len(supported)) + for _, v := range supported { + out[string(v)] = "stable" + } + return out +} // roomVersionsCapability is the m.room_versions capability. type roomVersionsCapability struct { @@ -52,7 +66,7 @@ func (c *ClientAPI) handleCapabilities(w http.ResponseWriter, _ *http.Request, _ Capabilities: capabilities{ RoomVersions: roomVersionsCapability{ Default: DefaultRoomVersion, - Available: map[string]string{DefaultRoomVersion: "stable"}, + Available: availableRoomVersions(), }, ChangePassword: booleanCapability{Enabled: false}, SetDisplayName: booleanCapability{Enabled: true}, diff --git a/server/internal/matrix/auth/auth.go b/server/internal/matrix/auth/auth.go new file mode 100644 index 0000000..a3900cb --- /dev/null +++ b/server/internal/matrix/auth/auth.go @@ -0,0 +1,559 @@ +// Package auth implements the Matrix authorisation rules: given an event +// and the room state it refers to, may that event be in the room? +// +// This is the part of a homeserver that must not be improvised. Every +// server in a room runs these rules over the same events and has to reach +// the same verdict, or the room's history forks. The rules are therefore +// written out here in the order and the shape the spec gives them, with +// the room version differences driven by the roomversion registry rather +// than by guesswork, and the comments say which rule is which so that the +// code can be read against the spec side by side. +// +// # Rule numbers +// +// "Rule N" in these comments means the numbering on the room version 12 +// page, the most recent one. Version 12 inserted a new rule 2 (the room ID +// is the create event's ID), so on the pages for versions 6 to 11 each rule +// after the first is one lower: rule 5 here is rule 4 there, rule 10 here +// is rule 9 there. Versions 1 to 5 happen to line up with version 12 again +// from rule 5 on, because their rule 4 was the m.room.aliases rule that +// version 6 removed. Where a sub-rule exists in only some versions, or is +// numbered differently beyond that shift, the comment says so. +// +// Nothing here touches storage or the network. The one exception is the +// restricted join rule, which needs somebody else's signature verified; +// that comes in through the Verifier interface. +// +// What this package does *not* decide: +// +// - Whether a redaction should be applied. The rules allow nearly every +// m.room.redaction event; whether the redaction takes effect is a +// separate test (ShouldApplyRedaction) the roomserver runs once it has +// both events. +// - Soft failure. An event can be allowed by the rules against its own +// auth events and still be refused a place in the room's forward +// extremities because it fails against the *current* state. That is +// the roomserver's business (PLAN.md phase 3). +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/id" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// The join rules a room may have. +const ( + JoinRulePublic = "public" + JoinRuleInvite = "invite" + JoinRuleKnock = "knock" + JoinRuleRestricted = "restricted" + JoinRuleKnockRestricted = "knock_restricted" + JoinRulePrivate = "private" +) + +// ErrRejected means the authorisation rules refused the event. Every +// rejection from Check wraps it, and the message says which rule said no. +var ErrRejected = errors.New("rejected by the authorisation rules") + +// ErrNoVerifier means a decision needed somebody else's signature checked +// and no Verifier was supplied. It is kept separate from ErrRejected +// because the event is not known to be bad — we are simply not equipped to +// judge it, and retrying with a verifier may well allow it. +var ErrNoVerifier = errors.New("cannot verify the signature this rule needs") + +// Verifier checks that an event carries a valid signature from a server. +// +// Only one rule needs this: a join under the restricted join rule is +// authorised by a third party, named in +// content.join_authorised_via_users_server, and the event must be signed +// by *that user's* server rather than only by the joining user's. Verifying +// it may mean fetching another server's keys, which is why it is an +// interface here instead of a function. +type Verifier interface { + VerifyEventSignature(ctx context.Context, serverName string, e *event.Event) error +} + +// reject builds a rejection error. +func reject(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrRejected, fmt.Sprintf(format, args...)) +} + +// Check applies the authorisation rules to an event. +// +// st is the room state the event is judged against: for an event arriving +// from a client or over federation that is the state its auth_events name, +// and during state resolution it is the state resolved so far. A nil error +// means the event is allowed. +// +// verifier may be nil. It is needed only for a join authorised by another +// user under the restricted join rule; without one, such a join fails with +// ErrNoVerifier rather than being waved through. +func Check(ctx context.Context, e *event.Event, st State, verifier Verifier) error { + rules := e.Rules() + + // Rule 1: the create event answers to nothing but itself. + if e.Type() == event.TypeCreate { + return checkCreate(e) + } + + create := st.Create() + if create == nil { + // Not one of the numbered rules but a precondition for all of them: + // without the create event we do not know the room's creators, and + // from room version 12 we do not even know its ID. (The numbered + // rule about a missing create event, 2.4 on the version 11 page, + // judges the declared auth_events and lives in CheckAuthEvents.) + return reject("the room state has no m.room.create event") + } + + // Rule 2 (room version 12+): the room ID is the create event's ID, so + // an event claiming a different room is not in this room. + if rules.RoomIDIsCreateEventID { + roomID, err := event.RoomIDOfCreateEvent(create) + if err != nil { + return err + } + if e.RoomID() != roomID { + return reject("room_id %q is not the ID of this room's m.room.create event (%q)", e.RoomID(), roomID) + } + } + + // Rule 4: a room created with m.federate false never leaves the + // creator's server. + if err := checkFederate(e, create); err != nil { + return err + } + + pl, err := ResolvePowerLevels(st.PowerLevels(), create, rules) + if err != nil { + return err + } + + // The m.room.aliases rule, rule 4 on the pages for room versions 1 to + // 5 and absent from version 6 on: every server managed its own alias + // list, so an aliases event needed only to match its sender's domain. + // It was dropped because it let any server in the room edit state. + if rules.AliasesEventAuth && e.Type() == event.TypeAliases { + return checkAliases(e) + } + + // Rule 5: membership changes have their own rules, and are the only + // thing a user who is not in the room may send. + if e.Type() == event.TypeMember { + return checkMember(ctx, e, st, create, pl, verifier) + } + + // Rule 6: everything else requires the sender to be in the room. + if m := st.Membership(e.Sender()); m != event.MembershipJoin { + return reject("the sender is not joined to the room (membership is %q)", m) + } + + senderLevel := pl.UserLevel(e.Sender()) + + // Rule 7: an invite to a third party needs the power to invite. + if e.Type() == event.TypeThirdPartyInvite { + if senderLevel < pl.Invite { + return reject("the sender's power level %s is below the invite level %d", + levelString(senderLevel), pl.Invite) + } + return nil + } + + // Rule 8: the sender must be allowed to send this type of event. + if required := pl.SendLevel(e.Type(), e.IsState()); senderLevel < required { + return reject("the sender's power level %s is below the %d needed to send %s", + levelString(senderLevel), required, e.Type()) + } + + // Rule 9: state keyed by a user ID belongs to that user. It is what + // stops one member rewriting another's per-user state. + if sk, ok := e.StateKey(); ok && len(sk) > 0 && sk[0] == '@' && sk != e.Sender() { + return reject("the state_key %q is a user ID that is not the sender", sk) + } + + // Rule 10: a power levels event may not grant or revoke more than the + // sender holds. + if e.Type() == event.TypePowerLevels { + return checkPowerLevels(e, st.PowerLevels(), create, senderLevel) + } + + // Rule 11: otherwise, allow. + return nil +} + +// checkCreate applies rule 1, the rules for the event that starts a room. +func checkCreate(e *event.Event) error { + rules := e.Rules() + + // Rule 1.1: a create event is the root of the DAG. + if len(e.PrevEventIDs()) > 0 { + return reject("an m.room.create event must have no prev_events") + } + + // Rule 1.2. + if rules.RoomIDIsCreateEventID { + if e.RoomID() != "" { + return reject("an m.room.create event in a version %s room must not have a room_id", rules.Version) + } + } else { + roomServer, err := id.ServerNameOfRoomID(e.RoomID()) + if err != nil { + return reject("room_id: %v", err) + } + senderServer, err := e.SenderServer() + if err != nil { + return reject("sender: %v", err) + } + if roomServer != senderServer { + return reject("the room_id domain %q does not match the sender's domain %q", roomServer, senderServer) + } + } + + var content struct { + RoomVersion *string `json:"room_version"` + Creator *string `json:"creator"` + Additional *json.RawMessage `json:"additional_creators"` + } + if err := json.Unmarshal(e.Content(), &content); err != nil { + return reject("m.room.create content: %v", err) + } + + // Rule 1.3: a room version we do not implement is one we must not + // pretend to be in. + if content.RoomVersion != nil { + if !roomversion.IsSupported(roomversion.Version(*content.RoomVersion)) { + return reject("room version %q is not one this server recognises", *content.RoomVersion) + } + } + + // Rule 1.4 (room version 12+): additional creators must be user IDs. + // Creators cannot be changed later, so a malformed entry here would be + // permanent. + if rules.AdditionalCreators && content.Additional != nil { + var additional []string + if err := json.Unmarshal(*content.Additional, &additional); err != nil { + return reject("m.room.create additional_creators must be an array of strings: %v", err) + } + for _, userID := range additional { + if err := id.ValidateUserID(userID); err != nil { + return reject("m.room.create additional_creators: %v", err) + } + } + } + + // Room versions up to 10 required the creator property, which only + // ever repeated the sender. + if rules.CreateEventRequiresCreator && content.Creator == nil { + return reject("an m.room.create event in a version %s room must have a creator", rules.Version) + } + return nil +} + +// checkFederate applies rule 4: a room whose create event says +// m.federate false accepts events only from the creator's own server. +func checkFederate(e *event.Event, create *event.Event) error { + var content struct { + Federate *bool `json:"m.federate"` + } + if err := json.Unmarshal(create.Content(), &content); err != nil { + return reject("m.room.create content: %v", err) + } + if content.Federate == nil || *content.Federate { + return nil + } + + senderServer, err := e.SenderServer() + if err != nil { + return reject("sender: %v", err) + } + createServer, err := create.SenderServer() + if err != nil { + return reject("the create event's sender: %v", err) + } + if senderServer != createServer { + return reject("the room does not federate and %q is not %q", senderServer, createServer) + } + return nil +} + +// checkAliases applies the room version 1 to 5 rule for m.room.aliases. +func checkAliases(e *event.Event) error { + sk, ok := e.StateKey() + if !ok { + return reject("an m.room.aliases event must have a state_key") + } + senderServer, err := e.SenderServer() + if err != nil { + return reject("sender: %v", err) + } + if sk != senderServer { + return reject("the state_key %q is not the sender's server %q", sk, senderServer) + } + return nil +} + +// checkPowerLevels applies rule 10: the content is well-formed, and no +// level is moved past the sender's own. +// +// The asymmetry in the rules is deliberate. You may lower a level you +// could have reached yourself, and you may raise one up to your own level, +// but you may not touch anybody whose level equals yours — otherwise two +// administrators could demote each other, and whoever's event won state +// resolution would end up alone in charge. +func checkPowerLevels(e, old, create *event.Event, senderLevel int64) error { + rules := e.Rules() + + // Rules 10.1 to 10.3: parsing the content is the check. ResolvePowerLevels + // rejects a level that is not an integer, an events or notifications + // map whose values are not integers, and a users key that is not a + // user ID. + if _, err := ResolvePowerLevels(e, create, rules); err != nil { + return err + } + + newContent, err := levelContent(e) + if err != nil { + return err + } + + // Rule 10.4 (room version 12+): creators have infinite power, so + // naming one here could only ever be an attempt to take it away. + // + // Being new, this sub-rule shifts the ones after it: on the version 11 + // page both halves of their numbers are one lower, so 10.5 here is 9.4 + // there and 10.10 here is 9.9. + if rules.CreatorsHaveInfinitePower { + creators, err := Creators(create) + if err != nil { + return err + } + for _, creator := range creators { + if _, ok := newContent.sub("users")[creator]; ok { + return reject("m.room.power_levels must not give a level to the room creator %q", creator) + } + } + } + + // Rule 10.5: the first power levels event in a room is always allowed. + // Until it exists the creator is the only person with any power, so + // there is nobody to protect anything from. + if old == nil { + return nil + } + oldContent, err := levelContent(old) + if err != nil { + return err + } + + // Rule 10.6: the named levels. + for _, name := range []string{ + "users_default", "events_default", "state_default", + "ban", "redact", "kick", "invite", + } { + if err := checkLevelChange(name, "", oldContent.top, newContent.top, senderLevel, false); err != nil { + return err + } + } + + // Rules 10.7 and 10.8: the events and notifications maps. + for _, section := range []string{"events", "notifications"} { + oldSection, newSection := oldContent.sub(section), newContent.sub(section) + for _, key := range unionKeys(oldSection, newSection) { + if err := checkLevelChange(key, section, oldSection, newSection, senderLevel, false); err != nil { + return err + } + } + } + + // Rules 10.9 and 10.10: the users map, where an entry other than the + // sender's own is protected at the sender's own level rather than + // above it. + oldUsers, newUsers := oldContent.sub("users"), newContent.sub("users") + for _, userID := range unionKeys(oldUsers, newUsers) { + if err := checkLevelChange(userID, "users", oldUsers, newUsers, senderLevel, userID != e.Sender()); err != nil { + return err + } + } + + return nil +} + +// checkLevelChange compares one level before and after. +// +// A level that is absent on one side is not compared on that side: the +// rules speak of "the current value" and "the new value", and where there +// is no value there is nothing to check. +func checkLevelChange(key, section string, old, new map[string]int64, senderLevel int64, protectPeers bool) error { + oldLevel, hadOld := old[key] + newLevel, hasNew := new[key] + if hadOld && hasNew && oldLevel == newLevel { + return nil + } + + where := key + if section != "" { + where = section + "." + key + } + + // The peer rule: you may not change or remove the entry of somebody at + // your own level. + if protectPeers && hadOld && oldLevel == senderLevel { + return reject("%s is at the sender's own power level %s and may not be changed by them", + where, levelString(senderLevel)) + } + if hadOld && oldLevel > senderLevel { + return reject("%s is %d, above the sender's power level %s", where, oldLevel, levelString(senderLevel)) + } + if hasNew && newLevel > senderLevel { + return reject("%s would become %d, above the sender's power level %s", where, newLevel, levelString(senderLevel)) + } + return nil +} + +// levels holds a power levels event's content as plain integers, keeping +// track of which keys were actually present. +type levels struct { + top map[string]int64 + subs map[string]map[string]int64 +} + +func (l levels) sub(name string) map[string]int64 { + if m, ok := l.subs[name]; ok { + return m + } + return map[string]int64{} +} + +// levelContent reads a power levels event's content for comparison. +// +// It parses leniently on purpose: by the time this runs, the new event has +// already been through ResolvePowerLevels, and the old one was accepted +// into the room's state under whatever rules applied then. A value we +// cannot read is left out rather than treated as zero, which would invent +// a level nobody set. +func levelContent(e *event.Event) (levels, error) { + out := levels{top: map[string]int64{}, subs: map[string]map[string]int64{}} + var obj map[string]json.RawMessage + if err := json.Unmarshal(e.Content(), &obj); err != nil { + return out, reject("m.room.power_levels content: %v", err) + } + + rules := e.Rules() + for _, name := range []string{ + "users_default", "events_default", "state_default", + "ban", "redact", "kick", "invite", + } { + if raw, ok := obj[name]; ok { + if level, err := parseLevel(raw, rules); err == nil { + out.top[name] = level + } + } + } + for _, name := range []string{"events", "notifications", "users"} { + raw, ok := obj[name] + if !ok { + continue + } + if levels, err := parseLevelMap(raw, rules); err == nil { + out.subs[name] = levels + } + } + return out, nil +} + +// unionKeys returns every key in either map, in a stable order so that the +// first rejection reported for a given pair of events is always the same +// one — a rule that rejects differently on different servers is as bad as +// one that rejects differently from the spec. +func unionKeys(a, b map[string]int64) []string { + seen := make(map[string]bool, len(a)+len(b)) + keys := make([]string, 0, len(a)+len(b)) + for _, m := range []map[string]int64{a, b} { + for k := range m { + if !seen[k] { + seen[k] = true + keys = append(keys, k) + } + } + } + slices.Sort(keys) + return keys +} + +// levelString renders a power level for an error message, spelling out the +// one value that is not a number anybody wrote down. +func levelString(level int64) string { + if level == InfinitePowerLevel { + return "infinite (room creator)" + } + return fmt.Sprintf("%d", level) +} + +// joinRuleOf reads the join rule from an m.room.join_rules event. +func joinRuleOf(e *event.Event) (string, error) { + var content struct { + JoinRule string `json:"join_rule"` + } + if err := json.Unmarshal(e.Content(), &content); err != nil { + return "", reject("m.room.join_rules content: %v", err) + } + return content.JoinRule, nil +} + +// ShouldApplyRedaction reports whether a redaction event takes effect on +// the event it targets. +// +// The authorisation rules let almost any redaction event into the room; +// this is the separate test the spec puts under "Handling redactions", +// which a server can only run once it holds both events. Either the sender +// has the redact level, or the redaction comes from the same server as the +// event it redacts — a server may always take back its own users' events. +// +// Note that the power level test passes even when the sender could not have +// sent the event they are redacting. Moderation is not the same permission +// as authorship. +func ShouldApplyRedaction(redaction, target *event.Event, pl *PowerLevels) bool { + if pl.UserLevel(redaction.Sender()) >= pl.Redact { + return true + } + return id.SameServer(redaction.Sender(), target.Sender()) +} + +// VerifyThirdPartyInviteSignature reports whether any signature on a +// third-party invite's signed block was made by one of the public keys the +// m.room.third_party_invite event published. +// +// The keys travel in the event itself, so unlike the restricted join rule +// this needs nothing from outside: the identity server's key was recorded +// when the invite was issued. +func VerifyThirdPartyInviteSignature(signed json.RawMessage, inviteEvent *event.Event) bool { + keys, err := thirdPartyInviteKeys(inviteEvent) + if err != nil || len(keys) == 0 { + return false + } + sigs, err := signing.ExtractSignatures(signed) + if err != nil { + return false + } + for serverName, byKeyID := range sigs { + for keyID := range byKeyID { + if algorithm, _, ok := signing.SplitKeyID(keyID); !ok || algorithm != signing.KeyIDPrefix { + continue + } + for _, pub := range keys { + if err := signing.VerifyJSON(serverName, keyID, pub, signed); err == nil { + return true + } + } + } + } + return false +} diff --git a/server/internal/matrix/auth/auth_test.go b/server/internal/matrix/auth/auth_test.go new file mode 100644 index 0000000..f9ad7e4 --- /dev/null +++ b/server/internal/matrix/auth/auth_test.go @@ -0,0 +1,1592 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// The users the tests below use. Alice creates every room; dave is on +// another server, which matters for the m.federate rule. +const ( + alice = "@alice:warp.test" + bob = "@bob:warp.test" + carol = "@carol:warp.test" + dave = "@dave:other.test" +) + +func ptr[T any](v T) *T { return &v } + +// roomCounter gives every test room a distinct ID. Two rooms that shared +// one would make the rule about auth events from another room untestable, +// and would hide a real confusion if the code ever stopped checking it. +var roomCounter atomic.Int64 + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + raw, err := canonicaljson.Marshal(v) + if err != nil { + t.Fatalf("encode test content: %v", err) + } + return raw +} + +// room drives a room forward one event at a time, filling in the +// prev_events, auth_events and depth that the rules expect so that the +// tests can say only what they are actually about. +type room struct { + t *testing.T + rules roomversion.Rules + key *signing.KeyPair + roomID string + state State + prev []string + depth int64 + clock int64 +} + +// newRoom creates a room and nothing else: the create event is in state, +// but nobody has joined yet. +func newRoom(t *testing.T, v roomversion.Version, createContent map[string]any) *room { + t.Helper() + key, err := signing.Generate("test") + if err != nil { + t.Fatalf("generate a signing key: %v", err) + } + r := &room{ + t: t, + rules: roomversion.MustGet(v), + key: key, + state: State{}, + depth: 1, + clock: 1700000000000, + } + + content := map[string]any{"room_version": string(v)} + if r.rules.CreateEventRequiresCreator { + content["creator"] = alice + } + for k, value := range createContent { + content[k] = value + } + if !r.rules.RoomIDIsCreateEventID { + r.roomID = fmt.Sprintf("!room%d:warp.test", roomCounter.Add(1)) + } + + create := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + Content: mustJSON(t, content), + }) + if err := Check(context.Background(), create, r.state, nil); err != nil { + t.Fatalf("the create event should be allowed: %v", err) + } + if r.rules.RoomIDIsCreateEventID { + if r.roomID, err = event.RoomIDOfCreateEvent(create); err != nil { + t.Fatalf("derive the room ID: %v", err) + } + } + r.add(create) + return r +} + +// newJoinedRoom is newRoom plus Alice's join, which is where most tests +// want to start. +func newJoinedRoom(t *testing.T, v roomversion.Version) *room { + t.Helper() + r := newRoom(t, v, nil) + r.mustSend(event.Builder{ + Type: event.TypeMember, + Sender: alice, + StateKey: ptr(alice), + Content: mustJSON(t, map[string]any{"membership": "join"}), + }) + return r +} + +// build fills in everything about an event that follows from the room +// rather than from the test. +func (r *room) build(b event.Builder) *event.Event { + r.t.Helper() + derivesRoomID := r.rules.RoomIDIsCreateEventID && b.Type == event.TypeCreate + if b.RoomID == "" && !derivesRoomID { + b.RoomID = r.roomID + } + if b.PrevEvents == nil && b.Type != event.TypeCreate { + b.PrevEvents = r.prev + } + if len(b.Content) == 0 { + b.Content = json.RawMessage("{}") + } + if b.AuthEvents == nil && b.Type != event.TypeCreate { + selected, err := SelectAuthEvents(r.rules, b.Type, b.Sender, b.StateKey, b.Content, r.state) + if err != nil { + r.t.Fatalf("select auth events for %s: %v", b.Type, err) + } + for _, e := range selected { + b.AuthEvents = append(b.AuthEvents, e.EventID()) + } + } + if b.Depth == 0 { + b.Depth = r.depth + } + + r.clock++ + e, err := b.Build(time.UnixMilli(r.clock), r.key, r.rules) + if err != nil { + r.t.Fatalf("build a %s event: %v", b.Type, err) + } + return e +} + +// check builds an event and runs the rules over it without adding it to +// the room. +func (r *room) check(b event.Builder) error { + r.t.Helper() + return Check(context.Background(), r.build(b), r.state, nil) +} + +// checkWith is check with a Verifier, for the restricted join rule. +func (r *room) checkWith(b event.Builder, verifier Verifier) error { + r.t.Helper() + return Check(context.Background(), r.build(b), r.state, verifier) +} + +// mustSend requires an event to be allowed and adds it to the room. +func (r *room) mustSend(b event.Builder) *event.Event { + r.t.Helper() + e := r.build(b) + if err := Check(context.Background(), e, r.state, nil); err != nil { + r.t.Fatalf("a %s event from %s should be allowed: %v", b.Type, b.Sender, err) + } + r.add(e) + return e +} + +// add puts an event into the room's state and makes it the latest event. +func (r *room) add(e *event.Event) { + if sk, ok := e.StateKey(); ok { + r.state[StateKey{Type: e.Type(), StateKey: sk}] = e + } + r.prev = []string{e.EventID()} + r.depth++ +} + +// setPowerLevels sends a power levels event as Alice, who can always send +// the first one. +func (r *room) setPowerLevels(content map[string]any) *event.Event { + r.t.Helper() + if r.rules.CreatorsHaveInfinitePower { + // From room version 12 naming a creator here is a rejection in + // itself, and there is no need to: their level is already above + // anything the event could say. Dropping them lets the tests that + // run against every version write one power levels event. + content = withoutCreators(r.t, content, r.state.Create()) + } + return r.mustSend(event.Builder{ + Type: event.TypePowerLevels, + Sender: alice, + StateKey: ptr(""), + Content: mustJSON(r.t, content), + }) +} + +// withoutCreators copies content with the room's creators removed from +// the users map. +func withoutCreators(t *testing.T, content map[string]any, create *event.Event) map[string]any { + t.Helper() + users, ok := content["users"].(map[string]any) + if !ok { + return content + } + creators, err := Creators(create) + if err != nil { + t.Fatalf("read the room's creators: %v", err) + } + trimmed := map[string]any{} + for k, v := range users { + trimmed[k] = v + } + for _, creator := range creators { + delete(trimmed, creator) + } + out := map[string]any{} + for k, v := range content { + out[k] = v + } + out["users"] = trimmed + return out +} + +func (r *room) setJoinRule(rule string, extra map[string]any) *event.Event { + r.t.Helper() + content := map[string]any{"join_rule": rule} + for k, v := range extra { + content[k] = v + } + return r.mustSend(event.Builder{ + Type: event.TypeJoinRules, + Sender: alice, + StateKey: ptr(""), + Content: mustJSON(r.t, content), + }) +} + +func (r *room) member(sender, target, membership string, extra map[string]any) event.Builder { + content := map[string]any{"membership": membership} + for k, v := range extra { + content[k] = v + } + return event.Builder{ + Type: event.TypeMember, + Sender: sender, + StateKey: ptr(target), + Content: mustJSON(r.t, content), + } +} + +// message is a plain message event, the thing rule 6 and rule 8 guard. +func (r *room) message(sender string) event.Builder { + return event.Builder{ + Type: "m.room.message", + Sender: sender, + Content: mustJSON(r.t, map[string]any{"body": "hello", "msgtype": "m.text"}), + } +} + +// wantRejected asserts that err is a rejection mentioning want. +func wantRejected(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatalf("the event was allowed; expected a rejection mentioning %q", want) + } + if !errors.Is(err, ErrRejected) { + t.Fatalf("error = %v, want it to wrap ErrRejected", err) + } + if want != "" && !strings.Contains(err.Error(), want) { + t.Errorf("rejection = %q, want it to mention %q", err, want) + } +} + +func wantAllowed(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("the event should have been allowed: %v", err) + } +} + +// supportedVersions is every version the tests run the shared rules +// against, so that a rule that changed by accident in one version shows up. +func supportedVersions() []roomversion.Version { return roomversion.Supported() } + +// --- Rule 1: m.room.create --- + +func TestCreateEventRules(t *testing.T) { + t.Run("a create event with prev_events is rejected", func(t *testing.T) { + r := newRoom(t, roomversion.V11, nil) + e := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + RoomID: "!other:warp.test", + PrevEvents: []string{"$something"}, + Content: mustJSON(t, map[string]any{"room_version": "11"}), + }) + wantRejected(t, Check(context.Background(), e, State{}, nil), "no prev_events") + }) + + t.Run("the room_id domain must match the sender", func(t *testing.T) { + r := newRoom(t, roomversion.V11, nil) + e := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + RoomID: "!room:elsewhere.test", + Content: mustJSON(t, map[string]any{"room_version": "11"}), + }) + wantRejected(t, Check(context.Background(), e, State{}, nil), "does not match the sender") + }) + + t.Run("an unrecognised room version is rejected", func(t *testing.T) { + r := newRoom(t, roomversion.V11, nil) + e := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + RoomID: "!room:warp.test", + Content: mustJSON(t, map[string]any{"room_version": "99-not-real"}), + }) + wantRejected(t, Check(context.Background(), e, State{}, nil), "not one this server recognises") + }) + + t.Run("a room version 1 room is rejected because we cannot join it", func(t *testing.T) { + r := newRoom(t, roomversion.V11, nil) + e := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + RoomID: "!room:warp.test", + Content: mustJSON(t, map[string]any{"room_version": "1"}), + }) + wantRejected(t, Check(context.Background(), e, State{}, nil), "not one this server recognises") + }) + + t.Run("version 10 requires a creator", func(t *testing.T) { + r := newRoom(t, roomversion.V10, nil) + e := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + RoomID: "!room:warp.test", + Content: mustJSON(t, map[string]any{"room_version": "10"}), + }) + wantRejected(t, Check(context.Background(), e, State{}, nil), "must have a creator") + }) + + t.Run("version 11 does not require a creator", func(t *testing.T) { + newRoom(t, roomversion.V11, nil) // newRoom checks the create event + }) + + t.Run("version 12 rejects additional_creators that are not user IDs", func(t *testing.T) { + r := newRoom(t, roomversion.V12, nil) + e := r.build(event.Builder{ + Type: event.TypeCreate, + Sender: alice, + StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"room_version": "12", "additional_creators": []string{"not-a-user"}}), + }) + wantRejected(t, Check(context.Background(), e, State{}, nil), "additional_creators") + }) + + t.Run("version 11 ignores additional_creators", func(t *testing.T) { + // The property has no meaning before version 12, so it is content + // like any other and must not be validated. + newRoom(t, roomversion.V11, map[string]any{"additional_creators": []string{"not-a-user"}}) + }) +} + +// --- Rule 2 (room version 12): the room ID is the create event --- + +func TestVersion12RoomIDMustMatchCreateEvent(t *testing.T) { + r := newJoinedRoom(t, roomversion.V12) + + wrong := r.build(event.Builder{ + Type: "m.room.message", + Sender: alice, + RoomID: "!NotTheCreateEventsID", + Content: mustJSON(t, map[string]any{"body": "hi"}), + }) + wantRejected(t, Check(context.Background(), wrong, r.state, nil), "is not the ID of this room") +} + +// --- Rule 3: auth_events --- + +func TestCheckAuthEvents(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + + authFor := func(e *event.Event) []*event.Event { + var out []*event.Event + for _, want := range e.AuthEventIDs() { + for _, candidate := range r.state { + if candidate.EventID() == want { + out = append(out, candidate) + } + } + } + return out + } + + t.Run("a well-formed event passes", func(t *testing.T) { + e := r.build(r.message(alice)) + if err := CheckAuthEvents(e, authFor(e)); err != nil { + t.Errorf("CheckAuthEvents: %v", err) + } + }) + + t.Run("a create event must cite nothing", func(t *testing.T) { + create := r.state.Create() + if err := CheckAuthEvents(create, nil); err != nil { + t.Errorf("CheckAuthEvents on the create event: %v", err) + } + }) + + t.Run("two events for one piece of state are rejected", func(t *testing.T) { + // Not the same event twice: two *different* member events for + // Alice, which leaves it ambiguous which one authorised this. + second := r.build(r.member(alice, alice, "join", map[string]any{"displayname": "Alice again"})) + e := r.build(r.message(alice)) + wantRejected(t, CheckAuthEvents(e, append(authFor(e), second)), "two entries") + }) + + t.Run("an entry the selection algorithm does not ask for is rejected", func(t *testing.T) { + joinRules := r.setJoinRule(JoinRulePublic, nil) + e := r.build(r.message(alice)) + // A message event never needs the join rules. + wantRejected(t, CheckAuthEvents(e, append(authFor(e), joinRules)), + "the auth events selection algorithm does not ask for") + }) + + t.Run("an entry that resolved to nothing is rejected", func(t *testing.T) { + e := r.build(r.message(alice)) + auth := authFor(e) + for i, a := range auth { + if a.Type() != event.TypeCreate { + auth[i] = nil // a storage lookup that found nothing + break + } + } + wantRejected(t, CheckAuthEvents(e, auth), "missing or were themselves rejected") + }) + + t.Run("a missing create event is rejected", func(t *testing.T) { + e := r.build(r.message(alice)) + var withoutCreate []*event.Event + for _, auth := range authFor(e) { + if auth.Type() != event.TypeCreate { + withoutCreate = append(withoutCreate, auth) + } + } + wantRejected(t, CheckAuthEvents(e, withoutCreate), "no m.room.create event") + }) + + t.Run("an auth event from another room is rejected", func(t *testing.T) { + other := newJoinedRoom(t, roomversion.V11) + e := r.build(r.message(alice)) + auth := authFor(e) + for i, a := range auth { + if a.Type() == event.TypeMember { + auth[i] = other.state.Member(alice) + } + } + wantRejected(t, CheckAuthEvents(e, auth), "belongs to room") + }) +} + +func TestAuthEventKeys(t *testing.T) { + rules := roomversion.MustGet(roomversion.V11) + + t.Run("a message event", func(t *testing.T) { + keys, err := AuthEventKeysFor(rules, "m.room.message", alice, nil, json.RawMessage(`{}`)) + if err != nil { + t.Fatal(err) + } + want := []StateKey{ + {Type: event.TypeCreate}, + {Type: event.TypePowerLevels}, + {Type: event.TypeMember, StateKey: alice}, + } + if fmt.Sprint(keys) != fmt.Sprint(want) { + t.Errorf("keys = %v, want %v", keys, want) + } + }) + + t.Run("a join also needs the join rules", func(t *testing.T) { + keys, err := AuthEventKeysFor(rules, event.TypeMember, bob, ptr(bob), + json.RawMessage(`{"membership":"join"}`)) + if err != nil { + t.Fatal(err) + } + // The sender and target are the same user, so their member event + // is cited once, not twice. + want := []StateKey{ + {Type: event.TypeCreate}, + {Type: event.TypePowerLevels}, + {Type: event.TypeMember, StateKey: bob}, + {Type: event.TypeJoinRules}, + } + if fmt.Sprint(keys) != fmt.Sprint(want) { + t.Errorf("keys = %v, want %v", keys, want) + } + }) + + t.Run("a restricted join cites the authorising user", func(t *testing.T) { + keys, err := AuthEventKeysFor(rules, event.TypeMember, bob, ptr(bob), + json.RawMessage(`{"membership":"join","join_authorised_via_users_server":"`+carol+`"}`)) + if err != nil { + t.Fatal(err) + } + if !containsKey(keys, StateKey{Type: event.TypeMember, StateKey: carol}) { + t.Errorf("keys = %v, want the authorising user's member event", keys) + } + }) + + t.Run("a third-party invite cites the invite event", func(t *testing.T) { + keys, err := AuthEventKeysFor(rules, event.TypeMember, alice, ptr(bob), + json.RawMessage(`{"membership":"invite","third_party_invite":{"signed":{"token":"tok"}}}`)) + if err != nil { + t.Fatal(err) + } + if !containsKey(keys, StateKey{Type: event.TypeThirdPartyInvite, StateKey: "tok"}) { + t.Errorf("keys = %v, want the third-party invite event", keys) + } + }) + + t.Run("a create event cites nothing", func(t *testing.T) { + keys, err := AuthEventKeysFor(rules, event.TypeCreate, alice, ptr(""), json.RawMessage(`{}`)) + if err != nil { + t.Fatal(err) + } + if len(keys) != 0 { + t.Errorf("keys = %v, want none", keys) + } + }) + + t.Run("version 12 leaves out the create event", func(t *testing.T) { + keys, err := AuthEventKeysFor(roomversion.MustGet(roomversion.V12), + "m.room.message", alice, nil, json.RawMessage(`{}`)) + if err != nil { + t.Fatal(err) + } + if containsKey(keys, StateKey{Type: event.TypeCreate}) { + t.Errorf("keys = %v, want no create event in a version 12 room", keys) + } + }) +} + +func containsKey(keys []StateKey, want StateKey) bool { + for _, k := range keys { + if k == want { + return true + } + } + return false +} + +// --- Rule 4: m.federate --- + +func TestFederateFalseKeepsOtherServersOut(t *testing.T) { + r := newRoom(t, roomversion.V11, map[string]any{"m.federate": false}) + r.mustSend(r.member(alice, alice, "join", nil)) + r.setJoinRule(JoinRulePublic, nil) + + wantRejected(t, r.check(r.member(dave, dave, "join", nil)), "does not federate") + wantAllowed(t, r.check(r.member(bob, bob, "join", nil))) +} + +func TestFederateTrueLetsOtherServersIn(t *testing.T) { + r := newRoom(t, roomversion.V11, map[string]any{"m.federate": true}) + r.mustSend(r.member(alice, alice, "join", nil)) + r.setJoinRule(JoinRulePublic, nil) + wantAllowed(t, r.check(r.member(dave, dave, "join", nil))) +} + +// --- Rule 5.3: joining --- + +func TestJoinRules(t *testing.T) { + for _, v := range supportedVersions() { + t.Run(string(v), func(t *testing.T) { + t.Run("public lets anybody in", func(t *testing.T) { + r := newJoinedRoom(t, v) + r.setJoinRule(JoinRulePublic, nil) + wantAllowed(t, r.check(r.member(bob, bob, "join", nil))) + }) + + t.Run("invite keeps the uninvited out", func(t *testing.T) { + r := newJoinedRoom(t, v) + r.setJoinRule(JoinRuleInvite, nil) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "has not been invited") + }) + + t.Run("an invited user may join", func(t *testing.T) { + r := newJoinedRoom(t, v) + r.setJoinRule(JoinRuleInvite, nil) + r.mustSend(r.member(alice, bob, "invite", nil)) + wantAllowed(t, r.check(r.member(bob, bob, "join", nil))) + }) + + t.Run("a banned user may not join a public room", func(t *testing.T) { + r := newJoinedRoom(t, v) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(alice, bob, "ban", nil)) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "banned") + }) + + t.Run("you cannot join on somebody else's behalf", func(t *testing.T) { + r := newJoinedRoom(t, v) + r.setJoinRule(JoinRulePublic, nil) + wantRejected(t, r.check(r.member(alice, bob, "join", nil)), "on their behalf") + }) + + t.Run("a room with no join rules event is invite only", func(t *testing.T) { + r := newJoinedRoom(t, v) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "has not been invited") + }) + + t.Run("an unknown join rule keeps people out", func(t *testing.T) { + r := newJoinedRoom(t, v) + r.setJoinRule("com.example.secret_handshake", nil) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "does not allow joining") + }) + }) + } +} + +// TestCreatorJoinIsAllowedWithNoState covers rule 5.3.1, the join that has +// nothing but the create event behind it. +func TestCreatorJoinIsAllowedWithNoState(t *testing.T) { + for _, v := range supportedVersions() { + t.Run(string(v), func(t *testing.T) { + r := newRoom(t, v, nil) + // The room is invite-only by default and Alice has not been + // invited, so only the special case can allow this. + wantAllowed(t, r.check(r.member(alice, alice, "join", nil))) + }) + } +} + +func TestCreatorJoinSpecialCaseIsNarrow(t *testing.T) { + r := newRoom(t, roomversion.V11, nil) + + t.Run("it does not apply to another user", func(t *testing.T) { + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "has not been invited") + }) + + t.Run("it does not apply once there is another event", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.mustSend(r.member(alice, alice, "leave", nil)) + // Alice has left; her join must now go through the join rules like + // anybody else's, and the room is invite-only. + wantRejected(t, r.check(r.member(alice, alice, "join", nil)), "has not been invited") + }) +} + +// --- Rule 5.3.5: restricted rooms --- + +type fakeVerifier struct{ err error } + +func (f fakeVerifier) VerifyEventSignature(context.Context, string, *event.Event) error { + return f.err +} + +func TestRestrictedJoinRule(t *testing.T) { + setup := func(t *testing.T) *room { + t.Helper() + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}, "invite": 50}) + r.setJoinRule(JoinRuleRestricted, map[string]any{ + "allow": []any{map[string]any{"type": "m.room_membership", "room_id": "!space:warp.test"}}, + }) + return r + } + + t.Run("an unauthorised join is rejected", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "not authorised by any user") + }) + + t.Run("a join authorised by a member who can invite is allowed", func(t *testing.T) { + r := setup(t) + wantAllowed(t, r.checkWith( + r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}), + fakeVerifier{})) + }) + + t.Run("the authorising user must be in the room", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.checkWith( + r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": carol}), + fakeVerifier{}), "is not joined to the room") + }) + + t.Run("the authorising user must be able to invite", func(t *testing.T) { + r := setup(t) + r.setJoinRule(JoinRuleInvite, nil) + r.mustSend(r.member(alice, carol, "invite", nil)) + r.mustSend(r.member(carol, carol, "join", nil)) + r.setJoinRule(JoinRuleRestricted, map[string]any{"allow": []any{}}) + + wantRejected(t, r.checkWith( + r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": carol}), + fakeVerifier{}), "below the invite level") + }) + + t.Run("a bad signature on the authorisation is rejected", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.checkWith( + r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}), + fakeVerifier{err: errors.New("no such key")}), "no valid signature") + }) + + t.Run("without a verifier the join is not allowed", func(t *testing.T) { + r := setup(t) + err := r.check(r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice})) + if !errors.Is(err, ErrNoVerifier) { + t.Errorf("error = %v, want ErrNoVerifier", err) + } + }) + + t.Run("an existing invite is enough on its own", func(t *testing.T) { + r := setup(t) + r.mustSend(r.member(alice, bob, "invite", nil)) + wantAllowed(t, r.check(r.member(bob, bob, "join", nil))) + }) + + t.Run("version 7 has no restricted join rule", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V7) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + r.setJoinRule(JoinRuleRestricted, nil) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), + "does not have the restricted join rule") + }) + + t.Run("version 9 has no knock_restricted join rule", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V9) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + r.setJoinRule(JoinRuleKnockRestricted, nil) + wantRejected(t, r.check(r.member(bob, bob, "join", nil)), + "does not have the knock_restricted join rule") + }) + + t.Run("version 10 has knock_restricted", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V10) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}, "invite": 0}) + r.setJoinRule(JoinRuleKnockRestricted, nil) + wantAllowed(t, r.checkWith( + r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}), + fakeVerifier{})) + }) +} + +// --- Rule 5.4: inviting --- + +func TestInviteRules(t *testing.T) { + t.Run("a stranger cannot invite", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantRejected(t, r.check(r.member(bob, carol, "invite", nil)), "not joined to the room") + }) + + t.Run("inviting somebody already joined is rejected", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + wantRejected(t, r.check(r.member(alice, bob, "invite", nil)), "already") + }) + + t.Run("inviting somebody banned is rejected", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + r.mustSend(r.member(alice, bob, "ban", nil)) + wantRejected(t, r.check(r.member(alice, bob, "invite", nil)), "already") + }) + + t.Run("inviting needs the invite level", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100, bob: 0}, "invite": 50}) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + wantRejected(t, r.check(r.member(bob, carol, "invite", nil)), "below the invite level") + wantAllowed(t, r.check(r.member(alice, carol, "invite", nil))) + }) +} + +// --- Rule 5.5 and 5.6: leaving, kicking and banning --- + +func TestLeaveRules(t *testing.T) { + t.Run("a joined user may leave", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantAllowed(t, r.check(r.member(alice, alice, "leave", nil))) + }) + + t.Run("an invited user may turn the invitation down", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.mustSend(r.member(alice, bob, "invite", nil)) + wantAllowed(t, r.check(r.member(bob, bob, "leave", nil))) + }) + + t.Run("a stranger cannot leave a room they were never in", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantRejected(t, r.check(r.member(bob, bob, "leave", nil)), "cannot leave from membership") + }) + + t.Run("a knocking user may withdraw", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRuleKnock, nil) + r.mustSend(r.member(bob, bob, "knock", nil)) + wantAllowed(t, r.check(r.member(bob, bob, "leave", nil))) + }) +} + +func TestKickRules(t *testing.T) { + setup := func(t *testing.T, levels map[string]any) *room { + t.Helper() + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(levels) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + r.mustSend(r.member(carol, carol, "join", nil)) + return r + } + + t.Run("a moderator may kick somebody below them", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50}, "kick": 50}) + wantAllowed(t, r.check(r.member(bob, carol, "leave", nil))) + }) + + t.Run("below the kick level is a rejection", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 25}, "kick": 50}) + wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "below the kick level") + }) + + t.Run("you cannot kick somebody at your own level", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50, carol: 50}, "kick": 50}) + wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "does not exceed") + }) + + t.Run("you cannot kick somebody above you", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50, carol: 75}, "kick": 50}) + wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "does not exceed") + }) + + t.Run("a non-member cannot kick", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100}, "kick": 0}) + wantRejected(t, r.check(r.member(dave, carol, "leave", nil)), "not joined to the room") + }) +} + +func TestBanRules(t *testing.T) { + setup := func(t *testing.T, levels map[string]any) *room { + t.Helper() + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(levels) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + r.mustSend(r.member(carol, carol, "join", nil)) + return r + } + + t.Run("a moderator may ban somebody below them", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50}, "ban": 50}) + wantAllowed(t, r.check(r.member(bob, carol, "ban", nil))) + }) + + t.Run("below the ban level is a rejection", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 25}, "ban": 50}) + wantRejected(t, r.check(r.member(bob, carol, "ban", nil)), "below the ban level") + }) + + t.Run("you cannot ban a peer", func(t *testing.T) { + r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 50}) + wantRejected(t, r.check(r.member(bob, carol, "ban", nil)), "does not exceed") + }) + + // The spec spells this consequence out: unbanning takes both the kick + // and the ban level, because it is a leave event over a ban. + t.Run("unbanning needs both the kick and the ban level", func(t *testing.T) { + r := setup(t, map[string]any{ + "users": map[string]any{alice: 100, bob: 50}, + "ban": 50, "kick": 100, + }) + r.mustSend(r.member(alice, carol, "ban", nil)) + wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "below the kick level") + + r2 := setup(t, map[string]any{ + "users": map[string]any{alice: 100, bob: 50}, + "ban": 100, "kick": 50, + }) + r2.mustSend(r2.member(alice, carol, "ban", nil)) + wantRejected(t, r2.check(r2.member(bob, carol, "leave", nil)), "below the ban level") + }) +} + +// --- Rule 5.7: knocking --- + +func TestKnockRules(t *testing.T) { + t.Run("knocking works in a knock room", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRuleKnock, nil) + wantAllowed(t, r.check(r.member(bob, bob, "knock", nil))) + }) + + t.Run("knocking on an invite-only room is rejected", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRuleInvite, nil) + wantRejected(t, r.check(r.member(bob, bob, "knock", nil)), "cannot be knocked on") + }) + + t.Run("you cannot knock for somebody else", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRuleKnock, nil) + wantRejected(t, r.check(r.member(bob, carol, "knock", nil)), "on behalf of") + }) + + t.Run("a joined user cannot knock", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRuleKnock, nil) + wantRejected(t, r.check(r.member(alice, alice, "knock", nil)), "while their membership is") + }) + + t.Run("a banned user cannot knock", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + r.setJoinRule(JoinRuleKnock, nil) + r.mustSend(r.member(alice, bob, "ban", nil)) + wantRejected(t, r.check(r.member(bob, bob, "knock", nil)), "while their membership is") + }) + + t.Run("version 6 does not allow knocking", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V6) + r.setJoinRule(JoinRuleKnock, nil) + wantRejected(t, r.check(r.member(bob, bob, "knock", nil)), "does not allow knocking") + }) +} + +func TestUnknownMembershipIsRejected(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantRejected(t, r.check(r.member(alice, alice, "sideways", nil)), "unknown membership") +} + +func TestMemberEventNeedsStateKeyAndMembership(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + + // The auth events selection algorithm cannot run on this event either, + // so the harness is told not to try. + wantRejected(t, r.check(event.Builder{ + Type: event.TypeMember, + Sender: alice, + AuthEvents: []string{}, + Content: mustJSON(t, map[string]any{"membership": "join"}), + }), "must have a state_key") + + wantRejected(t, r.check(event.Builder{ + Type: event.TypeMember, + Sender: alice, + StateKey: ptr(alice), + Content: mustJSON(t, map[string]any{"displayname": "Alice"}), + }), "must have a membership") +} + +// --- Rules 6 to 9 --- + +func TestOnlyMembersMaySendEvents(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantAllowed(t, r.check(r.message(alice))) + wantRejected(t, r.check(r.message(bob)), "not joined to the room") +} + +func TestSendLevels(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{ + "users": map[string]any{alice: 100, bob: 0}, + "events_default": 25, + "events": map[string]any{"com.example.special": 75}, + }) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + + wantRejected(t, r.check(r.message(bob)), "below the 25 needed") + wantAllowed(t, r.check(r.message(alice))) + + wantRejected(t, r.check(event.Builder{Type: "com.example.special", Sender: bob}), "below the 75 needed") + wantAllowed(t, r.check(event.Builder{Type: "com.example.special", Sender: alice})) +} + +func TestStateDefaultAppliesWithNoPowerLevelsEvent(t *testing.T) { + // With no power levels event the creator has 100 and state_default is + // 50, so the creator can send state and nobody else can. + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + + wantAllowed(t, r.check(event.Builder{Type: "m.room.topic", Sender: alice, StateKey: ptr("")})) + wantRejected(t, r.check(event.Builder{Type: "m.room.topic", Sender: bob, StateKey: ptr("")}), + "below the 50 needed") +} + +func TestStateKeyedByUserIDBelongsToThatUser(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}, "state_default": 0}) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + + wantAllowed(t, r.check(event.Builder{Type: "com.example.profile", Sender: bob, StateKey: ptr(bob)})) + wantRejected(t, r.check(event.Builder{Type: "com.example.profile", Sender: bob, StateKey: ptr(alice)}), + "is a user ID that is not the sender") + + // A state key that is not a user ID is not affected. + wantAllowed(t, r.check(event.Builder{Type: "com.example.thing", Sender: bob, StateKey: ptr("anything")})) +} + +func TestThirdPartyInviteEventNeedsInviteLevel(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100, bob: 0}, "invite": 50}) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + + invite := func(sender string) event.Builder { + return event.Builder{ + Type: event.TypeThirdPartyInvite, + Sender: sender, + StateKey: ptr("token"), + Content: mustJSON(t, map[string]any{"display_name": "someone", "public_key": "abc"}), + } + } + wantAllowed(t, r.check(invite(alice))) + wantRejected(t, r.check(invite(bob)), "below the invite level") +} + +// --- Rule 10: power levels --- + +func TestPowerLevelChanges(t *testing.T) { + setup := func(t *testing.T) *room { + t.Helper() + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, + "events_default": 0, "state_default": 50, "ban": 50, "kick": 50, + }) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + r.mustSend(r.member(carol, carol, "join", nil)) + return r + } + + change := func(r *room, sender string, content map[string]any) event.Builder { + return event.Builder{ + Type: event.TypePowerLevels, + Sender: sender, + StateKey: ptr(""), + Content: mustJSON(t, content), + } + } + + t.Run("the first power levels event is always allowed", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + // Alice's level is 100 by default, so she could not otherwise give + // herself 100 and set state_default to 100. + wantAllowed(t, r.check(change(r, alice, map[string]any{ + "users": map[string]any{alice: 100}, "state_default": 100, + }))) + }) + + t.Run("you cannot raise yourself above your own level", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 75, carol: 50}, + })), "would become 75") + }) + + t.Run("you may lower your own level", func(t *testing.T) { + r := setup(t) + wantAllowed(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 25, carol: 50}, + }))) + }) + + t.Run("you may promote somebody up to your own level", func(t *testing.T) { + r := setup(t) + wantAllowed(t, r.check(change(r, alice, map[string]any{ + "users": map[string]any{alice: 100, bob: 100, carol: 50}, + }))) + }) + + t.Run("you cannot demote a peer", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 0}, + })), "at the sender's own power level") + }) + + t.Run("you cannot remove a peer", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50}, + })), "at the sender's own power level") + }) + + t.Run("you cannot demote somebody above you", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 50, bob: 50, carol: 50}, + })), "above the sender's power level") + }) + + t.Run("an admin may demote anybody", func(t *testing.T) { + r := setup(t) + wantAllowed(t, r.check(change(r, alice, map[string]any{ + "users": map[string]any{alice: 100, bob: 0, carol: 0}, + }))) + }) + + t.Run("you cannot raise a named level above your own", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 75, + })), "ban would become 75") + }) + + t.Run("you cannot lower a level that is above your own", func(t *testing.T) { + r := setup(t) + r.setPowerLevels(map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 90, + }) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 50, + })), "ban is 90") + }) + + t.Run("the events map is protected the same way", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, + "events": map[string]any{"m.room.name": 75}, + })), "events.m.room.name would become 75") + wantAllowed(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, + "events": map[string]any{"m.room.name": 50}, + }))) + }) + + t.Run("the notifications map is protected the same way", func(t *testing.T) { + r := setup(t) + wantRejected(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, + "notifications": map[string]any{"room": 75}, + })), "notifications.room would become 75") + }) + + t.Run("an unchanged event is allowed", func(t *testing.T) { + r := setup(t) + wantAllowed(t, r.check(change(r, bob, map[string]any{ + "users": map[string]any{alice: 100, bob: 50, carol: 50}, + "events_default": 0, "state_default": 50, "ban": 50, "kick": 50, + }))) + }) +} + +func TestPowerLevelContentValidation(t *testing.T) { + t.Run("version 10 requires integers", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V10) + wantRejected(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"ban": "50"}), + }), "is not an integer") + }) + + t.Run("version 9 accepts a level written as a string", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V9) + wantAllowed(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"ban": "50"}), + })) + }) + + t.Run("version 9 still rejects a string that is not a number", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V9) + wantRejected(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"ban": "high"}), + }), "not an integer") + }) + + t.Run("a users key that is not a user ID is rejected", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantRejected(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"users": map[string]any{"alice": 50}}), + }), "users") + }) + + t.Run("an events map with a non-integer value is rejected", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantRejected(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"events": map[string]any{"m.room.name": true}}), + }), "events") + }) +} + +// --- Room version 12: creators --- + +func TestVersion12CreatorsHaveInfinitePower(t *testing.T) { + r := newRoom(t, roomversion.V12, map[string]any{"additional_creators": []string{bob}}) + r.mustSend(r.member(alice, alice, "join", nil)) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + r.mustSend(r.member(carol, carol, "join", nil)) + + // A power levels event that would strip everybody's power does not + // touch the creators. + r.setPowerLevels(map[string]any{ + "users": map[string]any{carol: 100}, + "state_default": 100, "events_default": 100, "kick": 100, "ban": 100, + }) + + t.Run("a creator can still send state", func(t *testing.T) { + wantAllowed(t, r.check(event.Builder{Type: "m.room.topic", Sender: alice, StateKey: ptr("")})) + }) + + t.Run("an additional creator can too", func(t *testing.T) { + wantAllowed(t, r.check(event.Builder{Type: "m.room.topic", Sender: bob, StateKey: ptr("")})) + }) + + t.Run("a creator outranks a user with level 100", func(t *testing.T) { + wantAllowed(t, r.check(r.member(alice, carol, "ban", nil))) + }) + + t.Run("a user with level 100 cannot kick a creator", func(t *testing.T) { + wantRejected(t, r.check(r.member(carol, bob, "leave", nil)), "does not exceed") + }) + + t.Run("one creator cannot kick another", func(t *testing.T) { + wantRejected(t, r.check(r.member(alice, bob, "leave", nil)), "does not exceed") + }) + + t.Run("a creator cannot be given a level", func(t *testing.T) { + wantRejected(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"users": map[string]any{alice: 100}}), + }), "must not give a level to the room creator") + + wantRejected(t, r.check(event.Builder{ + Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""), + Content: mustJSON(t, map[string]any{"users": map[string]any{bob: 50}}), + }), "must not give a level to the room creator") + }) +} + +func TestVersion11CreatorIsOrdinaryOnceLevelsExist(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(carol, carol, "join", nil)) + // The creator's 100 only applies while there is no power levels + // event. Here Alice writes herself out of it. + r.setPowerLevels(map[string]any{"users": map[string]any{carol: 100}, "state_default": 50}) + + wantRejected(t, r.check(event.Builder{Type: "m.room.topic", Sender: alice, StateKey: ptr("")}), + "below the 50 needed") +} + +// --- Third-party invites --- + +func TestThirdPartyInviteFlow(t *testing.T) { + identityKey, err := signing.Generate("1") + if err != nil { + t.Fatal(err) + } + + // The identity server signs a statement binding the token to a user. + signedJSON, err := canonicaljson.Marshal(map[string]any{"mxid": bob, "token": "tok"}) + if err != nil { + t.Fatal(err) + } + signed, err := signing.SignJSON("identity.test", identityKey, signedJSON) + if err != nil { + t.Fatal(err) + } + + setup := func(t *testing.T, publicKey string) *room { + t.Helper() + r := newJoinedRoom(t, roomversion.V11) + r.mustSend(event.Builder{ + Type: event.TypeThirdPartyInvite, + Sender: alice, + StateKey: ptr("tok"), + Content: mustJSON(t, map[string]any{ + "display_name": "bob@example.org", + "key_validity_url": "https://identity.test/_matrix/identity/v2/pubkey/isvalid", + "public_key": publicKey, + }), + }) + return r + } + + goodKey := base64.RawStdEncoding.EncodeToString(identityKey.Public) + + t.Run("a correctly signed invite is allowed", func(t *testing.T) { + r := setup(t, goodKey) + wantAllowed(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"display_name": "bob@example.org", "signed": json.RawMessage(signed)}, + }))) + }) + + t.Run("a signature from another key is rejected", func(t *testing.T) { + other, err := signing.Generate("2") + if err != nil { + t.Fatal(err) + } + r := setup(t, base64.RawStdEncoding.EncodeToString(other.Public)) + wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": json.RawMessage(signed)}, + })), "no signature on the third-party invite matches") + }) + + t.Run("public_keys is accepted as well as public_key", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.mustSend(event.Builder{ + Type: event.TypeThirdPartyInvite, + Sender: alice, + StateKey: ptr("tok"), + Content: mustJSON(t, map[string]any{ + "display_name": "bob@example.org", + "public_keys": []any{ + map[string]any{"public_key": "AAAA"}, + map[string]any{"public_key": goodKey}, + }, + }), + }) + wantAllowed(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": json.RawMessage(signed)}, + }))) + }) + + t.Run("the mxid must match the invited user", func(t *testing.T) { + r := setup(t, goodKey) + wantRejected(t, r.check(r.member(alice, carol, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": json.RawMessage(signed)}, + })), "but the event invites") + }) + + t.Run("there must be a matching invite event in state", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": json.RawMessage(signed)}, + })), "no m.room.third_party_invite in the room state") + }) + + t.Run("the redeemer must be the issuer", func(t *testing.T) { + r := setup(t, goodKey) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(carol, carol, "join", nil)) + wantRejected(t, r.check(r.member(carol, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": json.RawMessage(signed)}, + })), "was issued by") + }) + + t.Run("signed needs both mxid and token", func(t *testing.T) { + r := setup(t, goodKey) + wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": map[string]any{"mxid": bob}}, + })), "needs both mxid and token") + }) + + t.Run("there must be a signed property", func(t *testing.T) { + r := setup(t, goodKey) + wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"display_name": "x"}, + })), "no signed property") + }) + + t.Run("a banned user cannot be invited this way either", func(t *testing.T) { + r := setup(t, goodKey) + r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}}) + r.mustSend(r.member(alice, bob, "ban", nil)) + wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{ + "third_party_invite": map[string]any{"signed": json.RawMessage(signed)}, + })), "banned") + }) +} + +// --- Redactions --- + +func TestRedactionsPassTheRulesButNeedASecondTest(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + r.setPowerLevels(map[string]any{ + "users": map[string]any{alice: 100, bob: 0}, "redact": 50, "events_default": 0, + }) + r.setJoinRule(JoinRulePublic, nil) + r.mustSend(r.member(bob, bob, "join", nil)) + + target := r.build(r.message(alice)) + + // Rule-wise a redaction from anybody in the room is fine: the redact + // level is not an authorisation rule. + redaction := event.Builder{ + Type: event.TypeRedaction, + Sender: bob, + Redacts: target.EventID(), + Content: mustJSON(t, map[string]any{"reason": "spam"}), + } + wantAllowed(t, r.check(redaction)) + + // Dave is in the room too, from another server, and also below the + // redact level. + r.mustSend(r.member(dave, dave, "join", nil)) + + pl, err := ResolvePowerLevels(r.state.PowerLevels(), r.state.Create(), r.rules) + if err != nil { + t.Fatal(err) + } + + t.Run("whether it applies is a separate question", func(t *testing.T) { + fromDave := r.build(event.Builder{ + Type: event.TypeRedaction, Sender: dave, Redacts: target.EventID(), + }) + if ShouldApplyRedaction(fromDave, target, pl) { + t.Error("Dave is below the redact level and is on another server, so his redaction should not apply") + } + }) + + t.Run("the redact level is enough", func(t *testing.T) { + fromAlice := r.build(event.Builder{ + Type: event.TypeRedaction, Sender: alice, Redacts: target.EventID(), + }) + if !ShouldApplyRedaction(fromAlice, target, pl) { + t.Error("Alice is above the redact level, so her redaction should apply") + } + }) + + t.Run("the same server is enough", func(t *testing.T) { + // Bob is on the same server as the event's sender, so his server + // may take the event back whatever his power level. + own := r.build(event.Builder{ + Type: event.TypeRedaction, Sender: bob, Redacts: target.EventID(), + }) + if !ShouldApplyRedaction(own, target, pl) { + t.Error("a redaction from the same server as the original should apply") + } + }) + + t.Run("another server without the level is not", func(t *testing.T) { + remote := r.build(event.Builder{ + Type: event.TypeRedaction, Sender: dave, Redacts: target.EventID(), + }) + if ShouldApplyRedaction(remote, target, pl) { + t.Error("a redaction from another server below the redact level should not apply") + } + }) +} + +// --- Power level resolution --- + +func TestResolvePowerLevels(t *testing.T) { + t.Run("defaults with no power levels event", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + pl, err := ResolvePowerLevels(nil, r.state.Create(), r.rules) + if err != nil { + t.Fatal(err) + } + if got := pl.UserLevel(alice); got != CreatorPowerLevel { + t.Errorf("the creator's level = %d, want %d", got, CreatorPowerLevel) + } + if got := pl.UserLevel(bob); got != 0 { + t.Errorf("another user's level = %d, want 0", got) + } + // state_default is 50 even here, which is what stops a new member + // rewriting the room's state. + if got := pl.SendLevel("m.room.topic", true); got != 50 { + t.Errorf("state_default = %d, want 50", got) + } + if got := pl.SendLevel("m.room.message", false); got != 0 { + t.Errorf("events_default = %d, want 0", got) + } + for name, got := range map[string]int64{"ban": pl.Ban, "kick": pl.Kick, "redact": pl.Redact} { + if got != 50 { + t.Errorf("%s = %d, want 50", name, got) + } + } + if pl.Invite != 0 { + t.Errorf("invite = %d, want 0", pl.Invite) + } + if got := pl.NotificationLevel("room"); got != 50 { + t.Errorf("notifications.room = %d, want 50", got) + } + }) + + t.Run("defaults fill in around what the event sets", func(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + plEvent := r.setPowerLevels(map[string]any{"users": map[string]any{alice: 42}}) + pl, err := ResolvePowerLevels(plEvent, r.state.Create(), r.rules) + if err != nil { + t.Fatal(err) + } + if got := pl.UserLevel(alice); got != 42 { + t.Errorf("alice = %d, want 42", got) + } + // The creator's 100 is gone: the event is authoritative. + if got := pl.UserLevel(bob); got != 0 { + t.Errorf("bob = %d, want 0", got) + } + if pl.Ban != 50 || pl.StateDefault != 50 || pl.Invite != 0 { + t.Errorf("defaults were not filled in: ban=%d state_default=%d invite=%d", + pl.Ban, pl.StateDefault, pl.Invite) + } + }) + + t.Run("version 12 creators are infinite", func(t *testing.T) { + r := newRoom(t, roomversion.V12, map[string]any{"additional_creators": []string{bob}}) + pl, err := ResolvePowerLevels(nil, r.state.Create(), r.rules) + if err != nil { + t.Fatal(err) + } + for _, creator := range []string{alice, bob} { + if got := pl.UserLevel(creator); got != InfinitePowerLevel { + t.Errorf("%s = %d, want InfinitePowerLevel", creator, got) + } + if !pl.IsCreator(creator) { + t.Errorf("IsCreator(%s) = false", creator) + } + } + if pl.IsCreator(carol) { + t.Error("IsCreator(carol) = true") + } + }) +} + +func TestCreators(t *testing.T) { + t.Run("version 11 has one creator", func(t *testing.T) { + r := newRoom(t, roomversion.V11, map[string]any{"additional_creators": []string{bob}}) + creators, err := Creators(r.state.Create()) + if err != nil { + t.Fatal(err) + } + if len(creators) != 1 || creators[0] != alice { + t.Errorf("creators = %v, want just %s", creators, alice) + } + }) + + t.Run("version 12 includes the additional creators", func(t *testing.T) { + r := newRoom(t, roomversion.V12, map[string]any{"additional_creators": []string{bob, carol}}) + creators, err := Creators(r.state.Create()) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(creators) != fmt.Sprint([]string{alice, bob, carol}) { + t.Errorf("creators = %v", creators) + } + }) +} + +func TestStateHelpers(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + + if got := r.state.Membership(alice); got != event.MembershipJoin { + t.Errorf("Membership(alice) = %q, want join", got) + } + // A user with no member event is treated exactly like one who left. + if got := r.state.Membership(dave); got != event.MembershipLeave { + t.Errorf("Membership(dave) = %q, want leave", got) + } + if got := r.state.JoinRule(); got != JoinRuleInvite { + t.Errorf("JoinRule() = %q, want invite by default", got) + } + r.setJoinRule(JoinRulePublic, nil) + if got := r.state.JoinRule(); got != JoinRulePublic { + t.Errorf("JoinRule() = %q, want public", got) + } + if r.state.PowerLevels() != nil { + t.Error("PowerLevels() should be nil in a room with no power levels event") + } + if r.state.Create() == nil { + t.Error("Create() should not be nil") + } +} + +func TestCheckWithoutACreateEvent(t *testing.T) { + r := newJoinedRoom(t, roomversion.V11) + e := r.build(r.message(alice)) + wantRejected(t, Check(context.Background(), e, State{}, nil), "no m.room.create event") +} diff --git a/server/internal/matrix/auth/authevents.go b/server/internal/matrix/auth/authevents.go new file mode 100644 index 0000000..39f1a1d --- /dev/null +++ b/server/internal/matrix/auth/authevents.go @@ -0,0 +1,201 @@ +package auth + +import ( + "encoding/json" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" +) + +// AuthEventKeys returns the state an event must cite in its auth_events, +// per the auth events selection algorithm in the server specification. +// +// The list is what makes an event self-contained: a server receiving it +// years later, with no idea what the room looks like now, can fetch +// exactly these events and re-run the rules. That is also why the set is +// as small as it is — it is the state the rules read, and nothing else. +func AuthEventKeys(e *event.Event) ([]StateKey, error) { + stateKey := (*string)(nil) + if sk, ok := e.StateKey(); ok { + stateKey = &sk + } + return AuthEventKeysFor(e.Rules(), e.Type(), e.Sender(), stateKey, e.Content()) +} + +// AuthEventKeysFor is AuthEventKeys for an event that does not exist yet, +// so that the roomserver can look up the state it needs before building it. +func AuthEventKeysFor(rules roomversion.Rules, eventType, sender string, stateKey *string, content json.RawMessage) ([]StateKey, error) { + // The create event starts the room; there is nothing for it to cite. + if eventType == event.TypeCreate { + return nil, nil + } + + var keys []StateKey + // From room version 12 the create event is left out: the room ID *is* + // its event ID, so every event already refers to it. + if !rules.CreateEventExcludedFromAuthEvents { + keys = append(keys, StateKey{Type: event.TypeCreate}) + } + keys = append(keys, + StateKey{Type: event.TypePowerLevels}, + StateKey{Type: event.TypeMember, StateKey: sender}, + ) + + if eventType != event.TypeMember { + return keys, nil + } + if stateKey == nil { + return nil, reject("an m.room.member event must have a state_key") + } + keys = append(keys, StateKey{Type: event.TypeMember, StateKey: *stateKey}) + + var parsed memberContent + if err := json.Unmarshal(content, &parsed); err != nil { + return nil, reject("m.room.member content: %v", err) + } + + switch parsed.Membership { + case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock: + keys = append(keys, StateKey{Type: event.TypeJoinRules}) + } + + if parsed.Membership == event.MembershipInvite && parsed.ThirdPartyInvite != nil { + var signed struct { + Token string `json:"token"` + } + if err := json.Unmarshal(parsed.ThirdPartyInvite.Signed, &signed); err == nil && signed.Token != "" { + keys = append(keys, StateKey{Type: event.TypeThirdPartyInvite, StateKey: signed.Token}) + } + } + if parsed.Membership == event.MembershipJoin && + parsed.JoinAuthorisedViaUsersServer != "" && + rules.RestrictedJoinRule { + keys = append(keys, StateKey{Type: event.TypeMember, StateKey: parsed.JoinAuthorisedViaUsersServer}) + } + return dedupe(keys), nil +} + +// CheckAuthEvents applies rule 3 (rule 2 on the version 11 page): the +// auth_events an event declares are well-formed and are the ones the +// selection algorithm asks for. +// +// authEvents are the events the IDs in e.AuthEventIDs() resolve to, with +// any that this server rejected already left out — rule 3.3 asks whether +// an auth event was itself rejected, which only storage can answer, so +// leaving it out of the slice is how the caller reports it. A missing +// entry is then caught here as a citation that does not resolve. +func CheckAuthEvents(e *event.Event, authEvents []*event.Event) error { + if e.Type() == event.TypeCreate { + if len(e.AuthEventIDs()) > 0 { + return reject("an m.room.create event must have no auth_events") + } + return nil + } + + allowed, err := AuthEventKeys(e) + if err != nil { + return err + } + allowedSet := make(map[StateKey]bool, len(allowed)) + for _, k := range allowed { + allowedSet[k] = true + } + + seen := make(map[StateKey]bool, len(authEvents)) + hasCreate := false + resolved := 0 + for _, auth := range authEvents { + if auth == nil { + // A caller builds this slice from storage lookups, so a nil is + // a citation that resolved to nothing. Counting it as missing + // is both the right answer and cheaper than trusting every + // caller to filter their own misses. + continue + } + resolved++ + + sk, ok := auth.StateKey() + if !ok { + return reject("auth_events contains %s, which is not a state event", auth.EventID()) + } + key := StateKey{Type: auth.Type(), StateKey: sk} + + // Rule 3.1: two events for the same piece of state leave it + // ambiguous which one authorised this event. + if seen[key] { + return reject("auth_events has two entries for (%s, %q)", key.Type, key.StateKey) + } + seen[key] = true + + // Rule 3.2. + if !allowedSet[key] { + return reject("auth_events contains (%s, %q), which the auth events selection algorithm does not ask for", + key.Type, key.StateKey) + } + + // Rule 3.4 (2.5 on the version 11 page): an auth event from + // another room proves nothing about this one. + if auth.RoomID() != e.RoomID() { + return reject("the auth event %s belongs to room %q, not %q", auth.EventID(), auth.RoomID(), e.RoomID()) + } + if key.Type == event.TypeCreate { + hasCreate = true + } + } + + // Rule 2.4 on the version 11 page; version 12 has no such sub-rule, + // because the create event is not cited at all and its new rule 2 — + // the room ID is the create event's ID — carries the same guarantee. + if !e.Rules().CreateEventExcludedFromAuthEvents && !hasCreate { + return reject("auth_events has no m.room.create event") + } + + // Rule 3.3 is the caller's to answer, and this is where their answer + // lands: an ID that resolved to nothing was either never seen or was + // itself rejected, and either way it authorises nothing. The check is + // last so that a specific fault above is reported as itself. + if resolved < len(e.AuthEventIDs()) { + return reject("%d of the %d auth_events are missing or were themselves rejected", + len(e.AuthEventIDs())-resolved, len(e.AuthEventIDs())) + } + return nil +} + +// SelectAuthEvents picks the events from a room's state that belong in an +// event's auth_events, ready to be turned into a list of event IDs. +func SelectAuthEvents(rules roomversion.Rules, eventType, sender string, stateKey *string, content json.RawMessage, st State) ([]*event.Event, error) { + keys, err := AuthEventKeysFor(rules, eventType, sender, stateKey, content) + if err != nil { + return nil, err + } + out := make([]*event.Event, 0, len(keys)) + for _, k := range keys { + if e := st[k]; e != nil { + out = append(out, e) + } + } + if len(out) > event.MaxAuthEvents { + // The selection algorithm cannot produce this many, so reaching it + // means the algorithm and the limit have drifted apart. + return nil, fmt.Errorf("auth events selection produced %d events, more than the %d an event may carry", + len(out), event.MaxAuthEvents) + } + return out, nil +} + +// dedupe removes repeated keys, which happens when a user invites or +// authorises themselves: the sender's member event and the target's are +// then the same piece of state. +func dedupe(keys []StateKey) []StateKey { + seen := make(map[StateKey]bool, len(keys)) + out := make([]StateKey, 0, len(keys)) + for _, k := range keys { + if seen[k] { + continue + } + seen[k] = true + out = append(out, k) + } + return out +} diff --git a/server/internal/matrix/auth/member.go b/server/internal/matrix/auth/member.go new file mode 100644 index 0000000..51a43fa --- /dev/null +++ b/server/internal/matrix/auth/member.go @@ -0,0 +1,358 @@ +package auth + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/id" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// memberContent is the part of an m.room.member event's content the rules +// read. The pointer and json.RawMessage fields are there so that "absent" +// can be told from "empty", which several of the rules turn on. +type memberContent struct { + Membership string `json:"membership"` + JoinAuthorisedViaUsersServer string `json:"join_authorised_via_users_server"` + ThirdPartyInvite *thirdPartyInvite `json:"third_party_invite"` +} + +type thirdPartyInvite struct { + Signed json.RawMessage `json:"signed"` +} + +// checkMember applies rule 5, the membership rules. +// +// These are the only rules a user who is not in the room may satisfy, so +// they carry the whole of who can get in: invitations, public rooms, +// knocking, restricted rooms, and who may throw whom out. +func checkMember(ctx context.Context, e *event.Event, st State, create *event.Event, pl *PowerLevels, verifier Verifier) error { + rules := e.Rules() + + // Rule 5.1. + target, ok := e.StateKey() + if !ok { + return reject("an m.room.member event must have a state_key") + } + var content memberContent + if err := json.Unmarshal(e.Content(), &content); err != nil { + return reject("m.room.member content: %v", err) + } + if content.Membership == "" { + return reject("an m.room.member event must have a membership") + } + + // Rule 5.2: a join authorised by another user must be signed by that + // user's server, not merely by the joining user's. Without it, anybody + // could claim to have been let into a restricted room. + if rules.RestrictedJoinRule && content.JoinAuthorisedViaUsersServer != "" { + if err := checkJoinAuthorisation(ctx, e, content.JoinAuthorisedViaUsersServer, verifier); err != nil { + return err + } + } + + senderMembership := st.Membership(e.Sender()) + targetMembership := st.Membership(target) + + switch content.Membership { + case event.MembershipJoin: + return checkJoin(e, st, create, pl, target, targetMembership, content) + case event.MembershipInvite: + return checkInvite(e, st, pl, target, senderMembership, targetMembership, content) + case event.MembershipLeave: + return checkLeave(e, pl, target, senderMembership, targetMembership) + case event.MembershipBan: + return checkBan(e, pl, target, senderMembership) + case event.MembershipKnock: + if !rules.KnockJoinRule { + return reject("room version %s does not allow knocking", rules.Version) + } + return checkKnock(e, st, target, senderMembership) + default: + // Rule 5.8. + return reject("unknown membership %q", content.Membership) + } +} + +// checkJoin applies rule 5.3. +func checkJoin(e *event.Event, st State, create *event.Event, pl *PowerLevels, target, targetMembership string, content memberContent) error { + rules := e.Rules() + + // Rule 5.3.1: the creator's own join, the second event in the room. + // There is no state yet to authorise it against, so it is recognised + // by its shape: it follows the create event and nothing else. + prev := e.PrevEventIDs() + if len(prev) == 1 && prev[0] == create.EventID() && target == create.Sender() { + return nil + } + + // Rule 5.3.2: you join yourself. Being dragged into a room is an + // invite, which the invited user then accepts with a join of their own. + if e.Sender() != target { + return reject("the sender may not join %q on their behalf", target) + } + // Rule 5.3.3. + if targetMembership == event.MembershipBan { + return reject("the user is banned from the room") + } + + joinRule := st.JoinRule() + switch joinRule { + case JoinRuleInvite, JoinRuleKnock: + // Rule 5.3.4. A knock does not admit anybody by itself: it asks, + // and somebody with the power to invite still has to answer. + if targetMembership == event.MembershipInvite || targetMembership == event.MembershipJoin { + return nil + } + return reject("the room's join rule is %q and the user has not been invited (membership is %q)", + joinRule, targetMembership) + + case JoinRuleRestricted, JoinRuleKnockRestricted: + if joinRule == JoinRuleRestricted && !rules.RestrictedJoinRule { + return reject("room version %s does not have the restricted join rule", rules.Version) + } + if joinRule == JoinRuleKnockRestricted && !rules.KnockRestrictedJoinRule { + return reject("room version %s does not have the knock_restricted join rule", rules.Version) + } + // Rule 5.3.5.1: an existing invite or join still works, so a + // restricted room is never *harder* to enter than an invite room. + if targetMembership == event.MembershipInvite || targetMembership == event.MembershipJoin { + return nil + } + // Rule 5.3.5.2. The authorising user has to be in the room and + // able to invite, because what they are really doing is vouching + // that the joining user is a member of one of the allowed rooms — + // something this server may be unable to see for itself. + authoriser := content.JoinAuthorisedViaUsersServer + if authoriser == "" { + return reject("the room's join rule is %q and the join is not authorised by any user", joinRule) + } + if m := st.Membership(authoriser); m != event.MembershipJoin { + return reject("the authorising user %q is not joined to the room (membership is %q)", authoriser, m) + } + if level := pl.UserLevel(authoriser); level < pl.Invite { + return reject("the authorising user %q has power level %s, below the invite level %d", + authoriser, levelString(level), pl.Invite) + } + // Rule 5.3.5.3. + return nil + + case JoinRulePublic: + // Rule 5.3.6. + return nil + + default: + // Rule 5.3.7. "private" lands here, and so does any join rule + // from a future room version: a rule we cannot evaluate must keep + // people out rather than let them in. + return reject("the room's join rule %q does not allow joining", joinRule) + } +} + +// checkInvite applies rule 5.4. +func checkInvite(e *event.Event, st State, pl *PowerLevels, target, senderMembership, targetMembership string, content memberContent) error { + // Rule 5.4.1: a third-party invite is an invitation issued to an email + // address or phone number before the server knew which user it was. + // The identity server's signature stands in for the inviter's power. + if content.ThirdPartyInvite != nil { + return checkThirdPartyInvite(e, st, target, targetMembership, content) + } + + // Rule 5.4.2. + if senderMembership != event.MembershipJoin { + return reject("the sender is not joined to the room (membership is %q)", senderMembership) + } + // Rule 5.4.3. + if targetMembership == event.MembershipJoin || targetMembership == event.MembershipBan { + return reject("the user is already %q in the room", targetMembership) + } + // Rule 5.4.4. + if level := pl.UserLevel(e.Sender()); level < pl.Invite { + return reject("the sender's power level %s is below the invite level %d", levelString(level), pl.Invite) + } + // Rule 5.4.5 is the negation of the above. + return nil +} + +// checkThirdPartyInvite applies rule 5.4.1. +func checkThirdPartyInvite(e *event.Event, st State, target, targetMembership string, content memberContent) error { + // Rule 5.4.1.1. + if targetMembership == event.MembershipBan { + return reject("the user is banned from the room") + } + // Rule 5.4.1.2. + if len(content.ThirdPartyInvite.Signed) == 0 { + return reject("the third_party_invite has no signed property") + } + var signed struct { + MXID string `json:"mxid"` + Token string `json:"token"` + } + if err := json.Unmarshal(content.ThirdPartyInvite.Signed, &signed); err != nil { + return reject("the third_party_invite signed property: %v", err) + } + // Rule 5.4.1.3. + if signed.MXID == "" || signed.Token == "" { + return reject("the third_party_invite signed property needs both mxid and token") + } + // Rule 5.4.1.4. + if signed.MXID != target { + return reject("the third_party_invite names %q but the event invites %q", signed.MXID, target) + } + // Rule 5.4.1.5. + inviteEvent := st.ThirdPartyInvite(signed.Token) + if inviteEvent == nil { + return reject("there is no m.room.third_party_invite in the room state for token %q", signed.Token) + } + // Rule 5.4.1.6: the same user who issued the invitation must be the + // one redeeming it. + if inviteEvent.Sender() != e.Sender() { + return reject("the third-party invite was issued by %q, not by %q", inviteEvent.Sender(), e.Sender()) + } + // Rules 5.4.1.7 and 5.4.1.8. + if !VerifyThirdPartyInviteSignature(content.ThirdPartyInvite.Signed, inviteEvent) { + return reject("no signature on the third-party invite matches a public key in %s", inviteEvent.EventID()) + } + return nil +} + +// checkLeave applies rule 5.5, which covers both leaving and being kicked. +func checkLeave(e *event.Event, pl *PowerLevels, target, senderMembership, targetMembership string) error { + rules := e.Rules() + + // Rule 5.5.1: leaving on your own account, which also covers turning + // down an invitation and withdrawing a knock. + if e.Sender() == target { + switch targetMembership { + case event.MembershipInvite, event.MembershipJoin: + return nil + case event.MembershipKnock: + if rules.KnockJoinRule { + return nil + } + } + return reject("the user cannot leave from membership %q", targetMembership) + } + + // Rule 5.5.2. + if senderMembership != event.MembershipJoin { + return reject("the sender is not joined to the room (membership is %q)", senderMembership) + } + + senderLevel := pl.UserLevel(e.Sender()) + + // Rule 5.5.3: unbanning is a leave over a ban, and it takes the power + // to have banned in the first place. + if targetMembership == event.MembershipBan && senderLevel < pl.Ban { + return reject("the sender's power level %s is below the ban level %d needed to unban", + levelString(senderLevel), pl.Ban) + } + // Rule 5.5.4. + if senderLevel < pl.Kick { + return reject("the sender's power level %s is below the kick level %d", levelString(senderLevel), pl.Kick) + } + if targetLevel := pl.UserLevel(target); targetLevel >= senderLevel { + return reject("the sender's power level %s does not exceed %q's power level %s", + levelString(senderLevel), target, levelString(targetLevel)) + } + // Rule 5.5.5 is the negation of the above. + return nil +} + +// checkBan applies rule 5.6. +func checkBan(e *event.Event, pl *PowerLevels, target, senderMembership string) error { + // Rule 5.6.1. + if senderMembership != event.MembershipJoin { + return reject("the sender is not joined to the room (membership is %q)", senderMembership) + } + // Rule 5.6.2. + senderLevel := pl.UserLevel(e.Sender()) + if senderLevel < pl.Ban { + return reject("the sender's power level %s is below the ban level %d", levelString(senderLevel), pl.Ban) + } + if targetLevel := pl.UserLevel(target); targetLevel >= senderLevel { + return reject("the sender's power level %s does not exceed %q's power level %s", + levelString(senderLevel), target, levelString(targetLevel)) + } + // Rule 5.6.3 is the negation of the above. + return nil +} + +// checkKnock applies rule 5.7. +func checkKnock(e *event.Event, st State, target, senderMembership string) error { + // Rule 5.7.1. + joinRule := st.JoinRule() + if joinRule != JoinRuleKnock && joinRule != JoinRuleKnockRestricted { + return reject("the room's join rule is %q, so it cannot be knocked on", joinRule) + } + // Rule 5.7.2. + if e.Sender() != target { + return reject("the sender may not knock on behalf of %q", target) + } + // Rule 5.7.3: knocking is allowed from anything but banned, invited or + // joined — there is no point knocking on a door that is already open. + switch senderMembership { + case event.MembershipBan, event.MembershipInvite, event.MembershipJoin: + // Rule 5.7.4. + return reject("the user cannot knock while their membership is %q", senderMembership) + default: + return nil + } +} + +// checkJoinAuthorisation applies rule 5.2. +func checkJoinAuthorisation(ctx context.Context, e *event.Event, authoriser string, verifier Verifier) error { + serverName, err := id.ServerNameOf(authoriser) + if err != nil { + return reject("join_authorised_via_users_server: %v", err) + } + if verifier == nil { + return fmt.Errorf("%w: %s is authorised by %q, whose server's signature cannot be checked", + ErrNoVerifier, e.EventID(), authoriser) + } + if err := verifier.VerifyEventSignature(ctx, serverName, e); err != nil { + return reject("the join is authorised by %q but carries no valid signature from %q: %v", + authoriser, serverName, err) + } + return nil +} + +// thirdPartyInviteKeys returns the public keys an m.room.third_party_invite +// event published, from either of the two places the spec allows. +func thirdPartyInviteKeys(e *event.Event) ([]ed25519.PublicKey, error) { + var content struct { + PublicKey string `json:"public_key"` + PublicKeys []struct { + PublicKey string `json:"public_key"` + } `json:"public_keys"` + } + if err := json.Unmarshal(e.Content(), &content); err != nil { + return nil, reject("m.room.third_party_invite content: %v", err) + } + + encoded := make([]string, 0, len(content.PublicKeys)+1) + if content.PublicKey != "" { + encoded = append(encoded, content.PublicKey) + } + for _, k := range content.PublicKeys { + if k.PublicKey != "" { + encoded = append(encoded, k.PublicKey) + } + } + + keys := make([]ed25519.PublicKey, 0, len(encoded)) + for _, s := range encoded { + raw, err := signing.DecodeBase64(s) + if err != nil || len(raw) != ed25519.PublicKeySize { + // A key we cannot read cannot match a signature, and the rule + // asks whether *any* key matches — so a bad one is skipped + // rather than fatal. + continue + } + keys = append(keys, ed25519.PublicKey(raw)) + } + return keys, nil +} diff --git a/server/internal/matrix/auth/powerlevels.go b/server/internal/matrix/auth/powerlevels.go new file mode 100644 index 0000000..68c6e0c --- /dev/null +++ b/server/internal/matrix/auth/powerlevels.go @@ -0,0 +1,270 @@ +package auth + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/id" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" +) + +// InfinitePowerLevel is the level a room creator has from room version 12. +// +// The spec calls it "infinitely high". Since every level that can be +// written down is capped at 2^53-1 by canonical JSON, the largest int64 +// stands in for infinity without any comparison going wrong — and nothing +// here does arithmetic on a level, only compares them. +const InfinitePowerLevel int64 = math.MaxInt64 + +// The default levels, which apply key by key: a power levels event that +// sets only "users" leaves every other level at the value below. +// +// state_default is 50 even in a room with no m.room.power_levels event at +// all, which looks odd until you notice that the creator has 100 there, so +// the only person who can send state is the one person who should be able +// to. +const ( + DefaultBan int64 = 50 + DefaultKick int64 = 50 + DefaultRedact int64 = 50 + DefaultInvite int64 = 0 + DefaultEventsDefault int64 = 0 + DefaultStateDefault int64 = 50 + DefaultUsersDefault int64 = 0 + DefaultNotification int64 = 50 + + // CreatorPowerLevel is what the room's creator has while the room has + // no m.room.power_levels event, in room versions up to 11. + CreatorPowerLevel int64 = 100 +) + +// PowerLevels is a room's resolved power level configuration: the event's +// content with every default filled in, and the room's creators noted so +// that their level can be answered. +type PowerLevels struct { + Ban int64 + Kick int64 + Redact int64 + Invite int64 + EventsDefault int64 + StateDefault int64 + UsersDefault int64 + + Events map[string]int64 + Notifications map[string]int64 + Users map[string]int64 + + // creators have InfinitePowerLevel from room version 12. Before that + // the map is empty and the creator's 100 is written into Users + // instead, because it is a real level there that a later power levels + // event can change. + creators map[string]bool +} + +// ResolvePowerLevels works out the levels in force, given the room's +// m.room.power_levels event (which may be nil) and its create event. +// +// The create event is needed even when there is a power levels event: from +// room version 12 it is what says who the creators are, and their level +// cannot be written down. +func ResolvePowerLevels(plEvent, createEvent *event.Event, rules roomversion.Rules) (*PowerLevels, error) { + pl := &PowerLevels{ + Ban: DefaultBan, + Kick: DefaultKick, + Redact: DefaultRedact, + Invite: DefaultInvite, + EventsDefault: DefaultEventsDefault, + StateDefault: DefaultStateDefault, + UsersDefault: DefaultUsersDefault, + Events: map[string]int64{}, + Notifications: map[string]int64{}, + Users: map[string]int64{}, + creators: map[string]bool{}, + } + + if createEvent != nil { + creators, err := Creators(createEvent) + if err != nil { + return nil, err + } + if rules.CreatorsHaveInfinitePower { + for _, c := range creators { + pl.creators[c] = true + } + } else if plEvent == nil && len(creators) > 0 { + // With no power levels event, the creator has 100 and everyone + // else has nothing. + pl.Users[creators[0]] = CreatorPowerLevel + } + } + + if plEvent == nil { + return pl, nil + } + if err := pl.apply(plEvent.Content(), rules); err != nil { + return nil, err + } + return pl, nil +} + +// apply overlays the content of an m.room.power_levels event. +func (p *PowerLevels) apply(content json.RawMessage, rules roomversion.Rules) error { + var obj map[string]json.RawMessage + if err := json.Unmarshal(content, &obj); err != nil { + return reject("m.room.power_levels content is not an object: %v", err) + } + + scalars := map[string]*int64{ + "ban": &p.Ban, + "kick": &p.Kick, + "redact": &p.Redact, + "invite": &p.Invite, + "events_default": &p.EventsDefault, + "state_default": &p.StateDefault, + "users_default": &p.UsersDefault, + } + for name, target := range scalars { + raw, ok := obj[name] + if !ok { + continue + } + level, err := parseLevel(raw, rules) + if err != nil { + return reject("m.room.power_levels %s: %v", name, err) + } + *target = level + } + + for name, target := range map[string]*map[string]int64{ + "events": &p.Events, + "notifications": &p.Notifications, + "users": &p.Users, + } { + raw, ok := obj[name] + if !ok { + continue + } + levels, err := parseLevelMap(raw, rules) + if err != nil { + return reject("m.room.power_levels %s: %v", name, err) + } + if name == "users" { + for userID := range levels { + if err := id.ValidateUserID(userID); err != nil { + return reject("m.room.power_levels users: %v", err) + } + } + } + *target = levels + } + return nil +} + +// UserLevel returns a user's power level. +func (p *PowerLevels) UserLevel(userID string) int64 { + if p.creators[userID] { + return InfinitePowerLevel + } + if level, ok := p.Users[userID]; ok { + return level + } + return p.UsersDefault +} + +// IsCreator reports whether a user is one of the room's creators, in a room +// version where that gives them infinite power. +func (p *PowerLevels) IsCreator(userID string) bool { return p.creators[userID] } + +// SendLevel returns the level needed to send an event of a type. The events +// map overrides everything; otherwise a state event needs state_default and +// a message event events_default. +func (p *PowerLevels) SendLevel(eventType string, isState bool) int64 { + if level, ok := p.Events[eventType]; ok { + return level + } + if isState { + return p.StateDefault + } + return p.EventsDefault +} + +// NotificationLevel returns the level needed to trigger a notification, +// such as "room" for an @room mention. +func (p *PowerLevels) NotificationLevel(key string) int64 { + if level, ok := p.Notifications[key]; ok { + return level + } + return DefaultNotification +} + +// Creators returns the user IDs that count as creators of the room: the +// sender of the create event, plus content.additional_creators in room +// versions that allow it. +func Creators(createEvent *event.Event) ([]string, error) { + if createEvent.Type() != event.TypeCreate { + return nil, fmt.Errorf("%s is not an m.room.create event", createEvent.EventID()) + } + creators := []string{createEvent.Sender()} + if !createEvent.Rules().AdditionalCreators { + return creators, nil + } + + var content struct { + AdditionalCreators []string `json:"additional_creators"` + } + if err := json.Unmarshal(createEvent.Content(), &content); err != nil { + return nil, reject("m.room.create additional_creators is not an array of strings: %v", err) + } + for _, c := range content.AdditionalCreators { + if err := id.ValidateUserID(c); err != nil { + return nil, reject("m.room.create additional_creators: %v", err) + } + creators = append(creators, c) + } + return creators, nil +} + +// parseLevel reads a single power level. +// +// Room versions up to 9 accepted a level written as a string, so "50" and +// 50 both meant fifty — a laxness that let two servers that disagreed +// about how to parse a string disagree about who could speak. Version 10 +// requires an integer. +func parseLevel(raw json.RawMessage, rules roomversion.Rules) (int64, error) { + var level int64 + if err := json.Unmarshal(raw, &level); err == nil { + return level, nil + } + if rules.RequireIntegerPowerLevels { + return 0, fmt.Errorf("%s is not an integer", raw) + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return 0, fmt.Errorf("%s is neither an integer nor a string", raw) + } + level, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("%s is a string that is not an integer", raw) + } + return level, nil +} + +// parseLevelMap reads the events, notifications or users map. +func parseLevelMap(raw json.RawMessage, rules roomversion.Rules) (map[string]int64, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, fmt.Errorf("not an object: %v", err) + } + out := make(map[string]int64, len(obj)) + for k, v := range obj { + level, err := parseLevel(v, rules) + if err != nil { + return nil, fmt.Errorf("%q: %w", k, err) + } + out[k] = level + } + return out, nil +} diff --git a/server/internal/matrix/auth/state.go b/server/internal/matrix/auth/state.go new file mode 100644 index 0000000..1a848c9 --- /dev/null +++ b/server/internal/matrix/auth/state.go @@ -0,0 +1,95 @@ +package auth + +import ( + "github.com/dariuszjaniak/warp/server/internal/matrix/event" +) + +// StateKey identifies one piece of room state: an event type and a state +// key together. Room state is a map from this to an event. +type StateKey struct { + Type string + StateKey string +} + +// State is the room state an event is authorised against. +// +// It is only ever the handful of events the rules actually read — the +// create event, the power levels, the join rules, the members involved and +// any third-party invite — not the whole state of the room. Two callers +// build it differently: event intake builds it from the event's own +// auth_events, and state resolution builds it from the state it has +// resolved so far. +type State map[StateKey]*event.Event + +// NewState indexes a set of state events. +// +// A non-state event in the input is ignored rather than refused: the usual +// source is an event's auth_events, and a server that cited something odd +// there should fail the auth_events checks, not crash the indexer. +func NewState(events []*event.Event) State { + st := make(State, len(events)) + for _, e := range events { + if sk, ok := e.StateKey(); ok { + st[StateKey{Type: e.Type(), StateKey: sk}] = e + } + } + return st +} + +// Get returns one state event, or nil. +func (s State) Get(eventType, stateKey string) *event.Event { + return s[StateKey{Type: eventType, StateKey: stateKey}] +} + +// Create returns the m.room.create event, or nil if the state does not +// contain one — which for any event but the create event itself means the +// event cannot be authorised at all. +func (s State) Create() *event.Event { return s.Get(event.TypeCreate, "") } + +// PowerLevels returns the m.room.power_levels event, or nil. A room +// without one is not broken: until the creator sends one, the defaults +// apply and the creator alone has power. +func (s State) PowerLevels() *event.Event { return s.Get(event.TypePowerLevels, "") } + +// JoinRules returns the m.room.join_rules event, or nil. +func (s State) JoinRules() *event.Event { return s.Get(event.TypeJoinRules, "") } + +// Member returns a user's m.room.member event, or nil. +func (s State) Member(userID string) *event.Event { return s.Get(event.TypeMember, userID) } + +// ThirdPartyInvite returns the m.room.third_party_invite event for a token, +// or nil. +func (s State) ThirdPartyInvite(token string) *event.Event { + return s.Get(event.TypeThirdPartyInvite, token) +} + +// Membership returns a user's membership in the room. +// +// A user with no m.room.member event has never been in the room, which the +// rules treat exactly like having left: every rule that cares asks whether +// the membership *is* something, never whether it is absent. +func (s State) Membership(userID string) string { + e := s.Member(userID) + if e == nil { + return event.MembershipLeave + } + membership, err := e.Membership() + if err != nil || membership == "" { + return event.MembershipLeave + } + return membership +} + +// JoinRule returns the room's join rule, defaulting to "invite" as the +// spec does for a room with no m.room.join_rules event. +func (s State) JoinRule() string { + e := s.JoinRules() + if e == nil { + return JoinRuleInvite + } + rule, err := joinRuleOf(e) + if err != nil || rule == "" { + return JoinRuleInvite + } + return rule +} diff --git a/server/internal/matrix/canonicaljson/canonicaljson.go b/server/internal/matrix/canonicaljson/canonicaljson.go new file mode 100644 index 0000000..8c733ce --- /dev/null +++ b/server/internal/matrix/canonicaljson/canonicaljson.go @@ -0,0 +1,263 @@ +// Package canonicaljson implements the canonical JSON encoding Matrix uses +// wherever two servers have to agree on bytes rather than on values: event +// content hashes, event IDs, and every signature. +// +// The rules (spec appendices, "Canonical JSON") are short. Sort object keys +// by Unicode code point, emit no whitespace, allow only integers in +// [-(2^53)+1, 2^53-1], and escape as little as possible — non-ASCII text +// goes out as raw UTF-8 rather than \u escapes. +// +// encoding/json cannot produce this on its own: it escapes <, > and & so +// that output is safe to paste into HTML, which no other Matrix +// implementation does, and it reads and writes floats happily. So the +// encoder below is hand-written and encoding/json is used only to parse. +package canonicaljson + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +// The range Matrix allows for integers, which is the range a float64 holds +// without losing precision — the limit comes from JavaScript, like so much +// else about JSON. +const ( + MaxInt int64 = 1<<53 - 1 + MinInt int64 = -(1<<53 - 1) +) + +// ErrNotCanonical is the root of every error this package returns, so that +// a caller who only wants to answer M_BAD_JSON can test for it. +var ErrNotCanonical = errors.New("not canonical JSON") + +// Encode canonicalises a JSON value. +// +// Input need not already be canonical — keys may be in any order and +// strings may use any legal escaping — but it must be *encodable* as +// canonical JSON, so a float or an oversized integer is an error rather +// than something to round off. +func Encode(raw []byte) ([]byte, error) { + v, err := parse(raw) + if err != nil { + return nil, err + } + buf := bytes.NewBuffer(make([]byte, 0, len(raw))) + if err := write(buf, v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Marshal encodes v as JSON and canonicalises the result. +func Marshal(v any) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal JSON: %w", err) + } + return Encode(raw) +} + +// Valid reports whether raw can be encoded as canonical JSON, with an error +// that says what is wrong with it if it cannot. +func Valid(raw []byte) error { + _, err := Encode(raw) + return err +} + +// parse reads raw into the generic Go representation, keeping numbers as +// their source text so that write can decide what to do with them. +func parse(raw []byte) (any, error) { + if !utf8.Valid(raw) { + return nil, fmt.Errorf("%w: not valid UTF-8", ErrNotCanonical) + } + if err := checkEscapes(raw); err != nil { + return nil, err + } + + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("%w: %w", ErrNotCanonical, err) + } + // A signature covers one value. Anything after it is either a second + // document or junk, and either way the bytes we would hash are not the + // bytes we were handed. + if dec.More() { + return nil, fmt.Errorf("%w: trailing data after the JSON value", ErrNotCanonical) + } + return v, nil +} + +func write(buf *bytes.Buffer, v any) error { + switch v := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case json.Number: + return writeNumber(buf, v) + case string: + writeString(buf, v) + case []any: + buf.WriteByte('[') + for i, elem := range v { + if i > 0 { + buf.WriteByte(',') + } + if err := write(buf, elem); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + // Go compares strings byte by byte, and UTF-8 is ordered so that a + // byte-wise comparison is a code point comparison. Sorting the keys + // as Go strings is therefore exactly what the spec asks for. + slices.Sort(keys) + + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + writeString(buf, k) + buf.WriteByte(':') + if err := write(buf, v[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("%w: cannot encode a value of type %T", ErrNotCanonical, v) + } + return nil +} + +// writeNumber re-emits a number from its source text. +// +// Matrix has no floats, so anything with a decimal point or an exponent is +// rejected rather than rounded: a server that quietly turned 1.0 into 1 +// would compute a hash nobody else agrees with. The value is reformatted +// from the parsed integer, which also normalises the one spelling the +// grammar forbids but parsers accept, "-0". +func writeNumber(buf *bytes.Buffer, n json.Number) error { + s := string(n) + if strings.ContainsAny(s, ".eE") { + return fmt.Errorf("%w: %s is not an integer; Matrix JSON has no floats", ErrNotCanonical, s) + } + i, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("%w: %s is not an integer we can represent", ErrNotCanonical, s) + } + if i > MaxInt || i < MinInt { + return fmt.Errorf("%w: %s is outside the allowed range [%d, %d]", ErrNotCanonical, s, MinInt, MaxInt) + } + buf.WriteString(strconv.FormatInt(i, 10)) + return nil +} + +const hexDigits = "0123456789abcdef" + +// writeString writes a JSON string literal with the minimum of escaping: +// the two characters that must always be escaped, the five control +// characters that have a short form, and \u00XX for the remaining controls. +// Everything else, non-ASCII included, is copied through as UTF-8. +func writeString(buf *bytes.Buffer, s string) { + buf.WriteByte('"') + for i := 0; i < len(s); i++ { + switch c := s[i]; c { + case '"': + buf.WriteString(`\"`) + case '\\': + buf.WriteString(`\\`) + case '\b': + buf.WriteString(`\b`) + case '\f': + buf.WriteString(`\f`) + case '\n': + buf.WriteString(`\n`) + case '\r': + buf.WriteString(`\r`) + case '\t': + buf.WriteString(`\t`) + default: + if c < 0x20 { + buf.WriteString(`\u00`) + buf.WriteByte(hexDigits[c>>4]) + buf.WriteByte(hexDigits[c&0xf]) + continue + } + buf.WriteByte(c) + } + } + buf.WriteByte('"') +} + +// checkEscapes rejects \u escapes that do not form a character. +// +// encoding/json turns a lone surrogate into U+FFFD instead of failing, +// which would quietly change the bytes we are about to hash — Python's +// json, which the spec uses as its reference encoder, raises instead. +// Scanning the source for the escape is the only way to tell the two cases +// apart once the parser has run. +// +// Walking from the first byte is safe because a backslash can only appear +// inside a string, so every one found here opens an escape sequence. +func checkEscapes(raw []byte) error { + for i := 0; i < len(raw); i++ { + if raw[i] != '\\' { + continue + } + if i+1 >= len(raw) { + break // a truncated escape; the parser reports it better than we can + } + if raw[i+1] != 'u' { + i++ // an escape of one character, including a backslash itself + continue + } + r, ok := hexEscape(raw, i) + if !ok { + break + } + switch { + case r >= 0xD800 && r <= 0xDBFF: + low, ok := hexEscape(raw, i+6) + if !ok || low < 0xDC00 || low > 0xDFFF { + return fmt.Errorf("%w: \\u%04X is a high surrogate with no low surrogate after it", ErrNotCanonical, r) + } + i += 11 + case r >= 0xDC00 && r <= 0xDFFF: + return fmt.Errorf("%w: \\u%04X is a low surrogate with no high surrogate before it", ErrNotCanonical, r) + default: + i += 5 + } + } + return nil +} + +// hexEscape reads the \uXXXX escape starting at i, if that is what is there. +func hexEscape(raw []byte, i int) (rune, bool) { + if i+6 > len(raw) || raw[i] != '\\' || raw[i+1] != 'u' { + return 0, false + } + v, err := strconv.ParseUint(string(raw[i+2:i+6]), 16, 32) + if err != nil { + return 0, false + } + return rune(v), true +} diff --git a/server/internal/matrix/canonicaljson/canonicaljson_test.go b/server/internal/matrix/canonicaljson/canonicaljson_test.go new file mode 100644 index 0000000..2d7bdd4 --- /dev/null +++ b/server/internal/matrix/canonicaljson/canonicaljson_test.go @@ -0,0 +1,292 @@ +package canonicaljson + +import ( + "errors" + "strings" + "testing" +) + +// TestSpecExamples runs the worked examples from the spec appendices, +// section "Canonical JSON". They are the one set of values every +// implementation is expected to agree on. +func TestSpecExamples(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty object", `{}`, `{}`}, + {"values", "{\n \"one\": 1,\n \"two\": \"Two\"\n}", `{"one":1,"two":"Two"}`}, + {"sorts keys", "{\n \"b\": \"2\",\n \"a\": \"1\"\n}", `{"a":"1","b":"2"}`}, + {"sorts keys, no whitespace to remove", `{"b":"2","a":"1"}`, `{"a":"1","b":"2"}`}, + { + "nested", + `{ + "auth": { + "success": true, + "mxid": "@john.doe:example.com", + "profile": { + "display_name": "John Doe", + "three_pids": [ + {"medium": "email", "address": "john.doe@example.org"}, + {"medium": "msisdn", "address": "123456789"} + ] + } + } + }`, + `{"auth":{"mxid":"@john.doe:example.com","profile":{"display_name":"John Doe",` + + `"three_pids":[{"address":"john.doe@example.org","medium":"email"},` + + `{"address":"123456789","medium":"msisdn"}]},"success":true}}`, + }, + {"non-ASCII stays UTF-8", `{"a": "日本語"}`, `{"a":"日本語"}`}, + {"non-ASCII keys sort by code point", `{"本":2,"日":1}`, `{"日":1,"本":2}`}, + {"escapes are decoded", `{"a": "日"}`, `{"a":"日"}`}, + {"null", `{"a": null}`, `{"a":null}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Encode([]byte(tc.in)) + if err != nil { + t.Fatalf("Encode: %v", err) + } + if string(got) != tc.want { + t.Errorf("Encode() = %s, want %s", got, tc.want) + } + }) + } +} + +// TestEncodeIsIdempotent checks that canonicalising canonical JSON changes +// nothing, which is what lets a stored event be re-signed and re-hashed +// without drifting. +func TestEncodeIsIdempotent(t *testing.T) { + once, err := Encode([]byte(`{"b":[1,2,{"d":"日","c":null}],"a":true}`)) + if err != nil { + t.Fatal(err) + } + twice, err := Encode(once) + if err != nil { + t.Fatal(err) + } + if string(once) != string(twice) { + t.Errorf("second pass changed the output:\n %s\n %s", once, twice) + } +} + +// TestNoHTMLEscaping is the reason this package exists rather than calling +// encoding/json: the standard library escapes these three characters, and +// an event containing one would hash differently on every other server. +func TestNoHTMLEscaping(t *testing.T) { + got, err := Encode([]byte(`{"body":" & "}`)) + if err != nil { + t.Fatal(err) + } + if want := `{"body":" & "}`; string(got) != want { + t.Errorf("Encode() = %s, want %s", got, want) + } +} + +// TestStringEscaping checks the escapes the grammar allows: the short forms +// where they exist and \u00XX for the other control characters, with +// everything else passed through. +func TestStringEscaping(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"quote and backslash", `{"a":"\" \\"}`, `{"a":"\" \\"}`}, + {"short forms", `{"a":"\b\t\n\f\r"}`, `{"a":"\b\t\n\f\r"}`}, + {"other controls", `{"a":"\u0000\u001f\u000b\u000e"}`, `{"a":"\u0000\u001f\u000b\u000e"}`}, + {"literal newline is not legal JSON input", "{\"a\":\"\t\"}", ""}, + {"solidus is not escaped", `{"a":"/"}`, `{"a":"/"}`}, + {"surrogate pair", `{"a":"😀"}`, `{"a":"😀"}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Encode([]byte(tc.in)) + if tc.want == "" { + if err == nil { + t.Fatalf("Encode(%s) = %s, want an error", tc.in, got) + } + return + } + if err != nil { + t.Fatalf("Encode: %v", err) + } + if string(got) != tc.want { + t.Errorf("Encode() = %s, want %s", got, tc.want) + } + }) + } +} + +func TestNumbers(t *testing.T) { + cases := []struct { + name string + in string + want string + wantErr string + }{ + {name: "zero", in: `{"a":0}`, want: `{"a":0}`}, + {name: "negative", in: `{"a":-12}`, want: `{"a":-12}`}, + {name: "largest allowed", in: `{"a":9007199254740991}`, want: `{"a":9007199254740991}`}, + {name: "smallest allowed", in: `{"a":-9007199254740991}`, want: `{"a":-9007199254740991}`}, + + // The grammar forbids -0 in the output, so it is normalised rather + // than rejected: every JSON parser reads it as zero anyway. + {name: "negative zero is normalised", in: `{"a":-0}`, want: `{"a":0}`}, + + {name: "float", in: `{"a":1.0}`, wantErr: "has no floats"}, + {name: "fraction", in: `{"a":0.5}`, wantErr: "has no floats"}, + {name: "exponent", in: `{"a":1e3}`, wantErr: "has no floats"}, + {name: "above the range", in: `{"a":9007199254740992}`, wantErr: "outside the allowed range"}, + {name: "below the range", in: `{"a":-9007199254740992}`, wantErr: "outside the allowed range"}, + {name: "far above the range", in: `{"a":18446744073709551616}`, wantErr: "not an integer we can represent"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Encode([]byte(tc.in)) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("Encode(%s) = %s, want an error", tc.in, got) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error = %q, want it to mention %q", err, tc.wantErr) + } + if !errors.Is(err, ErrNotCanonical) { + t.Errorf("error does not wrap ErrNotCanonical: %v", err) + } + return + } + if err != nil { + t.Fatalf("Encode: %v", err) + } + if string(got) != tc.want { + t.Errorf("Encode() = %s, want %s", got, tc.want) + } + }) + } +} + +func TestRejects(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"invalid UTF-8", "{\"a\":\"\xff\xfe\"}", "not valid UTF-8"}, + {"lone high surrogate", `{"a":"\uD800"}`, "high surrogate"}, + {"lone low surrogate", `{"a":"\uDC00"}`, "low surrogate"}, + {"high surrogate then a plain character", `{"a":"\uD800x"}`, "high surrogate"}, + {"trailing data", `{"a":1} {"b":2}`, "trailing data"}, + {"not JSON at all", `nope`, "not canonical JSON"}, + {"empty input", ``, "not canonical JSON"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Encode([]byte(tc.in)) + if err == nil { + t.Fatalf("Encode(%q) = %s, want an error", tc.in, got) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + if !errors.Is(err, ErrNotCanonical) { + t.Errorf("error does not wrap ErrNotCanonical: %v", err) + } + }) + } +} + +// TestEscapedBackslashIsNotAnEscape guards the backslash counting in +// checkEscapes: the sequence below is a literal backslash followed by the +// letters uD800, not a lone surrogate. +func TestEscapedBackslashIsNotAnEscape(t *testing.T) { + got, err := Encode([]byte(`{"a":"\\uD800"}`)) + if err != nil { + t.Fatalf("Encode: %v", err) + } + if want := `{"a":"\\uD800"}`; string(got) != want { + t.Errorf("Encode() = %s, want %s", got, want) + } +} + +// TestNonObjectValues checks that the encoder handles every JSON type at +// the top level, not just objects. Signing only ever sees objects, but +// hashing a bare array should not be a panic. +func TestNonObjectValues(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {`[1, 2, 3]`, `[1,2,3]`}, + {`[]`, `[]`}, + {` "x" `, `"x"`}, + {`true`, `true`}, + {`null`, `null`}, + {`7`, `7`}, + } { + got, err := Encode([]byte(tc.in)) + if err != nil { + t.Fatalf("Encode(%s): %v", tc.in, err) + } + if string(got) != tc.want { + t.Errorf("Encode(%s) = %s, want %s", tc.in, got, tc.want) + } + } +} + +func TestMarshal(t *testing.T) { + // A Go value with its fields in the wrong order, and a string the + // standard library would HTML-escape. + v := struct { + Two string `json:"two"` + One int `json:"one"` + Body string `json:"body"` + }{Two: "Two", One: 1, Body: "a & b"} + + got, err := Marshal(v) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if want := `{"body":"a & b","one":1,"two":"Two"}`; string(got) != want { + t.Errorf("Marshal() = %s, want %s", got, want) + } +} + +func TestValid(t *testing.T) { + if err := Valid([]byte(`{"a":1}`)); err != nil { + t.Errorf("Valid: %v", err) + } + if err := Valid([]byte(`{"a":1.5}`)); err == nil { + t.Error("Valid accepted a float") + } +} + +// FuzzEncode checks the two properties that matter for anything arriving +// over the network: the encoder never panics, and whatever it accepts it +// accepts again unchanged. The second half is what guarantees two servers +// agree on the bytes they hash. +func FuzzEncode(f *testing.F) { + for _, seed := range []string{ + `{}`, `{"a":1}`, `{"a":"日本語"}`, `[1,2,3]`, `{"a":{"b":[null,true]}}`, + `{"a":"\u0000"}`, `{"a":-0}`, `{"a":"😀"}`, `0`, `""`, + } { + f.Add([]byte(seed)) + } + f.Fuzz(func(t *testing.T, in []byte) { + once, err := Encode(in) + if err != nil { + return + } + twice, err := Encode(once) + if err != nil { + t.Fatalf("re-encoding canonical JSON failed: %v\ninput: %q\ncanonical: %s", err, in, once) + } + if string(once) != string(twice) { + t.Fatalf("not idempotent:\ninput: %q\nonce: %s\ntwice: %s", in, once, twice) + } + }) +} diff --git a/server/internal/matrix/event/builder.go b/server/internal/matrix/event/builder.go new file mode 100644 index 0000000..d7406fb --- /dev/null +++ b/server/internal/matrix/event/builder.go @@ -0,0 +1,203 @@ +package event + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/id" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// Builder assembles a new event for this server to send. +// +// The caller fills in what the event says; Build fills in what the room +// version says — where the redaction target goes, whether the create event +// carries a room ID — then hashes, signs and validates the result. The +// roomserver (PLAN.md phase 3) is the only thing that should build events, +// because it is the only thing that knows the right prev_events, depth and +// auth_events. +type Builder struct { + // RoomID is the room the event belongs to. It must be empty for an + // m.room.create event in a room version where the room ID is derived + // from that event; use RoomIDOfCreateEvent on the built event to find + // out what the room ID turned out to be. + RoomID string + + // Type is the event type, such as "m.room.message". + Type string + + // StateKey makes this a state event when it is non-nil. The empty + // string is a perfectly good state key, which is why this is a pointer. + StateKey *string + + // Sender is the user ID sending the event. Its server name is the one + // the event is signed as, since an event must be signed by its + // sender's server. + Sender string + + // Content is the event content. An empty Content becomes {}. + Content json.RawMessage + + // PrevEvents and AuthEvents are the event IDs this event refers to. + PrevEvents []string + AuthEvents []string + + // Depth is one more than the greatest depth of the prev_events. + Depth int64 + + // Redacts is the event an m.room.redaction event targets. Build puts + // it wherever this room version keeps it, so callers do not have to + // know which versions moved it into the content. + Redacts string + + // Unsigned is attached to the event but covered by neither its hashes + // nor its signatures, so anything here can be changed in transit by + // any server that passes the event on. + Unsigned json.RawMessage +} + +// Build hashes, signs and validates the event. +// +// now becomes origin_server_ts. It is passed in rather than read from the +// clock so that tests are deterministic and so that a caller sending a +// batch can stamp them all alike. +func (b *Builder) Build(now time.Time, key *signing.KeyPair, rules roomversion.Rules) (*Event, error) { + serverName, err := id.ServerNameOf(b.Sender) + if err != nil { + return nil, fmt.Errorf("build event: sender: %w", err) + } + if b.Type == "" { + return nil, fmt.Errorf("build event: type is required") + } + if b.Depth < 0 { + return nil, fmt.Errorf("build event: depth %d is negative", b.Depth) + } + + obj := map[string]json.RawMessage{} + set := func(k string, v any) error { + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("build event: %s: %w", k, err) + } + obj[k] = raw + return nil + } + + content := b.Content + if len(content) == 0 { + content = json.RawMessage("{}") + } + if b.Redacts != "" { + if content, err = b.placeRedacts(content, obj, rules); err != nil { + return nil, err + } + } + + if err := b.setRoomID(obj, rules); err != nil { + return nil, err + } + for k, v := range map[string]any{ + "type": b.Type, + "sender": b.Sender, + "depth": b.Depth, + "origin_server_ts": now.UnixMilli(), + // prev_events and auth_events are required, and must be [] rather + // than absent when there are none — which is the case for the + // create event and for nothing else. + "prev_events": orEmpty(b.PrevEvents), + "auth_events": orEmpty(b.AuthEvents), + } { + if err := set(k, v); err != nil { + return nil, err + } + } + obj["content"] = content + if b.StateKey != nil { + if err := set("state_key", *b.StateKey); err != nil { + return nil, err + } + } + if len(b.Unsigned) > 0 { + obj["unsigned"] = b.Unsigned + } + + raw, err := canonicaljson.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("build event: %w", err) + } + signed, err := hashAndSign(raw, rules, serverName, key) + if err != nil { + return nil, fmt.Errorf("build event: %w", err) + } + // Parse is the one gate every event goes through, built here or + // received over federation, so a bug in the builder surfaces as a + // failure to build rather than as an invalid event in a room. + return Parse(signed, rules) +} + +// setRoomID writes the room_id property, or leaves it out for a create +// event in a room version that derives the room ID from it. +func (b *Builder) setRoomID(obj map[string]json.RawMessage, rules roomversion.Rules) error { + if rules.RoomIDIsCreateEventID && b.Type == TypeCreate { + if b.RoomID != "" { + return fmt.Errorf("build event: an m.room.create event in a version %s room must not have a room_id; it becomes the room ID", + rules.Version) + } + return nil + } + if err := id.ValidateRoomID(b.RoomID); err != nil { + return fmt.Errorf("build event: room_id: %w", err) + } + raw, err := json.Marshal(b.RoomID) + if err != nil { + return fmt.Errorf("build event: room_id: %w", err) + } + obj["room_id"] = raw + return nil +} + +// placeRedacts puts Redacts where this room version keeps it: in the +// content from room version 11, at the top level before that. +func (b *Builder) placeRedacts(content json.RawMessage, obj map[string]json.RawMessage, rules roomversion.Rules) (json.RawMessage, error) { + if err := id.ValidateEventID(b.Redacts); err != nil { + return nil, fmt.Errorf("build event: redacts: %w", err) + } + if !rules.RedactsInContent { + raw, err := json.Marshal(b.Redacts) + if err != nil { + return nil, fmt.Errorf("build event: redacts: %w", err) + } + obj["redacts"] = raw + return content, nil + } + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(content, &parsed); err != nil { + return nil, fmt.Errorf("build event: content: %w", err) + } + if parsed == nil { + parsed = map[string]json.RawMessage{} + } + raw, err := json.Marshal(b.Redacts) + if err != nil { + return nil, fmt.Errorf("build event: redacts: %w", err) + } + parsed["redacts"] = raw + out, err := json.Marshal(parsed) + if err != nil { + return nil, fmt.Errorf("build event: content: %w", err) + } + return out, nil +} + +// orEmpty turns a nil slice into an empty one, so that it encodes as [] +// rather than null. +func orEmpty(s []string) []string { + if s == nil { + return []string{} + } + return s +} diff --git a/server/internal/matrix/event/event.go b/server/internal/matrix/event/event.go new file mode 100644 index 0000000..46eef71 --- /dev/null +++ b/server/internal/matrix/event/event.go @@ -0,0 +1,250 @@ +// Package event is the Matrix event (PDU) core: parsing and validating +// events, computing their hashes and identifiers, signing and verifying +// them, and applying the redaction algorithm. +// +// It is pure library code. Nothing here reaches a database or the network, +// and nothing here decides whether an event is *allowed* — that is the +// auth package. The division matters: these are the operations whose +// results every server in a room must agree on byte for byte, so they are +// kept small, deterministic and heavily tested. +// +// An Event is immutable once parsed. The canonical JSON, the event ID and +// the decoded fields are worked out up front and then only read, which is +// what lets an event be passed around and cached without copying. +// +// # A known gap +// +// Room versions 1 to 5 are allowed to contain integers outside the range +// canonical JSON permits, and the spec asks servers to cope where they +// can. Warp does not: such an event is rejected whatever its room version. +// It only arises for events that were already outside the current grammar +// when they were created, and handling them means a second, lenient +// encoder — which can wait until federation (PLAN.md phase 5) makes old +// rooms reachable at all. +package event + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/id" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// The event types that carry meaning for the event core itself: the ones +// the authorisation rules turn on, plus the two the redaction algorithm +// treats specially. +const ( + TypeCreate = "m.room.create" + TypeMember = "m.room.member" + TypeJoinRules = "m.room.join_rules" + TypePowerLevels = "m.room.power_levels" + TypeThirdPartyInvite = "m.room.third_party_invite" + TypeHistoryVisibility = "m.room.history_visibility" + TypeRedaction = "m.room.redaction" + TypeAliases = "m.room.aliases" +) + +// The membership values an m.room.member event may carry. +const ( + MembershipJoin = "join" + MembershipLeave = "leave" + MembershipInvite = "invite" + MembershipBan = "ban" + MembershipKnock = "knock" +) + +// ErrInvalidEvent is the root of every parsing and validation error here, +// so a caller that only needs to answer M_BAD_JSON can test for it. +var ErrInvalidEvent = errors.New("invalid event") + +// Event is a parsed, structurally valid PDU. +// +// "Structurally valid" is a lower bar than "allowed in the room": an Event +// has the fields the spec requires, of the right types and within the size +// limits, and its ID has been computed. Whether it may actually be added to +// the room is a question for the auth package. +type Event struct { + rules roomversion.Rules + raw []byte + id string + fields fields +} + +// fields are the event properties the server itself reads. Anything else +// stays in raw, untouched, because a server must pass through properties it +// does not understand rather than dropping them. +type fields struct { + RoomID string `json:"room_id"` + Type string `json:"type"` + StateKey *string `json:"state_key"` + Sender string `json:"sender"` + Content json.RawMessage `json:"content"` + PrevEvents []string `json:"prev_events"` + AuthEvents []string `json:"auth_events"` + Depth int64 `json:"depth"` + OriginServerTS int64 `json:"origin_server_ts"` + Hashes hashes `json:"hashes"` + Signatures signing.Signatures `json:"signatures"` + Unsigned json.RawMessage `json:"unsigned"` + + // Redacts is the top-level property m.room.redaction used up to room + // version 10. From version 11 the target is content.redacts instead, + // where the redaction algorithm and therefore the signature can cover + // it; Redacts is populated from whichever place this room version uses. + Redacts string `json:"redacts"` +} + +type hashes struct { + SHA256 string `json:"sha256"` +} + +// Parse validates an event and works out its ID. +// +// raw is canonicalised first, so an event may arrive with its keys in any +// order — what it may not do is use a float, an out-of-range integer or +// invalid UTF-8, because then there are no canonical bytes to hash and no +// two servers would agree on its ID. +func Parse(raw []byte, rules roomversion.Rules) (*Event, error) { + canonical, err := canonicaljson.Encode(raw) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err) + } + + e := &Event{rules: rules, raw: canonical} + if err := json.Unmarshal(canonical, &e.fields); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err) + } + if err := e.validate(); err != nil { + return nil, err + } + + // From room version 11 the redaction target lives in the content. Read + // it from there so that the rest of the server can ask one question. + if rules.RedactsInContent && e.fields.Type == TypeRedaction { + var content struct { + Redacts string `json:"redacts"` + } + if err := json.Unmarshal(e.fields.Content, &content); err != nil { + return nil, fmt.Errorf("%w: m.room.redaction content: %w", ErrInvalidEvent, err) + } + e.fields.Redacts = content.Redacts + } + + if e.id, err = computeEventID(canonical, rules); err != nil { + return nil, err + } + return e, nil +} + +// Accessors. They return the decoded fields; the JSON-valued ones hand back +// the event's own storage, which callers must treat as read-only. + +// EventID returns the event's identifier, including the '$' sigil. +func (e *Event) EventID() string { return e.id } + +// RoomID returns the room this event belongs to. It is empty for an +// m.room.create event in a room version where the room ID *is* the create +// event's ID; use RoomIDOfCreateEvent for that case. +func (e *Event) RoomID() string { return e.fields.RoomID } + +// Type returns the event type, such as "m.room.message". +func (e *Event) Type() string { return e.fields.Type } + +// Sender returns the user ID that sent the event. +func (e *Event) Sender() string { return e.fields.Sender } + +// StateKey returns the state key and whether the event has one. An event +// with a state key is a state event, even if the key is the empty string. +func (e *Event) StateKey() (string, bool) { + if e.fields.StateKey == nil { + return "", false + } + return *e.fields.StateKey, true +} + +// IsState reports whether the event is a state event. +func (e *Event) IsState() bool { return e.fields.StateKey != nil } + +// Content returns the event content as JSON. +func (e *Event) Content() json.RawMessage { return e.fields.Content } + +// PrevEventIDs returns the event IDs of the events this one follows. +func (e *Event) PrevEventIDs() []string { return e.fields.PrevEvents } + +// AuthEventIDs returns the event IDs of the events that authorise this one. +func (e *Event) AuthEventIDs() []string { return e.fields.AuthEvents } + +// Depth returns the event's depth in the room DAG. +func (e *Event) Depth() int64 { return e.fields.Depth } + +// OriginServerTS returns the origin server's timestamp, in milliseconds +// since the epoch. It is a claim by that server, not a fact: it is used for +// ordering and display, never for authorisation. +func (e *Event) OriginServerTS() int64 { return e.fields.OriginServerTS } + +// Unsigned returns the unsigned property, which is not covered by the +// event's hashes or signatures and may differ between servers. +func (e *Event) Unsigned() json.RawMessage { return e.fields.Unsigned } + +// Signatures returns the event's signature block. +func (e *Event) Signatures() signing.Signatures { return e.fields.Signatures } + +// Redacts returns the event ID an m.room.redaction event targets, from +// wherever this room version keeps it. It is empty on any other event. +func (e *Event) Redacts() string { return e.fields.Redacts } + +// Rules returns the room version rules the event was parsed under. +func (e *Event) Rules() roomversion.Rules { return e.rules } + +// Version returns the event's room version. +func (e *Event) Version() roomversion.Version { return e.rules.Version } + +// JSON returns the event as canonical JSON. The slice is the event's own +// and must not be modified. +func (e *Event) JSON() []byte { return e.raw } + +// ContentHash returns the content hash carried in the event, as the +// unpadded base64 string it is stored as. +func (e *Event) ContentHash() string { return e.fields.Hashes.SHA256 } + +// Membership returns the membership from an m.room.member event's content. +func (e *Event) Membership() (string, error) { + if e.fields.Type != TypeMember { + return "", fmt.Errorf("%s is not an m.room.member event", e.id) + } + var content struct { + Membership string `json:"membership"` + } + if err := json.Unmarshal(e.fields.Content, &content); err != nil { + return "", fmt.Errorf("%w: m.room.member content: %w", ErrInvalidEvent, err) + } + return content.Membership, nil +} + +// SenderServer returns the server name of the event's sender, which is the +// server that must have signed it. +func (e *Event) SenderServer() (string, error) { + return id.ServerNameOf(e.fields.Sender) +} + +// RoomIDOfCreateEvent returns the room ID an m.room.create event creates, +// in the room versions where the room ID is the create event's own ID with +// a '!' in place of the '$'. +// +// Deriving the room ID this way is what removed the domain from room IDs: +// a room is no longer named after the server that happened to create it. +func RoomIDOfCreateEvent(e *Event) (string, error) { + if e.fields.Type != TypeCreate { + return "", fmt.Errorf("%s is not an m.room.create event", e.id) + } + if !e.rules.RoomIDIsCreateEventID { + // In these versions the create event carries the room ID like any + // other event, so there is nothing to derive. + return e.fields.RoomID, nil + } + return "!" + e.id[1:], nil +} diff --git a/server/internal/matrix/event/event_test.go b/server/internal/matrix/event/event_test.go new file mode 100644 index 0000000..aa80e26 --- /dev/null +++ b/server/internal/matrix/event/event_test.go @@ -0,0 +1,945 @@ +package event + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// The signing key from the spec appendices, section "Cryptographic Test +// Vectors". +const specSeed = "YJDBA9Xnr2sVqXD9Vj7XVUnmFZcZrlw8Md7kMW+3XA1" + +func specKey(t *testing.T) *signing.KeyPair { + t.Helper() + seed, err := base64.RawStdEncoding.DecodeString(specSeed) + if err != nil { + t.Fatalf("decode the spec seed: %v", err) + } + priv := ed25519.NewKeyFromSeed(seed) + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + t.Fatal("unexpected public key type") + } + return &signing.KeyPair{ID: "1", Private: priv, Public: pub} +} + +func testKey(t *testing.T) *signing.KeyPair { + t.Helper() + key, err := signing.Generate("test") + if err != nil { + t.Fatalf("generate a signing key: %v", err) + } + return key +} + +// TestSpecEventSigningVectors runs the event signing vectors from the spec +// appendices. They were produced for the original event format, so they go +// through a room version whose redaction rules still protect the top-level +// origin and event_id properties — the point of the test is the hashing +// and signing, which has not changed since. +func TestSpecEventSigningVectors(t *testing.T) { + key := specKey(t) + rules := roomversion.MustGet(roomversion.V3) + + cases := []struct { + name string + in string + want string + }{ + { + name: "minimal event", + in: `{ + "room_id": "!x:domain", + "sender": "@a:domain", + "origin": "domain", + "origin_server_ts": 1000000, + "signatures": {}, + "hashes": {}, + "type": "X", + "content": {}, + "prev_events": [], + "auth_events": [], + "depth": 3, + "unsigned": {"age_ts": 1000000} + }`, + want: `{"auth_events":[],"content":{},"depth":3,` + + `"hashes":{"sha256":"5jM4wQpv6lnBo7CLIghJuHdW+s2CMBJPUOGOC89ncos"},` + + `"origin":"domain","origin_server_ts":1000000,"prev_events":[],` + + `"room_id":"!x:domain","sender":"@a:domain","signatures":{"domain":{"ed25519:1":` + + `"KxwGjPSDEtvnFgU00fwFz+l6d2pJM6XBIaMEn81SXPTRl16AqLAYqfIReFGZlHi5KLjAWbOoMszkwsQma+lYAg"}},` + + `"type":"X","unsigned":{"age_ts":1000000}}`, + }, + { + name: "event with redactable content", + in: `{ + "content": {"body": "Here is the message content"}, + "event_id": "$0:domain", + "origin": "domain", + "origin_server_ts": 1000000, + "type": "m.room.message", + "room_id": "!r:domain", + "sender": "@u:domain", + "signatures": {}, + "unsigned": {"age_ts": 1000000} + }`, + want: `{"content":{"body":"Here is the message content"},"event_id":"$0:domain",` + + `"hashes":{"sha256":"onLKD1bGljeBWQhWZ1kaP9SorVmRQNdN5aM2JYU2n/g"},` + + `"origin":"domain","origin_server_ts":1000000,"room_id":"!r:domain",` + + `"sender":"@u:domain","signatures":{"domain":{"ed25519:1":` + + `"Wm+VzmOUOz08Ds+0NTWb1d4CZrVsJSikkeRxh6aCcUwu6pNC78FunoD7KNWzqFn241eYHYMGCA5McEiVPdhzBA"}},` + + `"type":"m.room.message","unsigned":{"age_ts":1000000}}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := hashAndSign([]byte(tc.in), rules, "domain", key) + if err != nil { + t.Fatalf("hashAndSign: %v", err) + } + if string(got) != tc.want { + t.Errorf("hashAndSign() =\n %s\nwant\n %s", got, tc.want) + } + }) + } +} + +// buildEvent is the shorthand the tests below use for a signed event in a +// given room version. +func buildEvent(t *testing.T, rules roomversion.Rules, key *signing.KeyPair, b Builder) *Event { + t.Helper() + if b.Sender == "" { + b.Sender = "@alice:warp.test" + } + // A version 12 create event has no room ID: the room ID comes from it. + derivesRoomID := rules.RoomIDIsCreateEventID && b.Type == TypeCreate + if b.RoomID == "" && !derivesRoomID { + b.RoomID = "!room:warp.test" + } + e, err := b.Build(time.UnixMilli(1700000000000), key, rules) + if err != nil { + t.Fatalf("Build %s: %v", b.Type, err) + } + return e +} + +func TestBuildAndParse(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + + e := buildEvent(t, rules, key, Builder{ + Type: "m.room.message", + Content: json.RawMessage(`{"body":"hello","msgtype":"m.text"}`), + PrevEvents: []string{"$abc"}, + AuthEvents: []string{"$create"}, + Depth: 7, + }) + + if !strings.HasPrefix(e.EventID(), "$") { + t.Errorf("event ID %q does not start with $", e.EventID()) + } + if e.Type() != "m.room.message" { + t.Errorf("Type() = %q", e.Type()) + } + if e.Sender() != "@alice:warp.test" { + t.Errorf("Sender() = %q", e.Sender()) + } + if e.RoomID() != "!room:warp.test" { + t.Errorf("RoomID() = %q", e.RoomID()) + } + if e.Depth() != 7 { + t.Errorf("Depth() = %d", e.Depth()) + } + if e.OriginServerTS() != 1700000000000 { + t.Errorf("OriginServerTS() = %d", e.OriginServerTS()) + } + if _, ok := e.StateKey(); ok { + t.Error("a message event should have no state key") + } + if e.IsState() { + t.Error("IsState() = true for a message event") + } + if err := e.VerifyContentHash(); err != nil { + t.Errorf("VerifyContentHash: %v", err) + } + if err := e.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil { + t.Errorf("VerifySignature: %v", err) + } + + // Re-parsing the event's own JSON must produce the same ID, which is + // what makes it safe to store events as JSON and parse them back. + again, err := Parse(e.JSON(), rules) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + if again.EventID() != e.EventID() { + t.Errorf("re-parsed event ID = %q, want %q", again.EventID(), e.EventID()) + } +} + +func TestBuildStateEventWithEmptyStateKey(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + empty := "" + + e := buildEvent(t, rules, key, Builder{ + Type: TypePowerLevels, + StateKey: &empty, + Content: json.RawMessage(`{"users":{"@alice:warp.test":100}}`), + }) + sk, ok := e.StateKey() + if !ok { + t.Fatal("StateKey() reports no state key") + } + if sk != "" { + t.Errorf("StateKey() = %q, want the empty string", sk) + } + if !e.IsState() { + t.Error("IsState() = false for a power levels event") + } +} + +// TestEventIDFormatByVersion checks the two encodings of the reference +// hash. Room version 3 uses standard base64 and 4 onwards URL-safe, which +// is why the same event has two different IDs in the two rooms. +func TestEventIDFormatByVersion(t *testing.T) { + key := specKey(t) + body := Builder{ + Type: "m.room.message", + Sender: "@a:domain", + RoomID: "!r:domain", + Content: json.RawMessage(`{"body":"ÿþýü"}`), + } + + ids := map[roomversion.Version]string{} + for _, v := range []roomversion.Version{roomversion.V3, roomversion.V4} { + e, err := body.Build(time.UnixMilli(1000000), key, roomversion.MustGet(v)) + if err != nil { + t.Fatalf("Build for version %s: %v", v, err) + } + ids[v] = e.EventID() + } + + // Both encode the same 32-byte hash, so the IDs are the same length + // and differ only where the alphabets differ. + if len(ids[roomversion.V3]) != len(ids[roomversion.V4]) { + t.Errorf("IDs have different lengths: %q and %q", ids[roomversion.V3], ids[roomversion.V4]) + } + std := strings.TrimPrefix(ids[roomversion.V3], "$") + urlSafe := strings.TrimPrefix(ids[roomversion.V4], "$") + if decoded, err := base64.RawStdEncoding.DecodeString(std); err != nil || len(decoded) != 32 { + t.Errorf("version 3 ID is not 32 bytes of standard base64: %q (%v)", std, err) + } + if decoded, err := base64.RawURLEncoding.DecodeString(urlSafe); err != nil || len(decoded) != 32 { + t.Errorf("version 4 ID is not 32 bytes of URL-safe base64: %q (%v)", urlSafe, err) + } + if strings.ContainsAny(urlSafe, "+/") { + t.Errorf("version 4 ID contains a character that is not URL-safe: %q", urlSafe) + } +} + +// TestRedactionKeepsEventIDAndSignature is the property the whole hashing +// scheme exists for: a redacted event is still provably the event it claims +// to be. +func TestRedactionKeepsEventIDAndSignature(t *testing.T) { + key := testKey(t) + for _, v := range []roomversion.Version{roomversion.V3, roomversion.V10, roomversion.V11, roomversion.V12} { + t.Run(string(v), func(t *testing.T) { + rules := roomversion.MustGet(v) + e := buildEvent(t, rules, key, Builder{ + Type: "m.room.message", + Content: json.RawMessage(`{"body":"something private","msgtype":"m.text"}`), + }) + + redacted, err := e.Redact() + if err != nil { + t.Fatalf("Redact: %v", err) + } + if redacted.EventID() != e.EventID() { + t.Errorf("redaction changed the event ID: %q became %q", e.EventID(), redacted.EventID()) + } + if err := redacted.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil { + t.Errorf("the signature on the redacted event does not verify: %v", err) + } + if string(redacted.Content()) != "{}" { + t.Errorf("redacted content = %s, want {}", redacted.Content()) + } + + // The content hash no longer matches, which is exactly how a + // receiver tells a redacted copy from a tampered one. + if err := redacted.VerifyContentHash(); !errors.Is(err, ErrContentHashMismatch) { + t.Errorf("VerifyContentHash on a redacted event = %v, want ErrContentHashMismatch", err) + } + }) + } +} + +// TestRedactionProtectedKeys walks the keys each room version protects. +func TestRedactionProtectedKeys(t *testing.T) { + cases := []struct { + name string + version roomversion.Version + event string + wantKeys []string + wantContent string + }{ + { + name: "version 10 protects the legacy top-level keys", + version: roomversion.V10, + event: `{"type":"m.room.message","room_id":"!r:domain","sender":"@u:domain", + "content":{"body":"x"},"origin":"domain","membership":"join","prev_state":[], + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{},"age_ts":5}`, + wantKeys: []string{ + "content", "hashes", "membership", "origin", "origin_server_ts", + "prev_state", "room_id", "sender", "signatures", "type", + }, + wantContent: `{}`, + }, + { + name: "version 11 drops the legacy top-level keys", + version: roomversion.V11, + event: `{"type":"m.room.message","room_id":"!r:domain","sender":"@u:domain", + "content":{"body":"x"},"origin":"domain","membership":"join","prev_state":[], + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{},"age_ts":5}`, + wantKeys: []string{ + "content", "hashes", "origin_server_ts", "room_id", + "sender", "signatures", "type", + }, + wantContent: `{}`, + }, + { + name: "version 10 keeps only the creator of a create event", + version: roomversion.V10, + event: `{"type":"m.room.create","room_id":"!r:domain","sender":"@u:domain", + "content":{"creator":"@u:domain","room_version":"10","m.federate":false}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"creator":"@u:domain"}`, + }, + { + name: "version 11 keeps all of a create event's content", + version: roomversion.V11, + event: `{"type":"m.room.create","room_id":"!r:domain","sender":"@u:domain", + "content":{"room_version":"11","m.federate":false}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"m.federate":false,"room_version":"11"}`, + }, + { + name: "version 8 keeps the join rule and allow list", + version: roomversion.V8, + event: `{"type":"m.room.join_rules","room_id":"!r:domain","sender":"@u:domain", + "content":{"join_rule":"restricted","allow":[{"type":"m.room_membership"}],"extra":1}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"allow":[{"type":"m.room_membership"}],"join_rule":"restricted"}`, + }, + { + name: "version 7 keeps only the join rule", + version: roomversion.V7, + event: `{"type":"m.room.join_rules","room_id":"!r:domain","sender":"@u:domain", + "content":{"join_rule":"restricted","allow":[{"type":"m.room_membership"}]}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"join_rule":"restricted"}`, + }, + { + name: "version 8 redacts the restricted join authorisation", + version: roomversion.V8, + event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain", + "content":{"membership":"join","join_authorised_via_users_server":"@mod:other"}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"membership":"join"}`, + }, + { + name: "version 9 keeps the restricted join authorisation", + version: roomversion.V9, + event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain", + "content":{"membership":"join","join_authorised_via_users_server":"@mod:other","displayname":"U"}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"join_authorised_via_users_server":"@mod:other","membership":"join"}`, + }, + { + name: "version 10 drops a third-party invite entirely", + version: roomversion.V10, + event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain", + "content":{"membership":"invite","third_party_invite":{"display_name":"d","signed":{"token":"t"}}}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"membership":"invite"}`, + }, + { + name: "version 11 keeps the signed part of a third-party invite", + version: roomversion.V11, + event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain", + "content":{"membership":"invite","third_party_invite":{"display_name":"d","signed":{"token":"t"}}}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"membership":"invite","third_party_invite":{"signed":{"token":"t"}}}`, + }, + { + name: "version 10 loses the target of a redaction", + version: roomversion.V10, + event: `{"type":"m.room.redaction","room_id":"!r:domain","sender":"@u:domain", + "content":{"reason":"spam"},"redacts":"$target", + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantKeys: []string{"content", "hashes", "origin_server_ts", "room_id", "sender", "signatures", "type"}, + wantContent: `{}`, + }, + { + name: "version 11 keeps the target of a redaction", + version: roomversion.V11, + event: `{"type":"m.room.redaction","room_id":"!r:domain","sender":"@u:domain", + "content":{"redacts":"$target","reason":"spam"}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"redacts":"$target"}`, + }, + { + name: "version 11 keeps the invite level of a power levels event", + version: roomversion.V11, + event: `{"type":"m.room.power_levels","room_id":"!r:domain","sender":"@u:domain","state_key":"", + "content":{"invite":50,"ban":50,"notifications":{"room":20}}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"ban":50,"invite":50}`, + }, + { + name: "version 10 drops the invite level of a power levels event", + version: roomversion.V10, + event: `{"type":"m.room.power_levels","room_id":"!r:domain","sender":"@u:domain","state_key":"", + "content":{"invite":50,"ban":50,"notifications":{"room":20}}, + "origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{"ban":50}`, + }, + { + name: "an unknown type keeps no content at all", + version: roomversion.V11, + event: `{"type":"com.example.thing","room_id":"!r:domain","sender":"@u:domain", + "content":{"anything":1},"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`, + wantContent: `{}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rules := roomversion.MustGet(tc.version) + got, err := redactJSON([]byte(tc.event), rules) + if err != nil { + t.Fatalf("redactJSON: %v", err) + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(got, &obj); err != nil { + t.Fatalf("the redacted event is not an object: %v", err) + } + if tc.wantKeys != nil { + var keys []string + for k := range obj { + keys = append(keys, k) + } + if got, want := joinSorted(keys), strings.Join(tc.wantKeys, ","); got != want { + t.Errorf("top-level keys = %s, want %s", got, want) + } + } + if content := string(obj["content"]); content != tc.wantContent { + t.Errorf("content = %s, want %s", content, tc.wantContent) + } + }) + } +} + +func joinSorted(keys []string) string { + // canonicaljson sorts object keys, so the redacted event already has + // them in order; this is only for a readable failure message. + out := make([]string, len(keys)) + copy(out, keys) + for i := range out { + for j := i + 1; j < len(out); j++ { + if out[j] < out[i] { + out[i], out[j] = out[j], out[i] + } + } + } + return strings.Join(out, ",") +} + +// TestRedactsLocation checks that the builder puts a redaction's target +// where the room version keeps it, and that Redacts reads it back from +// either place. +func TestRedactsLocation(t *testing.T) { + key := testKey(t) + + t.Run("version 10 uses the top level", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V10) + e := buildEvent(t, rules, key, Builder{ + Type: TypeRedaction, + Redacts: "$target", + Content: json.RawMessage(`{"reason":"spam"}`), + }) + if e.Redacts() != "$target" { + t.Errorf("Redacts() = %q, want $target", e.Redacts()) + } + if !strings.Contains(string(e.JSON()), `"redacts":"$target"`) { + t.Errorf("redacts is missing from the event: %s", e.JSON()) + } + if strings.Contains(string(e.Content()), "redacts") { + t.Errorf("redacts should not be in the content: %s", e.Content()) + } + }) + + t.Run("version 11 uses the content", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: TypeRedaction, + Redacts: "$target", + Content: json.RawMessage(`{"reason":"spam"}`), + }) + if e.Redacts() != "$target" { + t.Errorf("Redacts() = %q, want $target", e.Redacts()) + } + if !strings.Contains(string(e.Content()), `"redacts":"$target"`) { + t.Errorf("redacts is missing from the content: %s", e.Content()) + } + }) +} + +// TestCreateEventRoomIDByVersion covers the room version 12 change that +// makes the room ID the create event's own ID. +func TestCreateEventRoomIDByVersion(t *testing.T) { + key := testKey(t) + + t.Run("version 11 carries a room ID", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: TypeCreate, + Content: json.RawMessage(`{"room_version":"11"}`), + }) + if e.RoomID() != "!room:warp.test" { + t.Errorf("RoomID() = %q", e.RoomID()) + } + roomID, err := RoomIDOfCreateEvent(e) + if err != nil { + t.Fatalf("RoomIDOfCreateEvent: %v", err) + } + if roomID != "!room:warp.test" { + t.Errorf("RoomIDOfCreateEvent() = %q", roomID) + } + }) + + t.Run("version 12 derives the room ID", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V12) + e := buildEvent(t, rules, key, Builder{ + Type: TypeCreate, + Content: json.RawMessage(`{"room_version":"12"}`), + }) + if e.RoomID() != "" { + t.Errorf("RoomID() = %q, want it to be absent", e.RoomID()) + } + roomID, err := RoomIDOfCreateEvent(e) + if err != nil { + t.Fatalf("RoomIDOfCreateEvent: %v", err) + } + if want := "!" + strings.TrimPrefix(e.EventID(), "$"); roomID != want { + t.Errorf("RoomIDOfCreateEvent() = %q, want %q", roomID, want) + } + if strings.Contains(roomID, ":") { + t.Errorf("a version 12 room ID should carry no domain: %q", roomID) + } + }) + + t.Run("version 12 rejects a create event with a room ID", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V12) + b := Builder{ + Type: TypeCreate, + Sender: "@alice:warp.test", + RoomID: "!room:warp.test", + Content: json.RawMessage(`{"room_version":"12"}`), + } + if _, err := b.Build(time.UnixMilli(1), key, rules); err == nil { + t.Error("Build accepted a room_id on a version 12 create event") + } + }) +} + +func TestVerifyContentHashDetectsTampering(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: "m.room.message", + Content: json.RawMessage(`{"body":"the original","msgtype":"m.text"}`), + }) + + tampered := strings.Replace(string(e.JSON()), "the original", "a forgery!!!", 1) + if tampered == string(e.JSON()) { + t.Fatal("the test did not change anything") + } + parsed, err := Parse([]byte(tampered), rules) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if err := parsed.VerifyContentHash(); !errors.Is(err, ErrContentHashMismatch) { + t.Errorf("VerifyContentHash = %v, want ErrContentHashMismatch", err) + } + + // The signature still verifies, and that is not a hole: it covers the + // redacted event, whose content is {} either way. Rewriting content is + // therefore indistinguishable from being sent a redacted copy, and the + // receiver's answer to a failed hash check is to use the redacted form + // — so the rewritten content never reaches anybody. + if err := parsed.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil { + t.Errorf("the signature should still cover the redacted event: %v", err) + } + + // Rewriting the hash to match the new content is what the signature + // does catch, because the signature covers hashes. + sum, err := contentHash(parsed.JSON()) + if err != nil { + t.Fatal(err) + } + reHashed := strings.Replace(string(parsed.JSON()), + `"sha256":"`+parsed.ContentHash()+`"`, + `"sha256":"`+base64.RawStdEncoding.EncodeToString(sum[:])+`"`, 1) + forged, err := Parse([]byte(reHashed), rules) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if err := forged.VerifyContentHash(); err != nil { + t.Fatalf("the test did not recompute the hash correctly: %v", err) + } + if err := forged.VerifySignature("warp.test", key.KeyID(), key.Public); err == nil { + t.Error("the signature verifies on an event whose content and hash were both rewritten") + } + if forged.EventID() == e.EventID() { + t.Error("rewriting the hash should change the event ID") + } +} + +func TestSignAddsASecondSignature(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: "m.room.member", + Content: json.RawMessage(`{"membership":"invite"}`), + }) + + other, err := signing.Generate("other") + if err != nil { + t.Fatal(err) + } + countersigned, err := e.Sign("other.example", other) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if countersigned.EventID() != e.EventID() { + t.Errorf("countersigning changed the event ID: %q became %q", e.EventID(), countersigned.EventID()) + } + if err := countersigned.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil { + t.Errorf("the original signature no longer verifies: %v", err) + } + if err := countersigned.VerifySignature("other.example", other.KeyID(), other.Public); err != nil { + t.Errorf("the second signature does not verify: %v", err) + } +} + +func TestParseRejects(t *testing.T) { + rules := roomversion.MustGet(roomversion.V11) + const sigs = `"hashes":{"sha256":"x"},"signatures":{"d":{"ed25519:1":"s"}}` + + cases := []struct { + name string + in string + want string + }{ + {"no type", `{"room_id":"!r:d","sender":"@u:d","content":{},` + sigs + `}`, "type is required"}, + {"no sender", `{"type":"m.x","room_id":"!r:d","content":{},` + sigs + `}`, "sender"}, + {"sender is not a user ID", `{"type":"m.x","room_id":"!r:d","sender":"alice","content":{},` + sigs + `}`, "sender"}, + {"no room ID", `{"type":"m.x","sender":"@u:d","content":{},` + sigs + `}`, "room_id"}, + {"room ID with the wrong sigil", `{"type":"m.x","room_id":"@r:d","sender":"@u:d","content":{},` + sigs + `}`, "room_id"}, + {"no content", `{"type":"m.x","room_id":"!r:d","sender":"@u:d",` + sigs + `}`, "content is required"}, + {"content is not an object", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":[],` + sigs + `}`, "content must be an object"}, + {"content is null", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":null,` + sigs + `}`, "content must be an object"}, + {"no content hash", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"signatures":{"d":{"e:1":"s"}}}`, "hashes.sha256 is required"}, + {"no signatures", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"hashes":{"sha256":"x"}}`, "signatures is required"}, + {"a float in the content", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{"a":1.5},` + sigs + `}`, "floats"}, + {"a prev event that is not an event ID", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"prev_events":["abc"],` + sigs + `}`, "prev_events"}, + {"depth above the limit", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"depth":9007199254740992,` + sigs + `}`, "outside the allowed range"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse([]byte(tc.in), rules) + if err == nil { + t.Fatal("Parse succeeded, want an error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + }) + } +} + +func TestParseRejectsOversizedEvent(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + + b := Builder{ + Type: "m.room.message", + Sender: "@alice:warp.test", + RoomID: "!room:warp.test", + Content: json.RawMessage(`{"body":"` + strings.Repeat("x", MaxEventSize) + `"}`), + } + _, err := b.Build(time.UnixMilli(1), key, rules) + if err == nil { + t.Fatal("Build accepted an event over the size limit") + } + if !strings.Contains(err.Error(), "the limit is") { + t.Errorf("error = %q, want it to mention the limit", err) + } +} + +func TestParseRejectsTooManyDAGReferences(t *testing.T) { + rules := roomversion.MustGet(roomversion.V11) + ids := make([]string, MaxPrevEvents+1) + for i := range ids { + ids[i] = "$event" + string(rune('a'+i)) + } + encoded, err := json.Marshal(ids) + if err != nil { + t.Fatal(err) + } + raw := `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"prev_events":` + + string(encoded) + `,"hashes":{"sha256":"x"},"signatures":{"d":{"ed25519:1":"s"}}}` + if _, err := Parse([]byte(raw), rules); err == nil { + t.Error("Parse accepted more than the maximum number of prev_events") + } +} + +func TestMembership(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + target := "@bob:warp.test" + + e := buildEvent(t, rules, key, Builder{ + Type: TypeMember, + StateKey: &target, + Content: json.RawMessage(`{"membership":"invite"}`), + }) + got, err := e.Membership() + if err != nil { + t.Fatalf("Membership: %v", err) + } + if got != MembershipInvite { + t.Errorf("Membership() = %q, want %q", got, MembershipInvite) + } + + other := buildEvent(t, rules, key, Builder{Type: "m.room.message", Content: json.RawMessage(`{}`)}) + if _, err := other.Membership(); err == nil { + t.Error("Membership on a message event succeeded, want an error") + } +} + +func TestSenderServer(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{Type: "m.room.message", Content: json.RawMessage(`{}`)}) + + got, err := e.SenderServer() + if err != nil { + t.Fatalf("SenderServer: %v", err) + } + if got != "warp.test" { + t.Errorf("SenderServer() = %q, want warp.test", got) + } +} + +func TestBuilderRejects(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + + cases := []struct { + name string + b Builder + }{ + {"no sender", Builder{Type: "m.x", RoomID: "!r:d"}}, + {"sender is not a user ID", Builder{Type: "m.x", RoomID: "!r:d", Sender: "alice"}}, + {"no type", Builder{RoomID: "!r:d", Sender: "@u:d"}}, + {"no room ID", Builder{Type: "m.x", Sender: "@u:d"}}, + {"negative depth", Builder{Type: "m.x", RoomID: "!r:d", Sender: "@u:d", Depth: -1}}, + {"redacts is not an event ID", Builder{Type: TypeRedaction, RoomID: "!r:d", Sender: "@u:d", Redacts: "nope"}}, + {"content is not an object", Builder{Type: "m.x", RoomID: "!r:d", Sender: "@u:d", Content: json.RawMessage(`[]`)}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := tc.b.Build(time.UnixMilli(1), key, rules); err == nil { + t.Error("Build succeeded, want an error") + } + }) + } +} + +// TestUnsignedIsNotCoveredBySignature mirrors the federation case where a +// server rewrites unsigned.age in transit. +func TestUnsignedIsNotCoveredBySignature(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: "m.room.message", + Content: json.RawMessage(`{"body":"hi"}`), + Unsigned: json.RawMessage(`{"age":10}`), + }) + + rewritten := strings.Replace(string(e.JSON()), `"age":10`, `"age":99`, 1) + parsed, err := Parse([]byte(rewritten), rules) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if parsed.EventID() != e.EventID() { + t.Errorf("rewriting unsigned changed the event ID: %q became %q", e.EventID(), parsed.EventID()) + } + if err := parsed.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil { + t.Errorf("rewriting unsigned broke the signature: %v", err) + } + if err := parsed.VerifyContentHash(); err != nil { + t.Errorf("rewriting unsigned broke the content hash: %v", err) + } +} + +// FuzzParse checks that no input makes Parse panic, and that any event it +// accepts round-trips through its own JSON to the same ID. +func FuzzParse(f *testing.F) { + rules := roomversion.MustGet(roomversion.V11) + f.Add([]byte(`{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},` + + `"hashes":{"sha256":"x"},"signatures":{"d":{"ed25519:1":"s"}}}`)) + f.Add([]byte(`{}`)) + f.Fuzz(func(t *testing.T, in []byte) { + e, err := Parse(in, rules) + if err != nil { + return + } + again, err := Parse(e.JSON(), rules) + if err != nil { + t.Fatalf("re-parsing an accepted event failed: %v\nevent: %s", err, e.JSON()) + } + if again.EventID() != e.EventID() { + t.Fatalf("event ID is not stable: %q then %q\nevent: %s", e.EventID(), again.EventID(), e.JSON()) + } + if _, err := e.Redact(); err != nil { + t.Fatalf("redacting an accepted event failed: %v\nevent: %s", err, e.JSON()) + } + }) +} + +// TestAccessors walks the plain field accessors, which the rest of the +// server reads an event through. +func TestAccessors(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: "m.room.message", + Content: json.RawMessage(`{"body":"hi"}`), + PrevEvents: []string{"$prev"}, + AuthEvents: []string{"$auth"}, + Depth: 4, + Unsigned: json.RawMessage(`{"age":7}`), + }) + + if got := e.PrevEventIDs(); len(got) != 1 || got[0] != "$prev" { + t.Errorf("PrevEventIDs() = %v", got) + } + if got := e.AuthEventIDs(); len(got) != 1 || got[0] != "$auth" { + t.Errorf("AuthEventIDs() = %v", got) + } + if got := string(e.Unsigned()); got != `{"age":7}` { + t.Errorf("Unsigned() = %s", got) + } + if _, ok := e.Signatures().Get("warp.test", key.KeyID()); !ok { + t.Errorf("Signatures() has no entry for warp.test: %v", e.Signatures()) + } + if e.Rules().Version != roomversion.V11 { + t.Errorf("Rules().Version = %q", e.Rules().Version) + } + if e.Version() != roomversion.V11 { + t.Errorf("Version() = %q", e.Version()) + } + if e.ContentHash() == "" { + t.Error("ContentHash() is empty") + } + + // The reference hash is what the event ID is made of, so it must agree + // with the ID the event already has. + hash, err := e.ReferenceHash() + if err != nil { + t.Fatalf("ReferenceHash: %v", err) + } + if len(hash) != 32 { + t.Errorf("ReferenceHash() is %d bytes, want 32", len(hash)) + } + if want := "$" + base64.RawURLEncoding.EncodeToString(hash); want != e.EventID() { + t.Errorf("the reference hash encodes to %q, but the event ID is %q", want, e.EventID()) + } +} + +// TestContentHashAcceptsPaddedBase64 covers the leniency the spec asks for +// when decoding: values get copied between implementations by hand, so a +// padded hash still has to verify. +func TestContentHashAcceptsPaddedBase64(t *testing.T) { + key := testKey(t) + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{Type: "m.room.message", Content: json.RawMessage(`{"body":"hi"}`)}) + + sum, err := contentHash(e.JSON()) + if err != nil { + t.Fatal(err) + } + padded := strings.Replace(string(e.JSON()), + `"sha256":"`+e.ContentHash()+`"`, + `"sha256":"`+base64.StdEncoding.EncodeToString(sum[:])+`"`, 1) + + parsed, err := Parse([]byte(padded), rules) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if err := parsed.VerifyContentHash(); err != nil { + t.Errorf("a padded content hash did not verify: %v", err) + } +} + +// TestRedactARedaction checks that the target of a redaction survives +// being redacted itself, which is why the property moved into the content. +func TestRedactARedaction(t *testing.T) { + key := testKey(t) + + t.Run("version 11 keeps it", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V11) + e := buildEvent(t, rules, key, Builder{ + Type: TypeRedaction, + Redacts: "$target", + Content: json.RawMessage(`{"reason":"spam"}`), + }) + redacted, err := e.Redact() + if err != nil { + t.Fatalf("Redact: %v", err) + } + if redacted.Redacts() != "$target" { + t.Errorf("Redacts() = %q after redaction, want $target", redacted.Redacts()) + } + }) + + t.Run("version 10 loses it", func(t *testing.T) { + rules := roomversion.MustGet(roomversion.V10) + e := buildEvent(t, rules, key, Builder{ + Type: TypeRedaction, + Redacts: "$target", + Content: json.RawMessage(`{"reason":"spam"}`), + }) + redacted, err := e.Redact() + if err != nil { + t.Fatalf("Redact: %v", err) + } + if redacted.Redacts() != "" { + t.Errorf("Redacts() = %q after redaction, want it to be gone", redacted.Redacts()) + } + }) +} diff --git a/server/internal/matrix/event/hash.go b/server/internal/matrix/event/hash.go new file mode 100644 index 0000000..96d4e25 --- /dev/null +++ b/server/internal/matrix/event/hash.go @@ -0,0 +1,136 @@ +package event + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// ErrContentHashMismatch means an event's content does not match the hash +// it carries. +// +// Over federation this is not necessarily an attack and not necessarily +// fatal: the usual cause is being sent a redacted copy of an event whose +// signature still verifies, because the signature covers only the keys +// redaction keeps. The caller's answer is to use the redacted form rather +// than to drop the event. +var ErrContentHashMismatch = errors.New("event content does not match its content hash") + +// contentHash computes the content hash of an event: SHA-256 over the +// canonical JSON of the event with unsigned, signatures and hashes removed. +// +// Each of the three is excluded for its own reason. unsigned is explicitly +// rewritable in transit; signatures depend on the hash, so including it +// would be circular; and hashes is left out so that a future algorithm can +// be added alongside sha256 without changing what sha256 covers. +func contentHash(raw []byte) ([sha256.Size]byte, error) { + obj, err := decodeObject(raw) + if err != nil { + return [sha256.Size]byte{}, err + } + delete(obj, "unsigned") + delete(obj, "signatures") + delete(obj, "hashes") + + encoded, err := canonicaljson.Marshal(obj) + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("%w: content hash: %w", ErrInvalidEvent, err) + } + return sha256.Sum256(encoded), nil +} + +// referenceHash computes the reference hash of an event, which is what its +// ID is made of: the event is redacted, then signatures and unsigned are +// removed, then it is canonicalised and hashed. +// +// Redacting first is what makes an event ID survive redaction — the ID +// covers only the keys a redaction keeps, so redacting an event does not +// rename it. +func referenceHash(raw []byte, rules roomversion.Rules) ([sha256.Size]byte, error) { + redacted, err := redactJSON(raw, rules) + if err != nil { + return [sha256.Size]byte{}, err + } + obj, err := decodeObject(redacted) + if err != nil { + return [sha256.Size]byte{}, err + } + delete(obj, "signatures") + delete(obj, "unsigned") + + encoded, err := canonicaljson.Marshal(obj) + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("%w: reference hash: %w", ErrInvalidEvent, err) + } + return sha256.Sum256(encoded), nil +} + +// computeEventID derives an event ID from the event's reference hash. +func computeEventID(raw []byte, rules roomversion.Rules) (string, error) { + sum, err := referenceHash(raw, rules) + if err != nil { + return "", err + } + switch rules.EventIDFormat { + case roomversion.EventIDFormatSHA256: + return "$" + base64.RawStdEncoding.EncodeToString(sum[:]), nil + case roomversion.EventIDFormatSHA256URLSafe: + // URL-safe, because an ID with a '/' in it cannot be put in a path + // — which is where event IDs ended up in the federation API. + return "$" + base64.RawURLEncoding.EncodeToString(sum[:]), nil + default: + return "", fmt.Errorf("%w: room version %s does not derive event IDs from a hash", + ErrInvalidEvent, rules.Version) + } +} + +// ReferenceHash returns the event's reference hash, unencoded. Federation +// needs it for the room version 1 and 2 event format and for auth chain +// comparisons. +func (e *Event) ReferenceHash() ([]byte, error) { + sum, err := referenceHash(e.raw, e.rules) + if err != nil { + return nil, err + } + return sum[:], nil +} + +// VerifyContentHash checks the event against the content hash it carries. +// +// It returns ErrContentHashMismatch when the hash does not match, which a +// federation caller should answer by using the redacted copy of the event +// rather than by rejecting it. +func (e *Event) VerifyContentHash() error { + want, err := signing.DecodeBase64(e.fields.Hashes.SHA256) + if err != nil { + return fmt.Errorf("%w: hashes.sha256: %w", ErrInvalidEvent, err) + } + got, err := contentHash(e.raw) + if err != nil { + return err + } + if !bytes.Equal(want, got[:]) { + return ErrContentHashMismatch + } + return nil +} + +// decodeObject parses an event into its properties, leaving every value as +// the bytes it arrived as so that nothing we do not understand is lost. +func decodeObject(raw []byte) (map[string]json.RawMessage, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err) + } + if obj == nil { + return nil, fmt.Errorf("%w: event is null, not an object", ErrInvalidEvent) + } + return obj, nil +} diff --git a/server/internal/matrix/event/redact.go b/server/internal/matrix/event/redact.go new file mode 100644 index 0000000..885b39d --- /dev/null +++ b/server/internal/matrix/event/redact.go @@ -0,0 +1,222 @@ +package event + +import ( + "encoding/json" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" +) + +// Redact returns the event with everything a redaction removes stripped +// off, leaving the properties the room version protects. +// +// The redacted event keeps the same event ID, because the ID is the +// reference hash of the redacted form in the first place — redacting an +// event does not rename it, and the signature on it still verifies. That is +// the whole point of the algorithm: a server can prove a redacted event is +// genuine without ever having seen its content. +// +// Redact says nothing about whether the redaction is *allowed*. That +// depends on the sender's power level or on their server having sent the +// original, and belongs to the roomserver along with the decision to apply +// it at all. +func (e *Event) Redact() (*Event, error) { + redacted, err := redactJSON(e.raw, e.rules) + if err != nil { + return nil, err + } + out := &Event{rules: e.rules, raw: redacted, id: e.id} + if err := json.Unmarshal(redacted, &out.fields); err != nil { + return nil, fmt.Errorf("%w: redacted event: %w", ErrInvalidEvent, err) + } + if e.rules.RedactsInContent && out.fields.Type == TypeRedaction { + var content struct { + Redacts string `json:"redacts"` + } + if err := json.Unmarshal(out.fields.Content, &content); err != nil { + return nil, fmt.Errorf("%w: redacted m.room.redaction content: %w", ErrInvalidEvent, err) + } + out.fields.Redacts = content.Redacts + } + return out, nil +} + +// redactJSON applies the redaction algorithm to raw event JSON and returns +// canonical JSON. +func redactJSON(raw []byte, rules roomversion.Rules) ([]byte, error) { + obj, err := decodeObject(raw) + if err != nil { + return nil, err + } + + var header struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err) + } + + keep := protectedTopLevelKeys(rules) + for k := range obj { + if !keep[k] { + delete(obj, k) + } + } + + content, err := redactContent(header.Type, obj["content"], rules) + if err != nil { + return nil, err + } + obj["content"] = content + + out, err := canonicaljson.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("%w: redact: %w", ErrInvalidEvent, err) + } + return out, nil +} + +// protectedTopLevelKeys returns the event properties a redaction keeps. +func protectedTopLevelKeys(rules roomversion.Rules) map[string]bool { + keep := map[string]bool{ + "event_id": true, + "type": true, + "room_id": true, + "sender": true, + "state_key": true, + "content": true, + "hashes": true, + "signatures": true, + "depth": true, + "prev_events": true, + "auth_events": true, + "origin_server_ts": true, + } + if rules.RedactionProtectsLegacyKeys { + // Room versions up to 10 also protected these. None was + // load-bearing, and the top-level membership in particular let a + // redacted event keep asserting a membership its content no longer + // supported, so version 11 stopped protecting them. + keep["prev_state"] = true + keep["origin"] = true + keep["membership"] = true + } + return keep +} + +// redactContent strips the content of an event down to the keys its type is +// allowed to keep. +func redactContent(eventType string, content json.RawMessage, rules roomversion.Rules) (json.RawMessage, error) { + empty := json.RawMessage("{}") + if len(content) == 0 { + return empty, nil + } + + // m.room.create keeps all of its content from room version 11, so + // there is nothing to strip and nothing to re-encode. + if eventType == TypeCreate && rules.RedactionKeepsWholeCreateContent { + return content, nil + } + + keep := protectedContentKeys(eventType, rules) + keepsThirdPartyInvite := eventType == TypeMember && rules.RedactionKeepsThirdPartyInviteSigned + if len(keep) == 0 && !keepsThirdPartyInvite { + return empty, nil + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(content, &obj); err != nil { + return nil, fmt.Errorf("%w: content: %w", ErrInvalidEvent, err) + } + for k := range obj { + if !keep[k] { + delete(obj, k) + } + } + + if keepsThirdPartyInvite { + if signed, ok := thirdPartyInviteSigned(content); ok { + obj["third_party_invite"] = signed + } + } + + out, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("%w: redact content: %w", ErrInvalidEvent, err) + } + return out, nil +} + +// protectedContentKeys returns the content keys a redaction keeps for an +// event type. An unlisted type keeps nothing. +func protectedContentKeys(eventType string, rules roomversion.Rules) map[string]bool { + keep := map[string]bool{} + switch eventType { + case TypeMember: + keep["membership"] = true + if rules.RedactionKeepsJoinAuthorisedViaUsersServer { + // Without this, a redacted join in a restricted room could + // never be re-authorised, because the proof that somebody with + // the power to invite had allowed it was in the key that went + // away. + keep["join_authorised_via_users_server"] = true + } + case TypeCreate: + // Before room version 11 only the creator survived, which threw + // away room_version — the one property a server needs in order to + // read the rest of the room. + keep["creator"] = true + case TypeJoinRules: + keep["join_rule"] = true + if rules.RedactionKeepsJoinRulesAllow { + keep["allow"] = true + } + case TypePowerLevels: + for _, k := range []string{ + "ban", "events", "events_default", "kick", + "redact", "state_default", "users", "users_default", + } { + keep[k] = true + } + if rules.RedactionKeepsPowerLevelsInvite { + keep["invite"] = true + } + case TypeHistoryVisibility: + keep["history_visibility"] = true + case TypeRedaction: + if rules.RedactionKeepsRedacts { + keep["redacts"] = true + } + case TypeAliases: + if rules.RedactionKeepsAliases { + keep["aliases"] = true + } + } + return keep +} + +// thirdPartyInviteSigned returns {"signed": ...} from an m.room.member +// event's third_party_invite, if it has one. +// +// From room version 11 this much of a third-party invite survives +// redaction: it is the signature chain that proves the invite was issued by +// an identity server, and an event that loses it can never be authorised +// again. +func thirdPartyInviteSigned(content json.RawMessage) (json.RawMessage, bool) { + var parsed struct { + ThirdPartyInvite map[string]json.RawMessage `json:"third_party_invite"` + } + if err := json.Unmarshal(content, &parsed); err != nil { + return nil, false + } + signed, ok := parsed.ThirdPartyInvite["signed"] + if !ok { + return nil, false + } + out, err := json.Marshal(map[string]json.RawMessage{"signed": signed}) + if err != nil { + return nil, false + } + return out, true +} diff --git a/server/internal/matrix/event/sign.go b/server/internal/matrix/event/sign.go new file mode 100644 index 0000000..8c45e4e --- /dev/null +++ b/server/internal/matrix/event/sign.go @@ -0,0 +1,113 @@ +package event + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +// hashAndSign adds a content hash and a signature to a bare event. +// +// The order matters and is not the obvious one. The content hash is added +// first, so that it covers every property of the unredacted event. The +// event is then *redacted* and that copy is signed, so the signature covers +// only the properties a redaction keeps — plus the hashes, which cover the +// rest. The signature is then copied back onto the full event. +// +// The result is an event whose signature still verifies after someone +// redacts it, which is what lets a server hand out a redacted event and +// have the recipient believe it. +func hashAndSign(raw []byte, rules roomversion.Rules, serverName string, key *signing.KeyPair) ([]byte, error) { + obj, err := decodeObject(raw) + if err != nil { + return nil, err + } + + sum, err := contentHash(raw) + if err != nil { + return nil, err + } + obj["hashes"], err = json.Marshal(hashes{SHA256: base64.RawStdEncoding.EncodeToString(sum[:])}) + if err != nil { + return nil, fmt.Errorf("%w: encode hashes: %w", ErrInvalidEvent, err) + } + + hashed, err := canonicaljson.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err) + } + redacted, err := redactJSON(hashed, rules) + if err != nil { + return nil, err + } + signed, err := signing.SignJSON(serverName, key, redacted) + if err != nil { + return nil, err + } + + sigs, err := signing.ExtractSignatures(signed) + if err != nil { + return nil, err + } + if obj["signatures"], err = json.Marshal(sigs); err != nil { + return nil, fmt.Errorf("%w: encode signatures: %w", ErrInvalidEvent, err) + } + return canonicaljson.Marshal(obj) +} + +// VerifySignature checks the event for a valid signature from serverName +// made with keyID. +// +// The event is redacted first, because that is the form that was signed. +// This holds whether we were given the full event or a redacted copy, which +// is why a redacted event can still be shown to be genuine. +func (e *Event) VerifySignature(serverName, keyID string, pub ed25519.PublicKey) error { + redacted, err := redactJSON(e.raw, e.rules) + if err != nil { + return err + } + return signing.VerifyJSON(serverName, keyID, pub, redacted) +} + +// Sign returns the event with an additional signature from serverName. +// +// A normal event is signed once, by the sender's own server, when it is +// built. A second signature is needed in the few places where another +// server has to vouch for an event it did not send: the invited server +// countersigning an invite, and a third-party invite exchanged through a +// server other than the sender's. +func (e *Event) Sign(serverName string, key *signing.KeyPair) (*Event, error) { + redacted, err := redactJSON(e.raw, e.rules) + if err != nil { + return nil, err + } + signed, err := signing.SignJSON(serverName, key, redacted) + if err != nil { + return nil, err + } + sigs, err := signing.ExtractSignatures(signed) + if err != nil { + return nil, err + } + + obj, err := decodeObject(e.raw) + if err != nil { + return nil, err + } + if obj["signatures"], err = json.Marshal(sigs); err != nil { + return nil, fmt.Errorf("%w: encode signatures: %w", ErrInvalidEvent, err) + } + out, err := canonicaljson.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err) + } + // Re-parse rather than patching the Event in place: an Event is + // immutable, and the signature block is one of the things Parse + // validates. + return Parse(out, e.rules) +} diff --git a/server/internal/matrix/event/validate.go b/server/internal/matrix/event/validate.go new file mode 100644 index 0000000..5a5778d --- /dev/null +++ b/server/internal/matrix/event/validate.go @@ -0,0 +1,148 @@ +package event + +import ( + "encoding/json" + "fmt" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/id" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" +) + +// The limits the spec puts on an event. +const ( + // MaxEventSize is the largest an event may be as canonical JSON, + // signatures included. 64 KiB is generous for a message and small + // enough that a room's state cannot be used to exhaust a server. + MaxEventSize = 65536 + + // MaxFieldLength caps the type and state_key properties. + MaxFieldLength = 255 + + // MaxPrevEvents and MaxAuthEvents cap the DAG references an event may + // carry. The auth events selection algorithm never needs more than a + // handful, so a longer list is a sign of something wrong rather than a + // legitimate event. + MaxPrevEvents = 20 + MaxAuthEvents = 10 + + // MaxDepth is the largest depth an event may claim. Once a room gets + // here every later event stays at the limit. + MaxDepth = canonicaljson.MaxInt +) + +// validate applies the structural rules: the properties the spec marks +// required are present and of the right type, the identifiers are +// well-formed, and nothing is over its size limit. +// +// It deliberately does not look at the room state. Everything checkable +// from the event alone is checked here so that the authorisation rules can +// assume a well-formed event and concern themselves only with permission. +func (e *Event) validate() error { + if e.rules.EventIDFormat == roomversion.EventIDFormatExplicit { + // Unreachable today: Get refuses to return rules for room versions + // 1 and 2. The check is here so that adding them to the registry + // fails loudly rather than computing nonsense event IDs. + return fmt.Errorf("%w: room version %s carries event IDs in the event, which Warp cannot read yet", + ErrInvalidEvent, e.rules.Version) + } + + if n := len(e.raw); n > MaxEventSize { + return fmt.Errorf("%w: event is %d bytes, the limit is %d", ErrInvalidEvent, n, MaxEventSize) + } + + if e.fields.Type == "" { + return fmt.Errorf("%w: type is required", ErrInvalidEvent) + } + if n := len(e.fields.Type); n > MaxFieldLength { + return fmt.Errorf("%w: type is %d bytes, the limit is %d", ErrInvalidEvent, n, MaxFieldLength) + } + if err := id.ValidateUserID(e.fields.Sender); err != nil { + return fmt.Errorf("%w: sender: %w", ErrInvalidEvent, err) + } + if sk, ok := e.StateKey(); ok { + if n := len(sk); n > MaxFieldLength { + return fmt.Errorf("%w: state_key is %d bytes, the limit is %d", ErrInvalidEvent, n, MaxFieldLength) + } + } + + if err := e.validateRoomID(); err != nil { + return err + } + if err := e.validateContent(); err != nil { + return err + } + if err := e.validateDAGReferences(); err != nil { + return err + } + + if e.fields.Depth > MaxDepth { + return fmt.Errorf("%w: depth %d is above the limit of %d", ErrInvalidEvent, e.fields.Depth, MaxDepth) + } + if e.fields.Hashes.SHA256 == "" { + return fmt.Errorf("%w: hashes.sha256 is required", ErrInvalidEvent) + } + if len(e.fields.Signatures) == 0 { + return fmt.Errorf("%w: signatures is required", ErrInvalidEvent) + } + return nil +} + +// validateRoomID checks the room_id property, which from room version 12 is +// absent on the create event because the room ID is derived from it. +func (e *Event) validateRoomID() error { + isCreate := e.fields.Type == TypeCreate + if e.rules.RoomIDIsCreateEventID && isCreate { + if e.fields.RoomID != "" { + // Rule 1.2 for this room version rejects it as well; failing + // here too means we never compute an ID for an event whose + // room is ambiguous. + return fmt.Errorf("%w: an m.room.create event in a version %s room must not have a room_id", + ErrInvalidEvent, e.rules.Version) + } + return nil + } + if err := id.ValidateRoomID(e.fields.RoomID); err != nil { + return fmt.Errorf("%w: room_id: %w", ErrInvalidEvent, err) + } + return nil +} + +// validateContent checks that content is present and is an object. The +// spec marks it required on every event, and the authorisation rules and +// the redaction algorithm both read into it. +func (e *Event) validateContent() error { + if len(e.fields.Content) == 0 { + return fmt.Errorf("%w: content is required", ErrInvalidEvent) + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(e.fields.Content, &obj); err != nil { + return fmt.Errorf("%w: content must be an object: %w", ErrInvalidEvent, err) + } + if obj == nil { + return fmt.Errorf("%w: content must be an object, not null", ErrInvalidEvent) + } + return nil +} + +// validateDAGReferences checks prev_events and auth_events: within their +// limits, and every entry a well-formed event ID. +func (e *Event) validateDAGReferences() error { + if n := len(e.fields.PrevEvents); n > MaxPrevEvents { + return fmt.Errorf("%w: %d prev_events, the limit is %d", ErrInvalidEvent, n, MaxPrevEvents) + } + if n := len(e.fields.AuthEvents); n > MaxAuthEvents { + return fmt.Errorf("%w: %d auth_events, the limit is %d", ErrInvalidEvent, n, MaxAuthEvents) + } + for _, eventID := range e.fields.PrevEvents { + if err := id.ValidateEventID(eventID); err != nil { + return fmt.Errorf("%w: prev_events: %w", ErrInvalidEvent, err) + } + } + for _, eventID := range e.fields.AuthEvents { + if err := id.ValidateEventID(eventID); err != nil { + return fmt.Errorf("%w: auth_events: %w", ErrInvalidEvent, err) + } + } + return nil +} diff --git a/server/internal/matrix/id/id.go b/server/internal/matrix/id/id.go new file mode 100644 index 0000000..a9f391d --- /dev/null +++ b/server/internal/matrix/id/id.go @@ -0,0 +1,139 @@ +// Package id validates the Matrix identifiers that appear inside events. +// +// The checks here are deliberately laxer than the ones in userapi, which +// guard account creation on this server and enforce the current, strict +// localpart grammar. Events are different: a room can contain user IDs +// minted years ago by servers that allowed upper case and other characters +// the grammar has since dropped, and the spec requires receivers to accept +// them. Rejecting them would mean refusing to authorise perfectly valid +// history, so the rules below are the "historical" ones — a sigil, a +// non-empty localpart, a colon, a server name, and a length limit. +package id + +import ( + "errors" + "fmt" + "strings" +) + +// MaxLength is the spec's cap on a user ID, room ID, alias or event ID, +// sigil and server name included. +const MaxLength = 255 + +// ErrInvalidID is the root of every error this package returns. +var ErrInvalidID = errors.New("invalid Matrix identifier") + +// ValidateUserID checks a user ID against the historical grammar: +// "@localpart:server_name", at most 255 bytes, with no NUL anywhere. +// +// It is the check behind the authorisation rules that call for a "valid +// user ID", such as the keys of m.room.power_levels users. +func ValidateUserID(s string) error { + _, _, err := SplitUserID(s) + return err +} + +// SplitUserID splits a user ID into its localpart and server name, without +// the sigil or the colon. +func SplitUserID(s string) (localpart, serverName string, err error) { + if err := checkCommon("user ID", s, '@'); err != nil { + return "", "", err + } + localpart, serverName, ok := strings.Cut(s[1:], ":") + if !ok { + return "", "", fmt.Errorf("%w: user ID %q has no server name; expected @localpart:server", ErrInvalidID, s) + } + if localpart == "" { + return "", "", fmt.Errorf("%w: user ID %q has an empty localpart", ErrInvalidID, s) + } + if serverName == "" { + return "", "", fmt.Errorf("%w: user ID %q has an empty server name", ErrInvalidID, s) + } + return localpart, serverName, nil +} + +// ServerNameOf returns the server name from a user ID. +func ServerNameOf(userID string) (string, error) { + _, serverName, err := SplitUserID(userID) + return serverName, err +} + +// SameServer reports whether two user IDs belong to the same homeserver. +// +// Server names are compared byte for byte: the spec says they are +// case-sensitive, and treating "Example.org" and "example.org" as one +// server here would quietly widen rules that turn on a domain match, such +// as the m.federate check and the right to redact another user's event. +func SameServer(userIDA, userIDB string) bool { + a, errA := ServerNameOf(userIDA) + b, errB := ServerNameOf(userIDB) + if errA != nil || errB != nil { + return false + } + return a == b +} + +// ValidateRoomID checks a room ID: a '!' sigil and at most 255 bytes. +// +// Whether the rest of it is "opaque:server" or the hash of the create +// event depends on the room version, and in the hashed form there is +// nothing left to check beyond the sigil and the length — so the +// version-specific part of the check lives with the authorisation rules, +// which have the create event to compare against. +func ValidateRoomID(s string) error { + if err := checkCommon("room ID", s, '!'); err != nil { + return err + } + if len(s) == 1 { + return fmt.Errorf("%w: room ID %q is only a sigil", ErrInvalidID, s) + } + return nil +} + +// ServerNameOfRoomID returns the server name from a room ID of the +// "!opaque:server" form used up to room version 11. +// +// It fails for a room version 12 room ID, which is a hash of the create +// event and names no server at all — the caller should only ask in the +// versions where the answer exists. +func ServerNameOfRoomID(roomID string) (string, error) { + if err := ValidateRoomID(roomID); err != nil { + return "", err + } + _, serverName, ok := strings.Cut(roomID[1:], ":") + if !ok || serverName == "" { + return "", fmt.Errorf("%w: room ID %q names no server", ErrInvalidID, roomID) + } + return serverName, nil +} + +// ValidateEventID checks an event ID: a '$' sigil and at most 255 bytes. +func ValidateEventID(s string) error { + if err := checkCommon("event ID", s, '$'); err != nil { + return err + } + if len(s) == 1 { + return fmt.Errorf("%w: event ID %q is only a sigil", ErrInvalidID, s) + } + return nil +} + +// checkCommon applies the three rules every identifier shares. +func checkCommon(kind, s string, sigil byte) error { + if s == "" { + return fmt.Errorf("%w: %s must not be empty", ErrInvalidID, kind) + } + if s[0] != sigil { + return fmt.Errorf("%w: %s %q does not start with %q", ErrInvalidID, kind, s, string(sigil)) + } + if len(s) > MaxLength { + return fmt.Errorf("%w: %s is %d bytes, the limit is %d", ErrInvalidID, kind, len(s), MaxLength) + } + // A NUL would terminate the string in most of the software that ever + // sees it, which makes two identifiers that differ after it look the + // same to some servers and not to others. + if strings.IndexByte(s, 0) >= 0 { + return fmt.Errorf("%w: %s %q contains a NUL byte", ErrInvalidID, kind, s) + } + return nil +} diff --git a/server/internal/matrix/id/id_test.go b/server/internal/matrix/id/id_test.go new file mode 100644 index 0000000..2779efd --- /dev/null +++ b/server/internal/matrix/id/id_test.go @@ -0,0 +1,184 @@ +package id + +import ( + "errors" + "strings" + "testing" +) + +func TestSplitUserID(t *testing.T) { + cases := []struct { + in string + localpart string + serverName string + }{ + {"@alice:warp.test", "alice", "warp.test"}, + {"@alice:warp.test:8448", "alice", "warp.test:8448"}, + // Historical IDs: characters the current grammar forbids still have + // to be accepted, because rooms are full of them. + {"@Alice:warp.test", "Alice", "warp.test"}, + {"@a b:warp.test", "a b", "warp.test"}, + {"@😀:warp.test", "😀", "warp.test"}, + } + for _, tc := range cases { + localpart, serverName, err := SplitUserID(tc.in) + if err != nil { + t.Errorf("SplitUserID(%q) = %v", tc.in, err) + continue + } + if localpart != tc.localpart || serverName != tc.serverName { + t.Errorf("SplitUserID(%q) = %q, %q; want %q, %q", + tc.in, localpart, serverName, tc.localpart, tc.serverName) + } + } +} + +func TestValidateUserIDRejects(t *testing.T) { + cases := map[string]string{ + "": "must not be empty", + "alice:warp.test": "does not start with", + "#alice:warp.test": "does not start with", + "@alice": "no server name", + "@:warp.test": "empty localpart", + "@alice:": "empty server name", + "@alice\x00:warp.test": "NUL", + "@" + strings.Repeat("a", 255): "the limit is", + } + for in, want := range cases { + err := ValidateUserID(in) + if err == nil { + t.Errorf("ValidateUserID(%q) was accepted, want an error", in) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("ValidateUserID(%q) = %q, want it to mention %q", in, err, want) + } + if !errors.Is(err, ErrInvalidID) { + t.Errorf("ValidateUserID(%q) does not wrap ErrInvalidID: %v", in, err) + } + } +} + +func TestUserIDLengthLimit(t *testing.T) { + // Exactly at the limit is allowed; one byte over is not. + atLimit := "@" + strings.Repeat("a", MaxLength-len("@:warp.test")) + ":warp.test" + if len(atLimit) != MaxLength { + t.Fatalf("the test built a %d-byte ID, want %d", len(atLimit), MaxLength) + } + if err := ValidateUserID(atLimit); err != nil { + t.Errorf("a %d-byte user ID was rejected: %v", MaxLength, err) + } + if err := ValidateUserID(atLimit + "a"); err == nil { + t.Error("a user ID one byte over the limit was accepted") + } +} + +func TestServerNameOf(t *testing.T) { + got, err := ServerNameOf("@alice:warp.test") + if err != nil { + t.Fatal(err) + } + if got != "warp.test" { + t.Errorf("ServerNameOf = %q, want warp.test", got) + } + if _, err := ServerNameOf("nonsense"); err == nil { + t.Error("ServerNameOf accepted a string that is not a user ID") + } +} + +func TestSameServer(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"@alice:warp.test", "@bob:warp.test", true}, + {"@alice:warp.test", "@alice:warp.test", true}, + {"@alice:warp.test", "@bob:other.test", false}, + // Server names are case-sensitive by spec, and the rules that turn + // on a domain match must not be widened by a capital letter. + {"@alice:warp.test", "@bob:Warp.test", false}, + {"@alice:warp.test", "not-a-user-id", false}, + {"", "", false}, + } + for _, tc := range cases { + if got := SameServer(tc.a, tc.b); got != tc.want { + t.Errorf("SameServer(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func TestValidateRoomID(t *testing.T) { + valid := []string{ + "!abc:warp.test", + // From room version 12 a room ID is a hash with no domain at all. + "!Nhcu5BS-UMnFX7hBVfVSoXiD7OgH6iRT-xyIuqDnpYQ", + } + for _, s := range valid { + if err := ValidateRoomID(s); err != nil { + t.Errorf("ValidateRoomID(%q) = %v", s, err) + } + } + + invalid := map[string]string{ + "": "must not be empty", + "abc:warp.test": "does not start with", + "@abc:warp.test": "does not start with", + "!": "only a sigil", + "!abc\x00:warp.test": "NUL", + "!" + strings.Repeat("a", 255): "the limit is", + } + for s, want := range invalid { + err := ValidateRoomID(s) + if err == nil { + t.Errorf("ValidateRoomID(%q) was accepted, want an error", s) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("ValidateRoomID(%q) = %q, want it to mention %q", s, err, want) + } + } +} + +func TestServerNameOfRoomID(t *testing.T) { + got, err := ServerNameOfRoomID("!abc:warp.test") + if err != nil { + t.Fatal(err) + } + if got != "warp.test" { + t.Errorf("ServerNameOfRoomID = %q, want warp.test", got) + } + + // A version 12 room ID names no server, and saying so is the point: + // the caller must only ask in the versions where there is an answer. + if _, err := ServerNameOfRoomID("!Nhcu5BS-UMnFX7hBVfVSoXiD7OgH6iRT"); err == nil { + t.Error("ServerNameOfRoomID found a server in a room ID that has none") + } +} + +func TestValidateEventID(t *testing.T) { + valid := []string{"$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg", "$abc:warp.test"} + for _, s := range valid { + if err := ValidateEventID(s); err != nil { + t.Errorf("ValidateEventID(%q) = %v", s, err) + } + } + + invalid := map[string]string{ + "": "must not be empty", + "abc": "does not start with", + "!abc": "does not start with", + "$": "only a sigil", + "$abc\x00": "NUL", + "$" + strings.Repeat("a", 255): "the limit is", + } + for s, want := range invalid { + err := ValidateEventID(s) + if err == nil { + t.Errorf("ValidateEventID(%q) was accepted, want an error", s) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("ValidateEventID(%q) = %q, want it to mention %q", s, err, want) + } + } +} diff --git a/server/internal/matrix/roomversion/roomversion.go b/server/internal/matrix/roomversion/roomversion.go new file mode 100644 index 0000000..8c9ae2e --- /dev/null +++ b/server/internal/matrix/roomversion/roomversion.go @@ -0,0 +1,510 @@ +// Package roomversion is the registry of Matrix room versions and of what +// each one changes. +// +// A room version fixes the rules every server in that room must follow: +// how event IDs are derived, which keys survive a redaction, and which +// authorisation rules apply. Rooms never change version in place, so a +// server that wants to take part in old rooms has to keep every old rule +// set working — the differences are small but they are not optional, and a +// server that gets one wrong corrupts the room's history for everybody. +// +// Rather than branch on the version string in a dozen places, each version +// is described once here as a Rules value, and the event, auth and stateres +// packages ask it what to do. +// +// # Supported versions +// +// Versions 3 to 12 are implemented. Versions 1 and 2 are listed as known +// but unsupported: they carry the event ID inside the event, write +// prev_events and auth_events as [id, hashes] tuples rather than plain +// lists, and use state resolution v1, which is enough of a separate event +// format to be its own piece of work. It is only needed to join rooms +// created before 2018 on other servers, so it waits for federation +// (PLAN.md phase 5) and the registry rejects those rooms clearly until then. +package roomversion + +import ( + "errors" + "fmt" + "slices" + "strings" +) + +// Version is a room version identifier, such as "11". It is an opaque +// string by spec: do not compare it with < or parse it as a number. +type Version string + +// The versions this package knows about. +const ( + V1 Version = "1" + V2 Version = "2" + V3 Version = "3" + V4 Version = "4" + V5 Version = "5" + V6 Version = "6" + V7 Version = "7" + V8 Version = "8" + V9 Version = "9" + V10 Version = "10" + V11 Version = "11" + V12 Version = "12" +) + +// Default is the version `createRoom` uses when the client does not ask for +// one. +// +// The spec says servers SHOULD default to 12, but also asks them to stay on +// 11 for a while after the 1.16 release so that clients and bridges catch +// up (MSC4304). We follow that advice: 12 is implemented and a client can +// ask for it, but a plain createRoom still makes an 11 room. +const Default = V11 + +// Errors from Get. They are distinct because the answers differ: an +// unknown version is a client typo or a room from the future, while an +// unsupported one is a gap in this server. +var ( + // ErrUnknownVersion means no such room version exists, as far as this + // build of Warp knows. + ErrUnknownVersion = errors.New("unknown room version") + + // ErrUnsupportedVersion means the version exists but Warp cannot take + // part in rooms that use it. + ErrUnsupportedVersion = errors.New("unsupported room version") +) + +// EventIDFormat says how the event ID of an event is arrived at. +type EventIDFormat int + +const ( + // EventIDFormatExplicit carries the ID in the event's event_id + // property, as "$random:server" (room versions 1 and 2). + EventIDFormatExplicit EventIDFormat = iota + + // EventIDFormatSHA256 computes the ID from the reference hash, in + // unpadded standard base64 (room version 3). + EventIDFormatSHA256 + + // EventIDFormatSHA256URLSafe computes the ID from the reference hash, + // in URL-safe unpadded base64 (room version 4 and later). The change + // was made because an ID containing '/' cannot be put in a path. + EventIDFormatSHA256URLSafe +) + +// StateResVersion says which state resolution algorithm a room uses. +type StateResVersion int + +const ( + // StateResV1 is the original algorithm, used only by room version 1. + StateResV1 StateResVersion = iota + + // StateResV2 is the algorithm from room version 2 onwards. + StateResV2 + + // StateResV2Subgraph is state resolution v2 as amended by room version + // 12: iterative auth checks start from an empty state map, and the full + // conflicted set also contains the conflicted state subgraph. Both + // changes exist to make state resets rarer. + StateResV2Subgraph +) + +// Rules is everything that varies between room versions. A zero Rules is +// not usable; get one from Get or MustGet. +type Rules struct { + // Version is the identifier these rules belong to. + Version Version + + // Supported reports whether Warp can take part in rooms of this + // version. Get refuses to return rules that are not supported, so a + // Rules in hand is always supported; the field exists so that Known + // can report the full picture. + Supported bool + + // EventIDFormat says where an event's ID comes from. + EventIDFormat EventIDFormat + + // StateRes says which state resolution algorithm applies. + StateRes StateResVersion + + // StrictCanonicalJSON makes a malformed number or a non-UTF-8 string a + // hard rejection rather than something to tolerate (version 6+). In + // older rooms such events exist and still have to be processed. + StrictCanonicalJSON bool + + // EnforceSigningKeyValidity requires a signing key's valid_until_ts to + // be at least the event's origin_server_ts (version 5+). + EnforceSigningKeyValidity bool + + // --- Authorisation --- + + // AliasesEventAuth gives m.room.aliases its own authorisation rule, + // under which any server could edit its own alias list (versions 1 to + // 5). Version 6 dropped it, and with it the ability to put another + // server's aliases in a room. + AliasesEventAuth bool + + // CreateEventRequiresCreator rejects an m.room.create event whose + // content has no creator property (versions 1 to 10). Version 11 + // removed the property, since it only ever repeated sender. + CreateEventRequiresCreator bool + + // KnockJoinRule enables the knock membership and join rule (version 7+). + KnockJoinRule bool + + // RestrictedJoinRule enables the restricted join rule, where + // membership of another room grants entry, authorised by a server that + // can see that membership (version 8+). + RestrictedJoinRule bool + + // KnockRestrictedJoinRule enables knock_restricted, the two combined + // (version 10+). + KnockRestrictedJoinRule bool + + // RequireIntegerPowerLevels rejects a power level given as a string + // (version 10+). Earlier versions accepted "50" and parsed it, which + // meant two servers could disagree about what a value meant. + RequireIntegerPowerLevels bool + + // RoomIDIsCreateEventID makes the room ID the create event's own ID + // with a '!' sigil instead of '$', so it carries no domain. The create + // event has no room_id of its own (version 12+). + RoomIDIsCreateEventID bool + + // CreateEventExcludedFromAuthEvents keeps m.room.create out of every + // event's auth_events, because the room ID now identifies it (version + // 12+). + CreateEventExcludedFromAuthEvents bool + + // CreatorsHaveInfinitePower gives the room's creators a power level + // above any that can be written down, and forbids naming them in + // m.room.power_levels (version 12+). + CreatorsHaveInfinitePower bool + + // AdditionalCreators allows m.room.create content to name further + // creators alongside the sender (version 12+). + AdditionalCreators bool + + // --- Redaction --- + + // RedactionProtectsLegacyKeys keeps the top-level prev_state, origin + // and membership properties through a redaction (versions 1 to 10). + // Version 11 stopped protecting them: none was load-bearing, and + // membership in particular let a redaction preserve a claim the + // content no longer backed up. + RedactionProtectsLegacyKeys bool + + // RedactionKeepsWholeCreateContent keeps all of an m.room.create + // event's content (version 11+). Before that only creator survived, + // which threw away the room version — the one thing a server needs to + // read the rest of the room. + RedactionKeepsWholeCreateContent bool + + // RedactsInContent puts the target of an m.room.redaction event in + // content.redacts rather than at the top level (version 11+). The + // property moved so that the redaction algorithm, and therefore the + // event's hashes and signatures, would cover it at all. + RedactsInContent bool + + // RedactionKeepsRedacts keeps content.redacts on an m.room.redaction + // event (version 11+), where the property moved so that it would be + // covered by hashes and signatures at all. + RedactionKeepsRedacts bool + + // RedactionKeepsPowerLevelsInvite keeps content.invite on an + // m.room.power_levels event (version 11+). + RedactionKeepsPowerLevelsInvite bool + + // RedactionKeepsThirdPartyInviteSigned keeps + // content.third_party_invite.signed on an m.room.member event + // (version 11+), which is what proves a third-party invite was real. + RedactionKeepsThirdPartyInviteSigned bool + + // RedactionKeepsJoinAuthorisedViaUsersServer keeps + // content.join_authorised_via_users_server on an m.room.member event + // (version 9+). Version 8 redacted it, which made a redacted join in a + // restricted room fail to authorise for ever after. + RedactionKeepsJoinAuthorisedViaUsersServer bool + + // RedactionKeepsJoinRulesAllow keeps content.allow on an + // m.room.join_rules event (version 8+). + RedactionKeepsJoinRulesAllow bool + + // RedactionKeepsAliases keeps content.aliases on an m.room.aliases + // event (versions 1 to 5). + RedactionKeepsAliases bool +} + +// String returns the version identifier. +func (r Rules) String() string { return string(r.Version) } + +// versions is the registry, in the order Known reports them. +// +// Each entry is written out in full rather than derived from the one before +// it. It is more lines, but a reader can see what a single version does +// without replaying the whole history, and a new version cannot change an +// old one by accident. +var versions = []Rules{ + { + Version: V1, + Supported: false, + EventIDFormat: EventIDFormatExplicit, + StateRes: StateResV1, + AliasesEventAuth: true, + CreateEventRequiresCreator: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsAliases: true, + }, + { + Version: V2, + Supported: false, + EventIDFormat: EventIDFormatExplicit, + StateRes: StateResV2, + AliasesEventAuth: true, + CreateEventRequiresCreator: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsAliases: true, + }, + { + Version: V3, + Supported: true, + EventIDFormat: EventIDFormatSHA256, + StateRes: StateResV2, + AliasesEventAuth: true, + CreateEventRequiresCreator: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsAliases: true, + }, + { + Version: V4, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + AliasesEventAuth: true, + CreateEventRequiresCreator: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsAliases: true, + }, + { + Version: V5, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + EnforceSigningKeyValidity: true, + AliasesEventAuth: true, + CreateEventRequiresCreator: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsAliases: true, + }, + { + Version: V6, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + CreateEventRequiresCreator: true, + + RedactionProtectsLegacyKeys: true, + }, + { + Version: V7, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + CreateEventRequiresCreator: true, + KnockJoinRule: true, + + RedactionProtectsLegacyKeys: true, + }, + { + Version: V8, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + CreateEventRequiresCreator: true, + KnockJoinRule: true, + RestrictedJoinRule: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsJoinRulesAllow: true, + }, + { + Version: V9, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + CreateEventRequiresCreator: true, + KnockJoinRule: true, + RestrictedJoinRule: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsJoinRulesAllow: true, + RedactionKeepsJoinAuthorisedViaUsersServer: true, + }, + { + Version: V10, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + CreateEventRequiresCreator: true, + KnockJoinRule: true, + RestrictedJoinRule: true, + KnockRestrictedJoinRule: true, + RequireIntegerPowerLevels: true, + + RedactionProtectsLegacyKeys: true, + RedactionKeepsJoinRulesAllow: true, + RedactionKeepsJoinAuthorisedViaUsersServer: true, + }, + { + Version: V11, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + KnockJoinRule: true, + RestrictedJoinRule: true, + KnockRestrictedJoinRule: true, + RequireIntegerPowerLevels: true, + RedactsInContent: true, + + RedactionKeepsWholeCreateContent: true, + RedactionKeepsRedacts: true, + RedactionKeepsPowerLevelsInvite: true, + RedactionKeepsThirdPartyInviteSigned: true, + RedactionKeepsJoinRulesAllow: true, + RedactionKeepsJoinAuthorisedViaUsersServer: true, + }, + { + Version: V12, + Supported: true, + EventIDFormat: EventIDFormatSHA256URLSafe, + StateRes: StateResV2Subgraph, + StrictCanonicalJSON: true, + EnforceSigningKeyValidity: true, + KnockJoinRule: true, + RestrictedJoinRule: true, + KnockRestrictedJoinRule: true, + RequireIntegerPowerLevels: true, + + RedactsInContent: true, + RoomIDIsCreateEventID: true, + CreateEventExcludedFromAuthEvents: true, + CreatorsHaveInfinitePower: true, + AdditionalCreators: true, + + RedactionKeepsWholeCreateContent: true, + RedactionKeepsRedacts: true, + RedactionKeepsPowerLevelsInvite: true, + RedactionKeepsThirdPartyInviteSigned: true, + RedactionKeepsJoinRulesAllow: true, + RedactionKeepsJoinAuthorisedViaUsersServer: true, + }, +} + +// index is versions keyed by identifier, built once at startup. +var index = func() map[Version]Rules { + m := make(map[Version]Rules, len(versions)) + for _, v := range versions { + m[v.Version] = v + } + return m +}() + +// Get returns the rules for a room version. +// +// It fails for a version Warp does not support, so every Rules in +// circulation is one we can actually act on. +func Get(v Version) (Rules, error) { + rules, ok := index[v] + if !ok { + return Rules{}, fmt.Errorf("%w: %q", ErrUnknownVersion, v) + } + if !rules.Supported { + return Rules{}, fmt.Errorf("%w: %q", ErrUnsupportedVersion, v) + } + return rules, nil +} + +// MustGet is Get for a version fixed in the source, such as a test or +// Default. It panics rather than returning an error, because a typo in a +// constant is a bug to fix, not a condition to handle. +func MustGet(v Version) Rules { + rules, err := Get(v) + if err != nil { + panic(err) + } + return rules +} + +// IsSupported reports whether Warp can take part in rooms of this version. +// It is the test behind the authorisation rule that rejects an +// m.room.create event naming a version we do not recognise. +func IsSupported(v Version) bool { + return index[v].Supported +} + +// Supported lists the versions Warp can take part in, oldest first. It +// feeds the room_versions capability. +func Supported() []Version { + out := make([]Version, 0, len(versions)) + for _, v := range versions { + if v.Supported { + out = append(out, v.Version) + } + } + return out +} + +// Known lists every version in the registry, supported or not. +func Known() []Rules { + return slices.Clone(versions) +} + +// MaxVersionLength is the spec's cap on a room version identifier. +const MaxVersionLength = 32 + +// Validate checks a string against the room version grammar: 1 to 32 +// characters of lowercase letters, digits, '.' and '-'. +// +// It is for strings arriving from a client or another server, where the +// answer "that is not a room version at all" is more useful than "unknown +// room version". Note that a valid identifier need not be one we know. +func Validate(s string) error { + if s == "" { + return errors.New("room version must not be empty") + } + if n := len([]rune(s)); n > MaxVersionLength { + return fmt.Errorf("room version is %d characters, the limit is %d", n, MaxVersionLength) + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '-': + default: + return fmt.Errorf("room version %q contains %q; only lowercase letters, digits, '.' and '-' are allowed", s, r) + } + } + return nil +} + +// SupportedString renders Supported for a log line or an error message. +func SupportedString() string { + out := make([]string, 0, len(versions)) + for _, v := range Supported() { + out = append(out, string(v)) + } + return strings.Join(out, ", ") +} diff --git a/server/internal/matrix/roomversion/roomversion_test.go b/server/internal/matrix/roomversion/roomversion_test.go new file mode 100644 index 0000000..d0873d7 --- /dev/null +++ b/server/internal/matrix/roomversion/roomversion_test.go @@ -0,0 +1,234 @@ +package roomversion + +import ( + "errors" + "strings" + "testing" +) + +func TestGet(t *testing.T) { + t.Run("a supported version", func(t *testing.T) { + rules, err := Get(V11) + if err != nil { + t.Fatalf("Get(11): %v", err) + } + if rules.Version != V11 { + t.Errorf("Version = %q, want 11", rules.Version) + } + if !rules.Supported { + t.Error("the rules we were handed say they are not supported") + } + }) + + t.Run("a known but unsupported version", func(t *testing.T) { + _, err := Get(V1) + if !errors.Is(err, ErrUnsupportedVersion) { + t.Errorf("Get(1) = %v, want ErrUnsupportedVersion", err) + } + }) + + t.Run("an unknown version", func(t *testing.T) { + _, err := Get("99") + if !errors.Is(err, ErrUnknownVersion) { + t.Errorf("Get(99) = %v, want ErrUnknownVersion", err) + } + }) +} + +func TestMustGetPanicsOnAnUnsupportedVersion(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("MustGet did not panic on an unsupported version") + } + }() + MustGet(V1) +} + +func TestDefaultIsSupported(t *testing.T) { + if _, err := Get(Default); err != nil { + t.Fatalf("the default room version %q is not usable: %v", Default, err) + } + if !IsSupported(Default) { + t.Errorf("IsSupported(%q) = false", Default) + } +} + +func TestSupported(t *testing.T) { + supported := Supported() + if len(supported) == 0 { + t.Fatal("no room versions are supported") + } + for _, v := range supported { + if !IsSupported(v) { + t.Errorf("Supported() lists %q but IsSupported says no", v) + } + } + for _, v := range []Version{V1, V2} { + if IsSupported(v) { + t.Errorf("IsSupported(%q) = true, but the event format for it is not implemented", v) + } + } + if s := SupportedString(); !strings.Contains(s, string(Default)) { + t.Errorf("SupportedString() = %q, want it to include the default", s) + } +} + +func TestKnownListsEveryVersion(t *testing.T) { + known := Known() + if len(known) < 12 { + t.Errorf("Known() returned %d versions, want every version from 1 to 12", len(known)) + } + // Known hands out a copy, so a caller cannot edit the registry. + known[0].Supported = !known[0].Supported + if Known()[0].Supported == known[0].Supported { + t.Error("Known() returns the registry itself rather than a copy") + } +} + +// TestFlagsAreMonotonic checks the flags that a later room version never +// takes back. Each of these was added once and kept, so a version where +// one is off after a version where it is on is a transcription error in +// the table — the kind of mistake that would otherwise only show up as a +// room whose history stops verifying. +func TestFlagsAreMonotonic(t *testing.T) { + flags := map[string]func(Rules) bool{ + "StrictCanonicalJSON": func(r Rules) bool { return r.StrictCanonicalJSON }, + "EnforceSigningKeyValidity": func(r Rules) bool { return r.EnforceSigningKeyValidity }, + "KnockJoinRule": func(r Rules) bool { return r.KnockJoinRule }, + "RestrictedJoinRule": func(r Rules) bool { return r.RestrictedJoinRule }, + "KnockRestrictedJoinRule": func(r Rules) bool { return r.KnockRestrictedJoinRule }, + "RequireIntegerPowerLevels": func(r Rules) bool { return r.RequireIntegerPowerLevels }, + "RedactsInContent": func(r Rules) bool { return r.RedactsInContent }, + "RedactionKeepsWholeCreateContent": func(r Rules) bool { return r.RedactionKeepsWholeCreateContent }, + "RedactionKeepsRedacts": func(r Rules) bool { return r.RedactionKeepsRedacts }, + "RedactionKeepsPowerLevelsInvite": func(r Rules) bool { return r.RedactionKeepsPowerLevelsInvite }, + "RedactionKeepsThirdPartyInviteSigned": func(r Rules) bool { return r.RedactionKeepsThirdPartyInviteSigned }, + "RedactionKeepsJoinAuthorisedViaUsersServer": func(r Rules) bool { return r.RedactionKeepsJoinAuthorisedViaUsersServer }, + "RedactionKeepsJoinRulesAllow": func(r Rules) bool { return r.RedactionKeepsJoinRulesAllow }, + "RoomIDIsCreateEventID": func(r Rules) bool { return r.RoomIDIsCreateEventID }, + "CreateEventExcludedFromAuthEvents": func(r Rules) bool { return r.CreateEventExcludedFromAuthEvents }, + "CreatorsHaveInfinitePower": func(r Rules) bool { return r.CreatorsHaveInfinitePower }, + "AdditionalCreators": func(r Rules) bool { return r.AdditionalCreators }, + } + + known := Known() + for name, get := range flags { + seen := false + for _, rules := range known { + if get(rules) { + seen = true + continue + } + if seen { + t.Errorf("%s is on before room version %s and off again at it", name, rules.Version) + break + } + } + if !seen { + t.Errorf("%s is never on in any room version", name) + } + } +} + +// TestFlagsThatWereDropped covers the other direction: the four pieces of +// behaviour a later room version took away. +func TestFlagsThatWereDropped(t *testing.T) { + cases := []struct { + name string + get func(Rules) bool + lastOnFor Version + }{ + {"AliasesEventAuth", func(r Rules) bool { return r.AliasesEventAuth }, V5}, + {"RedactionKeepsAliases", func(r Rules) bool { return r.RedactionKeepsAliases }, V5}, + {"CreateEventRequiresCreator", func(r Rules) bool { return r.CreateEventRequiresCreator }, V10}, + {"RedactionProtectsLegacyKeys", func(r Rules) bool { return r.RedactionProtectsLegacyKeys }, V10}, + } + + known := Known() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + past := false + for _, rules := range known { + want := !past + if got := tc.get(rules); got != want { + t.Errorf("room version %s has %s = %v, want %v", rules.Version, tc.name, got, want) + } + if rules.Version == tc.lastOnFor { + past = true + } + } + }) + } +} + +func TestEventIDFormatByVersion(t *testing.T) { + cases := map[Version]EventIDFormat{ + V1: EventIDFormatExplicit, + V2: EventIDFormatExplicit, + V3: EventIDFormatSHA256, + V4: EventIDFormatSHA256URLSafe, + V11: EventIDFormatSHA256URLSafe, + V12: EventIDFormatSHA256URLSafe, + } + for _, rules := range Known() { + want, ok := cases[rules.Version] + if !ok { + continue + } + if rules.EventIDFormat != want { + t.Errorf("room version %s has event ID format %d, want %d", rules.Version, rules.EventIDFormat, want) + } + } +} + +func TestStateResByVersion(t *testing.T) { + cases := map[Version]StateResVersion{ + V1: StateResV1, + V2: StateResV2, + V11: StateResV2, + V12: StateResV2Subgraph, + } + for _, rules := range Known() { + want, ok := cases[rules.Version] + if !ok { + continue + } + if rules.StateRes != want { + t.Errorf("room version %s uses state resolution %d, want %d", rules.Version, rules.StateRes, want) + } + } +} + +func TestValidate(t *testing.T) { + valid := []string{"1", "11", "12", "org.example.room-version.1", "a.b-c", strings.Repeat("a", 32)} + for _, s := range valid { + if err := Validate(s); err != nil { + t.Errorf("Validate(%q) = %v, want it to be accepted", s, err) + } + } + + invalid := map[string]string{ + "": "empty", + strings.Repeat("a", 33): "the limit is", + "V11": "contains", + "11_beta": "contains", + "org.example/room": "contains", + "room version 11": "contains", + } + for s, want := range invalid { + err := Validate(s) + if err == nil { + t.Errorf("Validate(%q) was accepted, want an error", s) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("Validate(%q) = %q, want it to mention %q", s, err, want) + } + } +} + +func TestRulesString(t *testing.T) { + if got := MustGet(V11).String(); got != "11" { + t.Errorf("String() = %q, want 11", got) + } +} diff --git a/server/internal/matrix/signing/json.go b/server/internal/matrix/signing/json.go new file mode 100644 index 0000000..cb9ba81 --- /dev/null +++ b/server/internal/matrix/signing/json.go @@ -0,0 +1,195 @@ +package signing + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "maps" + "strings" + + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" +) + +// Signatures is the signatures block of a signed JSON object: server name, +// then key ID ("ed25519:abc"), then the unpadded base64 signature. +// +// An object can carry several, and adding one must not invalidate the +// others — which is why signatures, along with unsigned, is left out of the +// bytes a signature covers. +type Signatures map[string]map[string]string + +// Get returns one signature, and whether it was there. +func (s Signatures) Get(serverName, keyID string) (string, bool) { + sig, ok := s[serverName][keyID] + return sig, ok +} + +// Errors from verification. ErrNoSignature and ErrBadSignature are +// separate because only the second one is evidence of anything: a missing +// signature usually means we have not fetched the right key yet, while a +// wrong one means the event has been tampered with. +var ( + // ErrNoSignature means the object carries no signature from the server + // and key we were asked about. + ErrNoSignature = errors.New("no signature from that server and key") + + // ErrBadSignature means a signature was present and did not verify. + ErrBadSignature = errors.New("signature does not verify") +) + +// SigningBytes returns the bytes a signature over msg covers: the object +// with its signatures and unsigned properties removed, in canonical JSON. +// +// Both ends of a signature have to agree on these bytes exactly, so this +// is the single place the rule is written down. +func SigningBytes(msg []byte) ([]byte, error) { + obj, err := decodeObject(msg) + if err != nil { + return nil, err + } + delete(obj, "signatures") + delete(obj, "unsigned") + return canonicaljson.Marshal(obj) +} + +// SignJSON returns msg with a signature from serverName added. +// +// Signatures already on the object are kept; one from the same server and +// key ID is replaced. The result is canonical JSON, so signing is +// idempotent in the sense that matters: signing the output again produces +// the same bytes. +func SignJSON(serverName string, key *KeyPair, msg []byte) ([]byte, error) { + if serverName == "" { + return nil, errors.New("sign JSON: server name must not be empty") + } + obj, err := decodeObject(msg) + if err != nil { + return nil, err + } + + existing, err := signaturesOf(obj) + if err != nil { + return nil, err + } + + // The signature covers the object without signatures or unsigned, so + // take them out before canonicalising and put them back afterwards. + unsigned, hasUnsigned := obj["unsigned"] + delete(obj, "signatures") + delete(obj, "unsigned") + + signed, err := canonicaljson.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("sign JSON: %w", err) + } + sig := ed25519.Sign(key.Private, signed) + + if existing == nil { + existing = Signatures{} + } + if existing[serverName] == nil { + existing[serverName] = map[string]string{} + } + existing[serverName][key.KeyID()] = base64.RawStdEncoding.EncodeToString(sig) + + obj["signatures"], err = json.Marshal(existing) + if err != nil { + return nil, fmt.Errorf("sign JSON: encode signatures: %w", err) + } + if hasUnsigned { + obj["unsigned"] = unsigned + } + return canonicaljson.Marshal(obj) +} + +// VerifyJSON checks msg for a valid signature from serverName made with +// keyID, using the public key given. +// +// keyID is the full identifier including the algorithm, as it appears in +// the signatures block: "ed25519:abc123". +func VerifyJSON(serverName, keyID string, pub ed25519.PublicKey, msg []byte) error { + obj, err := decodeObject(msg) + if err != nil { + return err + } + sigs, err := signaturesOf(obj) + if err != nil { + return err + } + encoded, ok := sigs.Get(serverName, keyID) + if !ok { + return fmt.Errorf("%w: %s %s", ErrNoSignature, serverName, keyID) + } + sig, err := decodeBase64(encoded) + if err != nil { + return fmt.Errorf("%w: %s %s: decode: %w", ErrBadSignature, serverName, keyID, err) + } + if len(sig) != ed25519.SignatureSize { + return fmt.Errorf("%w: %s %s: signature is %d bytes, want %d", + ErrBadSignature, serverName, keyID, len(sig), ed25519.SignatureSize) + } + + delete(obj, "signatures") + delete(obj, "unsigned") + signed, err := canonicaljson.Marshal(obj) + if err != nil { + return err + } + if !ed25519.Verify(pub, signed, sig) { + return fmt.Errorf("%w: %s %s", ErrBadSignature, serverName, keyID) + } + return nil +} + +// ExtractSignatures returns the signatures block of a signed object. It is +// how a caller finds out which keys it needs before it has any of them. +func ExtractSignatures(msg []byte) (Signatures, error) { + obj, err := decodeObject(msg) + if err != nil { + return nil, err + } + return signaturesOf(obj) +} + +// SplitKeyID splits "ed25519:abc123" into its algorithm and identifier. +func SplitKeyID(keyID string) (algorithm, id string, ok bool) { + algorithm, id, ok = strings.Cut(keyID, ":") + if !ok || algorithm == "" || id == "" { + return "", "", false + } + return algorithm, id, true +} + +// decodeObject parses a JSON object into its properties, leaving each value +// as the bytes it arrived as. +func decodeObject(msg []byte) (map[string]json.RawMessage, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(msg, &obj); err != nil { + return nil, fmt.Errorf("parse signed JSON: %w", err) + } + if obj == nil { + return nil, errors.New("parse signed JSON: value is null, not an object") + } + return obj, nil +} + +// signaturesOf decodes the signatures property, which may be absent. +func signaturesOf(obj map[string]json.RawMessage) (Signatures, error) { + raw, ok := obj["signatures"] + if !ok { + return nil, nil + } + var sigs Signatures + if err := json.Unmarshal(raw, &sigs); err != nil { + return nil, fmt.Errorf("parse signatures: %w", err) + } + // Clone so that a caller mutating the result cannot reach back into + // the object we parsed. + out := make(Signatures, len(sigs)) + for server, keys := range sigs { + out[server] = maps.Clone(keys) + } + return out, nil +} diff --git a/server/internal/matrix/signing/json_test.go b/server/internal/matrix/signing/json_test.go new file mode 100644 index 0000000..468cb53 --- /dev/null +++ b/server/internal/matrix/signing/json_test.go @@ -0,0 +1,256 @@ +package signing + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "strings" + "testing" +) + +// The signing key, server name and key ID from the spec appendices, +// section "Cryptographic Test Vectors". Every implementation that signs +// JSON should produce the signatures below from them. +const ( + specSeed = "YJDBA9Xnr2sVqXD9Vj7XVUnmFZcZrlw8Md7kMW+3XA1" + specServerName = "domain" + specKeyID = "1" +) + +func specKey(t *testing.T) *KeyPair { + t.Helper() + seed, err := base64.RawStdEncoding.DecodeString(specSeed) + if err != nil { + t.Fatalf("decode the spec seed: %v", err) + } + priv := ed25519.NewKeyFromSeed(seed) + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + t.Fatal("unexpected public key type") + } + return &KeyPair{ID: specKeyID, Private: priv, Public: pub} +} + +// TestSpecJSONSigningVectors signs the two objects from the spec and +// compares the result byte for byte. +func TestSpecJSONSigningVectors(t *testing.T) { + key := specKey(t) + cases := []struct { + name string + in string + want string + }{ + { + name: "empty object", + in: `{}`, + want: `{"signatures":{"domain":{"ed25519:1":"K8280/U9SSy9IVtjBuVeLr+HpOB4BQFWbg+UZaADMtTdGYI7Geitb76LTrr5QV/7Xg4ahLwYGYZzuHGZKM5ZAQ"}}}`, + }, + { + name: "object with data", + in: `{"one":1,"two":"Two"}`, + want: `{"one":1,"signatures":{"domain":{"ed25519:1":"KqmLSbO39/Bzb0QIYE82zqLwsA+PDzYIpIRA2sRQ4sL53+sN6/fpNSoqE7BP7vBZhG6kYdD13EIMJpvhJI+6Bw"}},"two":"Two"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := SignJSON(specServerName, key, []byte(tc.in)) + if err != nil { + t.Fatalf("SignJSON: %v", err) + } + if string(got) != tc.want { + t.Errorf("SignJSON() =\n %s\nwant\n %s", got, tc.want) + } + if err := VerifyJSON(specServerName, "ed25519:"+specKeyID, key.Public, got); err != nil { + t.Errorf("VerifyJSON on our own signature: %v", err) + } + }) + } +} + +// TestSignatureIgnoresUnsignedAndSignatures is the property that lets a +// server add a signature, or a timestamp under unsigned, to an object +// without invalidating what is already there. +func TestSignatureIgnoresUnsignedAndSignatures(t *testing.T) { + key := specKey(t) + keyID := "ed25519:" + specKeyID + + signed, err := SignJSON(specServerName, key, []byte(`{"a":1,"unsigned":{"age":5}}`)) + if err != nil { + t.Fatalf("SignJSON: %v", err) + } + if !strings.Contains(string(signed), `"unsigned":{"age":5}`) { + t.Errorf("unsigned was dropped from the signed object: %s", signed) + } + + // Rewrite unsigned and add another server's signature; ours must + // still verify. + tampered := strings.Replace(string(signed), `"age":5`, `"age":9999`, 1) + if err := VerifyJSON(specServerName, keyID, key.Public, []byte(tampered)); err != nil { + t.Errorf("changing unsigned broke the signature: %v", err) + } + + other, err := Generate("other") + if err != nil { + t.Fatal(err) + } + countersigned, err := SignJSON("second.example", other, signed) + if err != nil { + t.Fatalf("SignJSON for a second server: %v", err) + } + if err := VerifyJSON(specServerName, keyID, key.Public, countersigned); err != nil { + t.Errorf("a second signature broke the first: %v", err) + } + if err := VerifyJSON("second.example", other.KeyID(), other.Public, countersigned); err != nil { + t.Errorf("the second signature does not verify: %v", err) + } +} + +// TestSignJSONReplacesOurOwnSignature checks that signing twice with the +// same key leaves one signature, not two entries fighting over the slot. +func TestSignJSONReplacesOurOwnSignature(t *testing.T) { + key := specKey(t) + once, err := SignJSON(specServerName, key, []byte(`{"a":1}`)) + if err != nil { + t.Fatal(err) + } + twice, err := SignJSON(specServerName, key, once) + if err != nil { + t.Fatal(err) + } + if string(once) != string(twice) { + t.Errorf("signing twice changed the object:\n %s\n %s", once, twice) + } +} + +func TestVerifyJSONRejects(t *testing.T) { + key := specKey(t) + keyID := "ed25519:" + specKeyID + signed, err := SignJSON(specServerName, key, []byte(`{"a":1}`)) + if err != nil { + t.Fatal(err) + } + + t.Run("content changed", func(t *testing.T) { + tampered := strings.Replace(string(signed), `"a":1`, `"a":2`, 1) + err := VerifyJSON(specServerName, keyID, key.Public, []byte(tampered)) + if !errors.Is(err, ErrBadSignature) { + t.Errorf("error = %v, want ErrBadSignature", err) + } + }) + + t.Run("wrong key", func(t *testing.T) { + other, err := Generate("other") + if err != nil { + t.Fatal(err) + } + err = VerifyJSON(specServerName, keyID, other.Public, signed) + if !errors.Is(err, ErrBadSignature) { + t.Errorf("error = %v, want ErrBadSignature", err) + } + }) + + t.Run("no signature from that server", func(t *testing.T) { + err := VerifyJSON("somebody.else", keyID, key.Public, signed) + if !errors.Is(err, ErrNoSignature) { + t.Errorf("error = %v, want ErrNoSignature", err) + } + }) + + t.Run("no signature with that key ID", func(t *testing.T) { + err := VerifyJSON(specServerName, "ed25519:other", key.Public, signed) + if !errors.Is(err, ErrNoSignature) { + t.Errorf("error = %v, want ErrNoSignature", err) + } + }) + + t.Run("signature is not base64", func(t *testing.T) { + broken := strings.Replace(string(signed), `"ed25519:1":"`, `"ed25519:1":"!!!`, 1) + err := VerifyJSON(specServerName, keyID, key.Public, []byte(broken)) + if !errors.Is(err, ErrBadSignature) { + t.Errorf("error = %v, want ErrBadSignature", err) + } + }) + + t.Run("signature is the wrong length", func(t *testing.T) { + short, err := SignJSON(specServerName, key, []byte(`{"a":1}`)) + if err != nil { + t.Fatal(err) + } + broken := strings.Replace(string(short), `"ed25519:1":"`, `"ed25519:1":"AAAA`, 1) + err = VerifyJSON(specServerName, keyID, key.Public, []byte(broken)) + if !errors.Is(err, ErrBadSignature) { + t.Errorf("error = %v, want ErrBadSignature", err) + } + }) +} + +func TestSigningBytes(t *testing.T) { + got, err := SigningBytes([]byte(`{"b":2,"signatures":{"x":{}},"unsigned":{"age":1},"a":1}`)) + if err != nil { + t.Fatalf("SigningBytes: %v", err) + } + if want := `{"a":1,"b":2}`; string(got) != want { + t.Errorf("SigningBytes() = %s, want %s", got, want) + } +} + +func TestExtractSignatures(t *testing.T) { + sigs, err := ExtractSignatures([]byte(`{"signatures":{"a.example":{"ed25519:1":"sig"}}}`)) + if err != nil { + t.Fatalf("ExtractSignatures: %v", err) + } + if got, ok := sigs.Get("a.example", "ed25519:1"); !ok || got != "sig" { + t.Errorf("Get() = %q, %v; want \"sig\", true", got, ok) + } + if _, ok := sigs.Get("b.example", "ed25519:1"); ok { + t.Error("Get found a signature for a server that has none") + } + + none, err := ExtractSignatures([]byte(`{}`)) + if err != nil { + t.Fatalf("ExtractSignatures on an unsigned object: %v", err) + } + if none != nil { + t.Errorf("ExtractSignatures() = %v, want nil for an unsigned object", none) + } +} + +func TestSplitKeyID(t *testing.T) { + cases := []struct { + in string + algorithm string + id string + ok bool + }{ + {"ed25519:abc", "ed25519", "abc", true}, + {"ed25519:", "", "", false}, + {":abc", "", "", false}, + {"ed25519", "", "", false}, + {"", "", "", false}, + } + for _, tc := range cases { + algorithm, id, ok := SplitKeyID(tc.in) + if algorithm != tc.algorithm || id != tc.id || ok != tc.ok { + t.Errorf("SplitKeyID(%q) = %q, %q, %v; want %q, %q, %v", + tc.in, algorithm, id, ok, tc.algorithm, tc.id, tc.ok) + } + } +} + +func TestSignJSONRejectsBadInput(t *testing.T) { + key := specKey(t) + for _, tc := range []struct{ name, in string }{ + {"not an object", `[1,2]`}, + {"null", `null`}, + {"not JSON", `{`}, + {"a float somewhere", `{"a":1.5}`}, + } { + if _, err := SignJSON(specServerName, key, []byte(tc.in)); err == nil { + t.Errorf("SignJSON(%s) succeeded, want an error", tc.name) + } + } + if _, err := SignJSON("", key, []byte(`{}`)); err == nil { + t.Error("SignJSON with no server name succeeded, want an error") + } +} diff --git a/server/internal/matrix/signing/key.go b/server/internal/matrix/signing/key.go index f9b1bc9..cd485af 100644 --- a/server/internal/matrix/signing/key.go +++ b/server/internal/matrix/signing/key.go @@ -172,8 +172,16 @@ func parseKeyLine(line, path string) (*KeyPair, error) { return &KeyPair{ID: parts[1], Private: priv, Public: pub}, nil } -// decodeBase64 accepts both padded and unpadded standard base64, because -// keys get copied between implementations by hand. +// DecodeBase64 decodes a base64 value from the Matrix wire format. +// +// Matrix writes these unpadded, but the spec asks decoders to accept +// padding too wherever they can: keys, hashes and signatures get copied +// between implementations by hand, and rejecting a trailing "=" would turn +// a harmless transcription into a verification failure. +func DecodeBase64(s string) ([]byte, error) { + return decodeBase64(s) +} + func decodeBase64(s string) ([]byte, error) { if strings.HasSuffix(s, "=") { return base64.StdEncoding.DecodeString(s) diff --git a/server/internal/matrix/stateres/sort.go b/server/internal/matrix/stateres/sort.go new file mode 100644 index 0000000..af9cbc8 --- /dev/null +++ b/server/internal/matrix/stateres/sort.go @@ -0,0 +1,245 @@ +package stateres + +import ( + "container/heap" + "context" + "math" + "slices" + "strings" + + "github.com/dariuszjaniak/warp/server/internal/matrix/event" +) + +// reverseTopologicalPowerSort orders events earliest to latest, so that +// nothing comes before an event that authorises it, and where there is a +// choice the sender with the most power goes first. +// +// It is Kahn's algorithm with a priority queue: at every step, of the +// events whose auth events have all been placed, the one that sorts +// smallest is taken. Tie-breaking on timestamp and then on event ID makes +// the result identical on every server, which is the whole requirement — +// two servers that ordered these differently would resolve the state +// differently and the room would fork. +func (r *resolver) reverseTopologicalPowerSort(ctx context.Context, events map[string]*event.Event) ([]*event.Event, error) { + // Edges run from an auth event to the event it authorises, counted + // only within the set being sorted: a reference out of the set has + // already happened as far as this ordering goes. + authorises := make(map[string][]string, len(events)) + remaining := make(map[string]int, len(events)) + for id, e := range events { + for _, authID := range e.AuthEventIDs() { + if _, inSet := events[authID]; !inSet { + continue + } + authorises[authID] = append(authorises[authID], id) + remaining[id]++ + } + } + + keys := make(map[string]sortKey, len(events)) + for id, e := range events { + level, err := r.senderPowerLevel(ctx, e) + if err != nil { + return nil, err + } + keys[id] = sortKey{rank: level, ts: e.OriginServerTS(), id: id} + } + + ready := &sortHeap{keys: keys} + for id := range events { + if remaining[id] == 0 { + ready.ids = append(ready.ids, id) + } + } + heap.Init(ready) + + out := make([]*event.Event, 0, len(events)) + for ready.Len() > 0 { + id := heap.Pop(ready).(string) + out = append(out, events[id]) + for _, next := range authorises[id] { + remaining[next]-- + if remaining[next] == 0 { + heap.Push(ready, next) + } + } + } + + // A cycle in the auth graph would leave events unplaced. It cannot + // happen in a well-formed room, where an event's auth events always + // precede it, but a malicious server can send anything and quietly + // dropping state events would be worse than an arbitrary but stable + // order. + if len(out) < len(events) { + leftover := make([]*event.Event, 0, len(events)-len(out)) + for id, e := range events { + if remaining[id] > 0 { + leftover = append(leftover, e) + } + } + sortByKey(leftover, keys) + out = append(out, leftover...) + } + return out, nil +} + +// mainlineSort orders events by where they hang off the room's succession +// of power levels events. +// +// The mainline is that succession: the given power levels event, the one +// in its auth events, and so on back to the first one in the room. An +// event's position is how far back along it the event depends, and an +// event depending on an older link sorts first. The effect is that a +// change made under an older set of power levels is applied before one +// made under a newer set, so the newer set has the last word. +func (r *resolver) mainlineSort(ctx context.Context, events map[string]*event.Event, powerLevels *event.Event) ([]*event.Event, error) { + // index maps each power levels event in the mainline to its place, + // 0 being the most recent. + index := map[string]int{} + for current := powerLevels; current != nil; { + if _, loop := index[current.EventID()]; loop { + break // a malformed room; stop rather than walk for ever + } + index[current.EventID()] = len(index) + next, err := r.powerLevelsInAuthEvents(ctx, current) + if err != nil { + return nil, err + } + current = next + } + + keys := make(map[string]sortKey, len(events)) + positions := map[string]int{} + for id, e := range events { + position, err := r.mainlinePosition(ctx, e, index, positions) + if err != nil { + return nil, err + } + keys[id] = sortKey{rank: int64(position), ts: e.OriginServerTS(), id: id} + } + + out := make([]*event.Event, 0, len(events)) + for _, e := range events { + out = append(out, e) + } + sortByKey(out, keys) + return out, nil +} + +// noMainlinePosition is the spec's "infinity": an event with no power +// levels event behind it at all. It sorts before everything else, because +// it depends on a point in the room older than any power change. +const noMainlinePosition = math.MaxInt + +// mainlinePosition returns how far back in the mainline an event depends. +// +// The event itself is not consulted, only the power levels events it +// reaches through its auth events — so a power levels event's own position +// is its predecessor's. That reading never comes up in practice: every +// power levels event is a power event, and power events are all settled in +// the first pass, so none of them reaches this sort. +// +// Each event cites at most one power levels event, so the walk follows a +// single path and every event along it shares the answer. +func (r *resolver) mainlinePosition(ctx context.Context, e *event.Event, index map[string]int, memo map[string]int) (int, error) { + var walked []string + current := e + for { + id := current.EventID() + if position, ok := memo[id]; ok { + return record(walked, position, memo), nil + } + next, err := r.powerLevelsInAuthEvents(ctx, current) + if err != nil { + return 0, err + } + if next == nil { + return record(append(walked, id), noMainlinePosition, memo), nil + } + if position, ok := index[next.EventID()]; ok { + return record(append(walked, id), position, memo), nil + } + walked = append(walked, id) + current = next + } +} + +// record caches one position for every event on the path that led to it. +func record(walked []string, position int, memo map[string]int) int { + for _, id := range walked { + memo[id] = position + } + return position +} + +// powerLevelsInAuthEvents returns the m.room.power_levels event an event +// cites, if it cites one. +func (r *resolver) powerLevelsInAuthEvents(ctx context.Context, e *event.Event) (*event.Event, error) { + for _, id := range e.AuthEventIDs() { + cited, err := r.get(ctx, id) + if err != nil { + return nil, err + } + if cited == nil { + continue + } + if sk, isState := cited.StateKey(); isState && sk == "" && cited.Type() == event.TypePowerLevels { + return cited, nil + } + } + return nil, nil +} + +// sortKey is what both orderings compare. +// +// rank holds the sender's power level for the reverse topological power +// ordering and the mainline position for the mainline ordering. The two +// orderings are otherwise the same comparison, and in both of them a +// higher rank sorts first: more power goes first, and so does a position +// further back in the mainline. +type sortKey struct { + rank int64 + ts int64 + id string +} + +func compareKeys(a, b sortKey) int { + switch { + case a.rank > b.rank: + return -1 + case a.rank < b.rank: + return 1 + case a.ts < b.ts: + return -1 + case a.ts > b.ts: + return 1 + default: + // Event IDs are unique, so the order is total: there is never a + // tie left for a server to break its own way. + return strings.Compare(a.id, b.id) + } +} + +func sortByKey(events []*event.Event, keys map[string]sortKey) { + slices.SortFunc(events, func(a, b *event.Event) int { + return compareKeys(keys[a.EventID()], keys[b.EventID()]) + }) +} + +// sortHeap is the priority queue Kahn's algorithm picks from. +type sortHeap struct { + ids []string + keys map[string]sortKey +} + +func (h *sortHeap) Len() int { return len(h.ids) } +func (h *sortHeap) Less(i, j int) bool { return compareKeys(h.keys[h.ids[i]], h.keys[h.ids[j]]) < 0 } +func (h *sortHeap) Swap(i, j int) { h.ids[i], h.ids[j] = h.ids[j], h.ids[i] } +func (h *sortHeap) Push(x any) { h.ids = append(h.ids, x.(string)) } + +func (h *sortHeap) Pop() any { + last := len(h.ids) - 1 + out := h.ids[last] + h.ids = h.ids[:last] + return out +} diff --git a/server/internal/matrix/stateres/stateres.go b/server/internal/matrix/stateres/stateres.go new file mode 100644 index 0000000..c256f9c --- /dev/null +++ b/server/internal/matrix/stateres/stateres.go @@ -0,0 +1,609 @@ +// Package stateres implements Matrix state resolution, version 2. +// +// Every event in a room names the events it follows, so a room is a graph +// rather than a line, and two servers can quite legitimately extend it at +// the same time. State resolution is the function that takes the differing +// views of the room's state at those points and produces one answer that +// every server computes identically. It is the reason two people who were +// both made moderator in the same second do not end up with the room in +// two different shapes. +// +// The algorithm is in the room version 2 specification, and room version +// 12 amends it. In outline: +// +// 1. State that every view agrees on is set aside as unconflicted. +// 2. The events in dispute, plus the events that authorise them, are +// sorted so that whoever had the most power goes first. +// 3. They are replayed through the authorisation rules; anything the +// rules refuse is dropped. +// 4. What is left is sorted again, this time by how far back in the +// room's history of power changes it hangs, and replayed. +// 5. The unconflicted state is put back over the top. +// +// Sorting by power first is what makes the result resistant to a +// misbehaving server: an event that grants power can only win if the event +// that granted *it* power already won. +// +// The algorithm is deliberately total — it always produces a state — so +// nothing here rejects an event. Events the rules refuse are simply not +// applied. +package stateres + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/dariuszjaniak/warp/server/internal/matrix/auth" + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" +) + +// EventProvider supplies the events state resolution needs beyond the +// state sets themselves: the auth chains behind them, and the chain of +// power levels events used for the second sort. +// +// Events this server does not have, and events it rejected, must be left +// out of the result rather than reported as an error. That is not +// leniency: the spec requires rejected auth events to take no part in +// resolution, and leaving them out here is how a caller says so. +type EventProvider interface { + EventsByID(ctx context.Context, ids []string) ([]*event.Event, error) +} + +// Resolve resolves a set of room states into one. +// +// stateSets are the differing views of the room's state — in the usual +// case, the state after each of an event's prev_events. The result is the +// state they resolve to. +// +// Resolving fewer than two states is not an error and does no work: there +// is nothing to disagree about. +func Resolve(ctx context.Context, rules roomversion.Rules, stateSets [][]*event.Event, provider EventProvider) (auth.State, error) { + switch len(stateSets) { + case 0: + return auth.State{}, nil + case 1: + return auth.NewState(stateSets[0]), nil + } + + r := &resolver{ + rules: rules, + provider: provider, + events: map[string]*event.Event{}, + missing: map[string]bool{}, + powerOf: map[string]int64{}, + } + maps := make([]auth.State, len(stateSets)) + for i, set := range stateSets { + maps[i] = auth.NewState(set) + for _, e := range set { + r.events[e.EventID()] = e + } + } + return r.resolve(ctx, maps) +} + +// Sorted returns a state map as a slice in a deterministic order, for +// storing or comparing. +func Sorted(state auth.State) []*event.Event { + keys := make([]auth.StateKey, 0, len(state)) + for k := range state { + keys = append(keys, k) + } + slices.SortFunc(keys, func(a, b auth.StateKey) int { + if a.Type != b.Type { + return strings.Compare(a.Type, b.Type) + } + return strings.Compare(a.StateKey, b.StateKey) + }) + out := make([]*event.Event, 0, len(keys)) + for _, k := range keys { + out = append(out, state[k]) + } + return out +} + +// resolver holds the caches one resolution needs. It is single-use. +type resolver struct { + rules roomversion.Rules + provider EventProvider + + // events is every event fetched so far, and missing records the IDs + // that resolved to nothing, so that a dangling reference is looked up + // once rather than on every walk that reaches it. + events map[string]*event.Event + missing map[string]bool + + // powerOf caches the sender's power level per event, which the first + // sort asks for repeatedly. + powerOf map[string]int64 +} + +func (r *resolver) resolve(ctx context.Context, stateSets []auth.State) (auth.State, error) { + unconflicted, conflicted := separate(stateSets) + + // The full conflicted set is wider than the events actually in + // dispute: it also takes in the auth difference, the events that only + // *some* of the views rely on. Without them, a view could smuggle in a + // power grant by keeping it out of the state it declares. + full := map[string]*event.Event{} + for id, e := range conflicted { + full[id] = e + } + authDiff, err := r.authDifference(ctx, stateSets) + if err != nil { + return nil, err + } + for id, e := range authDiff { + full[id] = e + } + + // Room version 12 adds the events that lie between conflicted events + // in the auth graph, which the auth difference can miss. Both this and + // the empty starting state below exist to make state resets rarer. + if r.rules.StateRes == roomversion.StateResV2Subgraph { + subgraph, err := r.conflictedSubgraph(ctx, conflicted) + if err != nil { + return nil, err + } + for id, e := range subgraph { + full[id] = e + } + } + + // Step 1: the power events in the full conflicted set, together with + // whatever in their auth chains is also in dispute. + power, err := r.powerEventsToSort(ctx, full) + if err != nil { + return nil, err + } + sortedPower, err := r.reverseTopologicalPowerSort(ctx, power) + if err != nil { + return nil, err + } + + // Step 2. Room version 12 starts from an empty state rather than the + // unconflicted one: an event then has to be authorised by the events + // it actually cites, which stops an unconflicted event from propping + // up a conflicted one that nothing else supports. + start := unconflicted + if r.rules.StateRes == roomversion.StateResV2Subgraph { + start = auth.State{} + } + partial, err := r.iterativeAuthChecks(ctx, start, sortedPower) + if err != nil { + return nil, err + } + + // Step 3: everything else, ordered by where it hangs off the room's + // history of power levels events. + rest := make(map[string]*event.Event, len(full)) + for id, e := range full { + if _, done := power[id]; !done { + rest[id] = e + } + } + sortedRest, err := r.mainlineSort(ctx, rest, partial.PowerLevels()) + if err != nil { + return nil, err + } + + // Step 4. + resolved, err := r.iterativeAuthChecks(ctx, partial, sortedRest) + if err != nil { + return nil, err + } + + // Step 5: the unconflicted state was never in doubt, so it wins + // outright — including over anything the replay above put in its place. + for k, e := range unconflicted { + resolved[k] = e + } + return resolved, nil +} + +// separate splits the state sets into the state they all agree on and the +// state they do not. +// +// A key missing from one view counts as a disagreement, not as a blank: +// one server thinking there is no topic and another thinking there is one +// is exactly the kind of conflict this algorithm exists to settle. +func separate(stateSets []auth.State) (auth.State, map[string]*event.Event) { + unconflicted := auth.State{} + conflicted := map[string]*event.Event{} + + keys := map[auth.StateKey]bool{} + for _, set := range stateSets { + for k := range set { + keys[k] = true + } + } + + for k := range keys { + var distinct []*event.Event + seen := map[string]bool{} + absent := false + for _, set := range stateSets { + e, ok := set[k] + if !ok { + absent = true + continue + } + if !seen[e.EventID()] { + seen[e.EventID()] = true + distinct = append(distinct, e) + } + } + if len(distinct) == 1 && !absent { + unconflicted[k] = distinct[0] + continue + } + for _, e := range distinct { + conflicted[e.EventID()] = e + } + } + return unconflicted, conflicted +} + +// authDifference returns the events that are in some views' auth chains but +// not in all of them. +func (r *resolver) authDifference(ctx context.Context, stateSets []auth.State) (map[string]*event.Event, error) { + chains := make([]map[string]bool, len(stateSets)) + for i, set := range stateSets { + ids := make([]string, 0, len(set)) + for _, e := range set { + ids = append(ids, e.EventID()) + } + chain, err := r.authChain(ctx, ids) + if err != nil { + return nil, err + } + chains[i] = chain + } + + diff := map[string]*event.Event{} + for i, chain := range chains { + for id := range chain { + inAll := true + for j, other := range chains { + if i != j && !other[id] { + inAll = false + break + } + } + if inAll { + continue + } + if e := r.events[id]; e != nil { + diff[id] = e + } + } + } + return diff, nil +} + +// authChain returns the transitive closure of the auth_events links from +// the given events, not including the events themselves. +func (r *resolver) authChain(ctx context.Context, ids []string) (map[string]bool, error) { + chain := map[string]bool{} + queue := make([]string, 0, len(ids)) + for _, id := range ids { + e, err := r.get(ctx, id) + if err != nil { + return nil, err + } + if e != nil { + queue = append(queue, e.AuthEventIDs()...) + } + } + + for len(queue) > 0 { + id := queue[len(queue)-1] + queue = queue[:len(queue)-1] + if chain[id] { + continue + } + e, err := r.get(ctx, id) + if err != nil { + return nil, err + } + if e == nil { + // A reference we cannot follow. It is not an error: the event + // may have been rejected, or we may simply never have seen it. + continue + } + chain[id] = true + queue = append(queue, e.AuthEventIDs()...) + } + return chain, nil +} + +// conflictedSubgraph returns the events that lie on an auth_events path +// between two conflicted events, which room version 12 adds to the full +// conflicted set. +// +// It is computed in two passes: walk forwards from the conflicted events +// to find everything they reach, recording the edges in reverse, then walk +// those edges backwards from the conflicted events. A node the second walk +// reaches is one that both descends from a conflicted event and leads to +// one, which is what being on a path between two of them means. +func (r *resolver) conflictedSubgraph(ctx context.Context, conflicted map[string]*event.Event) (map[string]*event.Event, error) { + parents := map[string][]string{} + seen := map[string]bool{} + queue := make([]string, 0, len(conflicted)) + for id := range conflicted { + queue = append(queue, id) + seen[id] = true + } + + for len(queue) > 0 { + id := queue[len(queue)-1] + queue = queue[:len(queue)-1] + e, err := r.get(ctx, id) + if err != nil { + return nil, err + } + if e == nil { + continue + } + for _, child := range e.AuthEventIDs() { + parents[child] = append(parents[child], id) + if !seen[child] { + seen[child] = true + queue = append(queue, child) + } + } + } + + subgraph := map[string]*event.Event{} + visited := map[string]bool{} + queue = queue[:0] + for id := range conflicted { + queue = append(queue, id) + visited[id] = true + } + for len(queue) > 0 { + id := queue[len(queue)-1] + queue = queue[:len(queue)-1] + if e := r.events[id]; e != nil { + subgraph[id] = e + } + for _, parent := range parents[id] { + if !visited[parent] { + visited[parent] = true + queue = append(queue, parent) + } + } + } + return subgraph, nil +} + +// powerEventsToSort returns the events the first pass sorts and replays: +// the power events in the full conflicted set, plus anything in their auth +// chains that is also in the full conflicted set. +func (r *resolver) powerEventsToSort(ctx context.Context, full map[string]*event.Event) (map[string]*event.Event, error) { + out := map[string]*event.Event{} + for id, e := range full { + if !isPowerEvent(e) { + continue + } + out[id] = e + chain, err := r.authChain(ctx, []string{id}) + if err != nil { + return nil, err + } + for chainID := range chain { + if e, ok := full[chainID]; ok { + out[chainID] = e + } + } + } + return out, nil +} + +// isPowerEvent reports whether an event could take away somebody's ability +// to do something in the room, which is what makes it worth sorting first. +func isPowerEvent(e *event.Event) bool { + switch e.Type() { + case event.TypePowerLevels, event.TypeJoinRules: + _, isState := e.StateKey() + return isState + case event.TypeMember: + sk, ok := e.StateKey() + if !ok || sk == e.Sender() { + // Leaving or being banned under your own name takes nothing + // from anybody else. + return false + } + membership, err := e.Membership() + if err != nil { + return false + } + return membership == event.MembershipLeave || membership == event.MembershipBan + default: + return false + } +} + +// iterativeAuthChecks replays events through the authorisation rules, +// applying the ones the rules allow and ignoring the rest. +// +// The state each event is judged against is its own auth_events, overlaid +// with whatever the replay has settled so far for the keys the rules will +// read. So an event is authorised by the room as it is being rebuilt, +// falling back to the events it cited for anything not yet decided. +func (r *resolver) iterativeAuthChecks(ctx context.Context, base auth.State, events []*event.Event) (auth.State, error) { + state := make(auth.State, len(base)) + for k, e := range base { + state[k] = e + } + + for _, e := range events { + checkState := auth.State{} + for _, id := range e.AuthEventIDs() { + cited, err := r.get(ctx, id) + if err != nil { + return nil, err + } + if cited == nil { + // Either we do not have it or we rejected it. A rejected + // auth event must not be used, and this is where that + // happens. + continue + } + if sk, ok := cited.StateKey(); ok { + checkState[auth.StateKey{Type: cited.Type(), StateKey: sk}] = cited + } + } + keys, err := auth.AuthEventKeys(e) + if err != nil { + // A malformed event cannot be authorised, which in this + // algorithm means it is skipped rather than fatal. + continue + } + for _, k := range keys { + if current, ok := state[k]; ok { + checkState[k] = current + } + } + + // From room version 12 the create event is in neither the event's + // auth_events nor the keys above, but the rules still need it: it + // says who the creators are, and the room ID is checked against + // it. Every other version has it from auth_events already. + if checkState.Create() == nil { + create, err := r.createEvent(ctx, e) + if err != nil { + return nil, err + } + if create != nil { + checkState[auth.StateKey{Type: event.TypeCreate}] = create + } + } + + if err := auth.Check(ctx, e, checkState, intakeVerified{}); err != nil { + continue + } + if sk, ok := e.StateKey(); ok { + state[auth.StateKey{Type: e.Type(), StateKey: sk}] = e + } + } + return state, nil +} + +// intakeVerified stands in for the signature check the restricted join +// rule asks for. +// +// Every event that reaches state resolution was accepted into the room +// first, and that is where its signatures — including the one from the +// server authorising a restricted join — were checked. Checking it again +// here would mean fetching another server's keys in the middle of an +// algorithm that has to be deterministic, run identically on every server +// and return without doing any I/O it can avoid. Synapse draws the line in +// the same place. +// +// The consequence is that intake must not be skipped: an event inserted +// into storage without a signature check would be authorised here as +// though it had one. +type intakeVerified struct{} + +func (intakeVerified) VerifyEventSignature(context.Context, string, *event.Event) error { return nil } + +// createEvent returns the room's m.room.create event, as seen from one +// event in it. +// +// Up to room version 11 every event cites it in auth_events. From version +// 12 it is deliberately left out, because the room ID is its event ID with +// a different sigil — so it is looked up from there instead, which is the +// same guarantee by a shorter route. +func (r *resolver) createEvent(ctx context.Context, e *event.Event) (*event.Event, error) { + for _, id := range e.AuthEventIDs() { + cited, err := r.get(ctx, id) + if err != nil { + return nil, err + } + if cited == nil { + continue + } + if sk, isState := cited.StateKey(); isState && sk == "" && cited.Type() == event.TypeCreate { + return cited, nil + } + } + if !r.rules.RoomIDIsCreateEventID { + return nil, nil + } + roomID := e.RoomID() + if len(roomID) < 2 || roomID[0] != '!' { + return nil, nil + } + return r.get(ctx, "$"+roomID[1:]) +} + +// get returns an event by ID, from the cache or from the provider. +func (r *resolver) get(ctx context.Context, id string) (*event.Event, error) { + if e, ok := r.events[id]; ok { + return e, nil + } + if r.missing[id] { + return nil, nil + } + fetched, err := r.provider.EventsByID(ctx, []string{id}) + if err != nil { + return nil, fmt.Errorf("state resolution: fetch %s: %w", id, err) + } + for _, e := range fetched { + r.events[e.EventID()] = e + } + if e, ok := r.events[id]; ok { + return e, nil + } + r.missing[id] = true + return nil, nil +} + +// senderPowerLevel returns the power level of an event's sender, judged +// from the event's own auth_events rather than from the room as it is now. +// +// Judging it from the event's own view is the point: it is what the sender +// could do at the time, which is the only thing every server can agree on +// after the fact. +func (r *resolver) senderPowerLevel(ctx context.Context, e *event.Event) (int64, error) { + if level, ok := r.powerOf[e.EventID()]; ok { + return level, nil + } + + var plEvent *event.Event + for _, id := range e.AuthEventIDs() { + cited, err := r.get(ctx, id) + if err != nil { + return 0, err + } + if cited == nil { + continue + } + if sk, isState := cited.StateKey(); isState && sk == "" && cited.Type() == event.TypePowerLevels { + plEvent = cited + break + } + } + createEvent, err := r.createEvent(ctx, e) + if err != nil { + return 0, err + } + + level := int64(0) + if createEvent != nil || plEvent != nil { + pl, err := auth.ResolvePowerLevels(plEvent, createEvent, r.rules) + if err != nil { + // A power levels event we cannot read gives its sender + // nothing, which keeps the sort total. + level = 0 + } else { + level = pl.UserLevel(e.Sender()) + } + } + r.powerOf[e.EventID()] = level + return level, nil +} diff --git a/server/internal/matrix/stateres/stateres_test.go b/server/internal/matrix/stateres/stateres_test.go new file mode 100644 index 0000000..bbe18af --- /dev/null +++ b/server/internal/matrix/stateres/stateres_test.go @@ -0,0 +1,746 @@ +package stateres + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/dariuszjaniak/warp/server/internal/matrix/auth" + "github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson" + "github.com/dariuszjaniak/warp/server/internal/matrix/event" + "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" + "github.com/dariuszjaniak/warp/server/internal/matrix/signing" +) + +const ( + alice = "@alice:example.com" + bob = "@bob:example.com" + charlie = "@charlie:example.com" + evelyn = "@evelyn:example.com" + zara = "@zara:example.com" +) + +func ptr[T any](v T) *T { return &v } + +// spec describes one event to put in the graph. Events are identified by a +// short name so that a test reads like the scenario it describes. +type spec struct { + name string + sender string + typ string + stateKey *string + content map[string]any +} + +// dag builds a room whose shape the test controls, and serves it back as +// an EventProvider. +// +// The model is the one Synapse's state resolution tests use, which is also +// where several of the scenarios below come from: events are listed with +// the paths that connect them, each event's auth_events are taken from the +// state resolved at its own prev_events, and timestamps increase in +// declaration order so that no outcome rests on an event ID tie-break. +type dag struct { + t *testing.T + rules roomversion.Rules + key *signing.KeyPair + roomID string + + byName map[string]*event.Event + byID map[string]*event.Event + after map[string]auth.State + depth map[string]int64 + clock int64 +} + +func (d *dag) EventsByID(_ context.Context, ids []string) ([]*event.Event, error) { + out := make([]*event.Event, 0, len(ids)) + for _, id := range ids { + if e := d.byID[id]; e != nil { + out = append(out, e) + } + } + return out, nil +} + +// newDAG starts a room: Alice creates it, joins, takes power level 100, +// makes it public, and Bob, Charlie, Evelyn and Zara join. Everything +// after that is up to the test. +func newDAG(t *testing.T, v roomversion.Version) *dag { + t.Helper() + key, err := signing.Generate("test") + if err != nil { + t.Fatalf("generate a signing key: %v", err) + } + d := &dag{ + t: t, + rules: roomversion.MustGet(v), + key: key, + byName: map[string]*event.Event{}, + byID: map[string]*event.Event{}, + after: map[string]auth.State{}, + depth: map[string]int64{}, + clock: 1700000000000, + } + if !d.rules.RoomIDIsCreateEventID { + d.roomID = "!room:example.com" + } + + create := map[string]any{"room_version": string(v)} + if d.rules.CreateEventRequiresCreator { + create["creator"] = alice + } + + join := map[string]any{"membership": "join"} + initial := []spec{ + {name: "CREATE", sender: alice, typ: event.TypeCreate, stateKey: ptr(""), content: create}, + {name: "IMA", sender: alice, typ: event.TypeMember, stateKey: ptr(alice), content: join}, + {name: "IPOWER", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100}}}, + {name: "IJR", sender: alice, typ: event.TypeJoinRules, stateKey: ptr(""), + content: map[string]any{"join_rule": auth.JoinRulePublic}}, + {name: "IMB", sender: bob, typ: event.TypeMember, stateKey: ptr(bob), content: join}, + {name: "IMC", sender: charlie, typ: event.TypeMember, stateKey: ptr(charlie), content: join}, + {name: "IMZ", sender: zara, typ: event.TypeMember, stateKey: ptr(zara), content: join}, + {name: "START", sender: zara, typ: "m.room.message", content: map[string]any{}}, + } + // A linear chain: each initial event follows the one before it. + edges := make([][]string, 0, len(initial)) + for i := 1; i < len(initial); i++ { + edges = append(edges, []string{initial[i].name, initial[i-1].name}) + } + d.build(initial, edges) + return d +} + +// build adds events to the graph. +// +// edges are paths listed newest first, so ["END", "MB", "PA"] means PA is +// followed by MB, which is followed by END. specs must be in dependency +// order; the one place that is not obvious, build says so. +func (d *dag) build(specs []spec, edges [][]string) { + d.t.Helper() + + prevs := map[string][]string{} + for _, path := range edges { + for i := 0; i < len(path)-1; i++ { + child, parent := path[i], path[i+1] + if !contains(prevs[child], parent) { + prevs[child] = append(prevs[child], parent) + } + } + } + + for _, s := range specs { + state := auth.State{} + var prevIDs []string + var depth int64 + + parents := prevs[s.name] + stateSets := make([][]*event.Event, 0, len(parents)) + for _, parent := range parents { + e, ok := d.byName[parent] + if !ok { + d.t.Fatalf("%s names %s as a previous event, but %s has not been built yet", + s.name, parent, parent) + } + prevIDs = append(prevIDs, e.EventID()) + if dep := d.depth[parent]; dep > depth { + depth = dep + } + var set []*event.Event + for _, stateEvent := range d.after[parent] { + set = append(set, stateEvent) + } + stateSets = append(stateSets, set) + } + + switch len(stateSets) { + case 0: + case 1: + state = d.after[parents[0]] + default: + // A fork's own events have to be authorised against something, + // and state resolution is what the room's own servers would + // use here too. + resolved, err := Resolve(context.Background(), d.rules, stateSets, d) + if err != nil { + d.t.Fatalf("resolve the state before %s: %v", s.name, err) + } + state = resolved + } + + content := mustJSON(d.t, s.content) + selected, err := auth.SelectAuthEvents(d.rules, s.typ, s.sender, s.stateKey, content, state) + if err != nil { + d.t.Fatalf("select auth events for %s: %v", s.name, err) + } + authIDs := make([]string, 0, len(selected)) + for _, e := range selected { + authIDs = append(authIDs, e.EventID()) + } + + b := event.Builder{ + Type: s.typ, + Sender: s.sender, + StateKey: s.stateKey, + Content: content, + PrevEvents: prevIDs, + AuthEvents: authIDs, + Depth: depth + 1, + } + derivesRoomID := d.rules.RoomIDIsCreateEventID && s.typ == event.TypeCreate + if !derivesRoomID { + b.RoomID = d.roomID + } + + d.clock++ + e, err := b.Build(time.UnixMilli(d.clock), d.key, d.rules) + if err != nil { + d.t.Fatalf("build %s: %v", s.name, err) + } + if s.typ == event.TypeCreate && d.rules.RoomIDIsCreateEventID { + if d.roomID, err = event.RoomIDOfCreateEvent(e); err != nil { + d.t.Fatalf("derive the room ID: %v", err) + } + } + + d.byName[s.name] = e + d.byID[e.EventID()] = e + d.depth[s.name] = depth + 1 + + next := make(auth.State, len(state)+1) + for k, v := range state { + next[k] = v + } + if sk, ok := e.StateKey(); ok { + next[auth.StateKey{Type: e.Type(), StateKey: sk}] = e + } + d.after[s.name] = next + } +} + +// resolveAt resolves the states after each of a node's previous events, +// which is the state that node would be sent in. +func (d *dag) resolveAt(name string) auth.State { + d.t.Helper() + e, ok := d.byName[name] + if !ok { + d.t.Fatalf("no event named %s", name) + } + var stateSets [][]*event.Event + for _, prevID := range e.PrevEventIDs() { + prevName := d.nameOf(prevID) + var set []*event.Event + for _, stateEvent := range d.after[prevName] { + set = append(set, stateEvent) + } + stateSets = append(stateSets, set) + } + resolved, err := Resolve(context.Background(), d.rules, stateSets, d) + if err != nil { + d.t.Fatalf("resolve at %s: %v", name, err) + } + return resolved +} + +func (d *dag) nameOf(eventID string) string { + for name, e := range d.byName { + if e.EventID() == eventID { + return name + } + } + d.t.Fatalf("no name for event %s", eventID) + return "" +} + +// wantState asserts which named event holds each of the given state keys. +func (d *dag) wantState(state auth.State, want map[auth.StateKey]string) { + d.t.Helper() + for key, wantName := range want { + got, ok := state[key] + if !ok { + d.t.Errorf("(%s, %q) is missing from the resolved state, want %s", key.Type, key.StateKey, wantName) + continue + } + if gotName := d.nameOf(got.EventID()); gotName != wantName { + d.t.Errorf("(%s, %q) resolved to %s, want %s", key.Type, key.StateKey, gotName, wantName) + } + } +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + raw, err := canonicaljson.Marshal(v) + if err != nil { + t.Fatalf("encode test content: %v", err) + } + return raw +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +var ( + powerKey = auth.StateKey{Type: event.TypePowerLevels} + joinRulesKey = auth.StateKey{Type: event.TypeJoinRules} + topicKey = auth.StateKey{Type: "m.room.topic"} +) + +func memberKey(userID string) auth.StateKey { + return auth.StateKey{Type: event.TypeMember, StateKey: userID} +} + +// --- Trivial cases --- + +func TestResolveFewerThanTwoStateSets(t *testing.T) { + d := newDAG(t, roomversion.V11) + + resolved, err := Resolve(context.Background(), d.rules, nil, d) + if err != nil { + t.Fatalf("Resolve with no state sets: %v", err) + } + if len(resolved) != 0 { + t.Errorf("resolving nothing gave %d events, want none", len(resolved)) + } + + var one []*event.Event + for _, e := range d.after["START"] { + one = append(one, e) + } + resolved, err = Resolve(context.Background(), d.rules, [][]*event.Event{one}, d) + if err != nil { + t.Fatalf("Resolve with one state set: %v", err) + } + if len(resolved) != len(one) { + t.Errorf("resolving one state set gave %d events, want %d", len(resolved), len(one)) + } +} + +func TestResolveIdenticalStateSets(t *testing.T) { + d := newDAG(t, roomversion.V11) + var set []*event.Event + for _, e := range d.after["START"] { + set = append(set, e) + } + + resolved, err := Resolve(context.Background(), d.rules, [][]*event.Event{set, set}, d) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if len(resolved) != len(set) { + t.Fatalf("resolved %d events, want %d", len(resolved), len(set)) + } + for _, e := range set { + sk, _ := e.StateKey() + if got := resolved[auth.StateKey{Type: e.Type(), StateKey: sk}]; got != e { + t.Errorf("(%s, %q) changed, though nothing was in conflict", e.Type(), sk) + } + } +} + +// --- Scenarios ported from Synapse's state resolution tests --- + +// TestBanBeatsPowerLevels: Alice bans Bob on one fork while Bob sends a +// power levels event on the other. Bob's event loses, because by the time +// it is replayed he is banned. +func TestBanBeatsPowerLevels(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "MA", sender: alice, typ: event.TypeMember, stateKey: ptr(alice), + content: map[string]any{"membership": "join"}}, + {name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob), + content: map[string]any{"membership": "ban"}}, + {name: "PB", sender: bob, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "MB", "MA", "PA", "START"}, + {"END", "PB", "PA"}, + }) + + d.wantState(d.resolveAt("END"), map[auth.StateKey]string{ + powerKey: "PA", + memberKey(alice): "MA", + memberKey(bob): "MB", + }) +} + +// TestJoinRuleEvasion: Alice makes the room private on one fork while +// Evelyn joins on the other. Evelyn's join loses, so closing a room cannot +// be dodged by joining through an older part of the graph. +func TestJoinRuleEvasion(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "JR", sender: alice, typ: event.TypeJoinRules, stateKey: ptr(""), + content: map[string]any{"join_rule": auth.JoinRulePrivate}}, + {name: "ME", sender: evelyn, typ: event.TypeMember, stateKey: ptr(evelyn), + content: map[string]any{"membership": "join"}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "JR", "START"}, + {"END", "ME", "START"}, + }) + + resolved := d.resolveAt("END") + d.wantState(resolved, map[auth.StateKey]string{joinRulesKey: "JR"}) + if _, ok := resolved[memberKey(evelyn)]; ok { + t.Error("Evelyn's join survived a join rule change to private") + } +} + +// TestTopicAndPowerLevelFork: Alice demotes Bob on one fork; on the other, +// Bob promotes himself back and sets the topic. Alice's demotion wins +// because it was made under power levels Bob's events were not. +func TestTopicAndPowerLevelFork(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + {name: "PA1", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + {name: "PA2", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 0}}}, + {name: "PB", sender: bob, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "T3", sender: bob, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "PA2", "T2", "PA1", "T1", "START"}, + {"END", "T3", "PB", "PA1"}, + }) + + d.wantState(d.resolveAt("END"), map[auth.StateKey]string{ + powerKey: "PA2", + topicKey: "T2", + }) +} + +// TestTopicReset is the known rough edge of state resolution v2, kept here +// because it is behaviour rather than a bug to fix: a fork from before a +// topic was set, merged after the author was banned, loses the topic. The +// ban stands and the power levels stand; only the topic reverts. +func TestTopicReset(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + {name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "T2", sender: bob, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + {name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob), + content: map[string]any{"membership": "ban"}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "MB", "T2", "PA", "T1", "START"}, + {"END", "T1"}, + }) + + d.wantState(d.resolveAt("END"), map[auth.StateKey]string{ + topicKey: "T1", + memberKey(bob): "MB", + powerKey: "PA", + }) +} + +// --- Properties that must hold whatever the scenario --- + +// TestResolutionDoesNotDependOnStateSetOrder is the property that makes +// the algorithm usable at all: two servers that hold the same views in a +// different order must reach the same state. +func TestResolutionDoesNotDependOnStateSetOrder(t *testing.T) { + for _, v := range []roomversion.Version{roomversion.V11, roomversion.V12} { + t.Run(string(v), func(t *testing.T) { + d := newDAG(t, v) + d.build([]spec{ + {name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{bob: 50}}}, + {name: "T1", sender: bob, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "one"}}, + {name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "two"}}, + {name: "MC", sender: alice, typ: event.TypeMember, stateKey: ptr(charlie), + content: map[string]any{"membership": "ban"}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "T1", "PA", "START"}, + {"END", "MC", "T2", "START"}, + }) + + sets := func() [][]*event.Event { + var out [][]*event.Event + for _, name := range []string{"T1", "MC"} { + var set []*event.Event + for _, e := range d.after[name] { + set = append(set, e) + } + out = append(out, set) + } + return out + } + + forwards := sets() + backwards := [][]*event.Event{forwards[1], forwards[0]} + + a, err := Resolve(context.Background(), d.rules, forwards, d) + if err != nil { + t.Fatal(err) + } + b, err := Resolve(context.Background(), d.rules, backwards, d) + if err != nil { + t.Fatal(err) + } + if got, want := describe(d, a), describe(d, b); got != want { + t.Errorf("the order of the state sets changed the result:\n %s\n %s", got, want) + } + }) + } +} + +// TestResolutionIsIdempotent: resolving a state against itself is the +// identity, so repeating the work cannot drift. +func TestResolutionIsIdempotent(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob), + content: map[string]any{"membership": "ban"}}, + {name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "MB", "PA", "START"}, + {"END", "T1", "PA"}, + }) + + once := d.resolveAt("END") + var set []*event.Event + for _, e := range once { + set = append(set, e) + } + twice, err := Resolve(context.Background(), d.rules, [][]*event.Event{set, set}, d) + if err != nil { + t.Fatal(err) + } + if got, want := describe(d, twice), describe(d, once); got != want { + t.Errorf("resolving the result again changed it:\n %s\n %s", got, want) + } +} + +// TestUnconflictedStateAlwaysWins: the last step of the algorithm puts the +// unconflicted state back, so an event nobody disputed cannot be displaced +// by the replay. +func TestUnconflictedStateAlwaysWins(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "one"}}, + {name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "two"}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "T1", "START"}, + {"END", "T2", "START"}, + }) + + resolved := d.resolveAt("END") + // The join rules, the create event and everybody's membership were + // never in dispute and must come through untouched. + for _, name := range []string{"CREATE", "IJR", "IMA", "IMB", "IMC", "IMZ"} { + e := d.byName[name] + sk, _ := e.StateKey() + if got := resolved[auth.StateKey{Type: e.Type(), StateKey: sk}]; got != e { + t.Errorf("%s was displaced, though nothing disputed it", name) + } + } +} + +// TestVersion12ResolvesARoom checks the version 12 path end to end: the +// create event is found through the room ID rather than through +// auth_events, and the amended algorithm still settles a conflict. +func TestVersion12ResolvesARoom(t *testing.T) { + d := newDAG(t, roomversion.V12) + d.build([]spec{ + {name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{bob: 50}}}, + {name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob), + content: map[string]any{"membership": "ban"}}, + {name: "PB", sender: bob, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{bob: 50, charlie: 50}}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "MB", "PA", "START"}, + {"END", "PB", "PA"}, + }) + + resolved := d.resolveAt("END") + d.wantState(resolved, map[auth.StateKey]string{ + powerKey: "PA", + memberKey(bob): "MB", + }) + if resolved[auth.StateKey{Type: event.TypeCreate}] == nil { + t.Error("the create event is missing from the resolved state") + } +} + +// TestMissingEventsAreSkipped: a reference the provider cannot resolve, +// which over federation means an event we never received or one we +// rejected, must not stop resolution. +func TestMissingEventsAreSkipped(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "one"}}, + {name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "two"}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "T1", "START"}, + {"END", "T2", "START"}, + }) + + var sets [][]*event.Event + for _, name := range []string{"T1", "T2"} { + var set []*event.Event + for _, e := range d.after[name] { + set = append(set, e) + } + sets = append(sets, set) + } + + resolved, err := Resolve(context.Background(), d.rules, sets, emptyProvider{}) + if err != nil { + t.Fatalf("Resolve with a provider that knows nothing: %v", err) + } + if len(resolved) == 0 { + t.Error("resolution produced no state at all") + } + // The topic was in conflict and its auth events cannot be fetched, so + // neither candidate can be authorised; what matters is that the + // unconflicted state still comes through. + if resolved[joinRulesKey] == nil { + t.Error("the unconflicted join rules were lost") + } +} + +type emptyProvider struct{} + +func (emptyProvider) EventsByID(context.Context, []string) ([]*event.Event, error) { + return nil, nil +} + +func TestProviderErrorsAreReported(t *testing.T) { + d := newDAG(t, roomversion.V11) + // PA replaces IPOWER, so IPOWER is in the auth chain of the state but + // not in the state itself — which is exactly when resolution has to go + // to the provider. + d.build([]spec{ + {name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}}, + {name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "one"}}, + {name: "T2", sender: bob, typ: "m.room.topic", stateKey: ptr(""), + content: map[string]any{"topic": "two"}}, + {name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}}, + }, [][]string{ + {"END", "T1", "PA", "START"}, + {"END", "T2", "PA"}, + }) + + var sets [][]*event.Event + for _, name := range []string{"T1", "T2"} { + var set []*event.Event + for _, e := range d.after[name] { + set = append(set, e) + } + sets = append(sets, set) + } + + _, err := Resolve(context.Background(), d.rules, sets, failingProvider{}) + if err == nil { + t.Fatal("Resolve succeeded with a provider that always fails") + } + if !strings.Contains(err.Error(), "state resolution") { + t.Errorf("error = %q, want it to say where it came from", err) + } +} + +type failingProvider struct{} + +func (failingProvider) EventsByID(context.Context, []string) ([]*event.Event, error) { + return nil, fmt.Errorf("the database is on fire") +} + +// --- Units --- + +func TestIsPowerEvent(t *testing.T) { + d := newDAG(t, roomversion.V11) + d.build([]spec{ + {name: "PL", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""), + content: map[string]any{"users": map[string]any{alice: 100}}}, + {name: "JR", sender: alice, typ: event.TypeJoinRules, stateKey: ptr(""), + content: map[string]any{"join_rule": auth.JoinRulePublic}}, + {name: "BAN", sender: alice, typ: event.TypeMember, stateKey: ptr(bob), + content: map[string]any{"membership": "ban"}}, + {name: "KICK", sender: alice, typ: event.TypeMember, stateKey: ptr(charlie), + content: map[string]any{"membership": "leave"}}, + {name: "SELF_LEAVE", sender: charlie, typ: event.TypeMember, stateKey: ptr(charlie), + content: map[string]any{"membership": "leave"}}, + {name: "JOIN", sender: evelyn, typ: event.TypeMember, stateKey: ptr(evelyn), + content: map[string]any{"membership": "join"}}, + {name: "INVITE", sender: alice, typ: event.TypeMember, stateKey: ptr(evelyn), + content: map[string]any{"membership": "invite"}}, + {name: "MESSAGE", sender: alice, typ: "m.room.message", content: map[string]any{}}, + {name: "TOPIC", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}}, + }, [][]string{ + {"TOPIC", "MESSAGE", "INVITE", "JOIN", "SELF_LEAVE", "KICK", "BAN", "JR", "PL", "START"}, + }) + + want := map[string]bool{ + "PL": true, "JR": true, "BAN": true, "KICK": true, + "SELF_LEAVE": false, "JOIN": false, "INVITE": false, + "MESSAGE": false, "TOPIC": false, + } + for name, expected := range want { + if got := isPowerEvent(d.byName[name]); got != expected { + t.Errorf("isPowerEvent(%s) = %v, want %v", name, got, expected) + } + } +} + +func TestSorted(t *testing.T) { + d := newDAG(t, roomversion.V11) + sorted := Sorted(d.after["START"]) + if len(sorted) == 0 { + t.Fatal("Sorted returned nothing") + } + for i := 1; i < len(sorted); i++ { + prev, next := sorted[i-1], sorted[i] + prevKey, _ := prev.StateKey() + nextKey, _ := next.StateKey() + if prev.Type() > next.Type() || (prev.Type() == next.Type() && prevKey > nextKey) { + t.Errorf("Sorted is out of order at %d: (%s, %q) before (%s, %q)", + i, prev.Type(), prevKey, next.Type(), nextKey) + } + } +} + +// describe renders a resolved state as a stable string for comparison. +func describe(d *dag, state auth.State) string { + var parts []string + for _, e := range Sorted(state) { + sk, _ := e.StateKey() + parts = append(parts, fmt.Sprintf("(%s,%s)=%s", e.Type(), sk, d.nameOf(e.EventID()))) + } + return strings.Join(parts, " ") +}