Files
Warp/server/internal/matrix/roomversion/roomversion.go
T
tajniak81andClaude Opus 5.5 6aaf554336
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 5: federation
Implements PLAN.md Phase 5: the Server-Server API. Warp joins rooms on
other servers and lets their users join its rooms, invites across servers
with the invitee's server countersigning, rejects remote invites (locally
if the inviter is unreachable), and exchanges events, typing, receipts and
to-device messages with other homeservers — all behind the runtime switch,
which now starts and stops real outbound traffic.

The protocol lives in internal/federation with no knowledge of rooms:
X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping
the dial address and the TLS name apart, a signed client that refuses
every request while the switch is off or the policy denies the
destination, and a key ring that caches other servers' keys for at most a
week, with optional notaries.

The roomserver takes events from elsewhere through the spec's intake:
signatures and hashes (a hash mismatch keeps the redacted form), the rules
against the event's own auth events, against the state before it — with
/get_missing_events for small gaps and /state_ids for wide ones — and
against the current state, where a failure is a soft failure. Events held
without their state are outliers: a column on roomserver_events, because
the current state of a room joined over federation is almost all
outliers, and excluded from every timeline query. Forks are resolved at
the room's head. Membership across servers is make_join/send_join,
make_leave/send_leave and countersigned invites, in both directions;
m.room.server_acl is enforced on everything another server sends.
History before a remote join is backfilled when /messages pages past it,
stored at negative stream positions.

internal/federationapi is the running component: the inbound endpoints,
and an outbound queue that persists what it owes each destination, sends
it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to
six hours, survives restarts, and drops events persisted while federation
is off rather than delivering a backlog in one burst. Its Module is what
the switch starts and stops.

Also: a TLS listener for port 8448, trusted key servers, key validity and
an outbound CA bundle in the config; per-destination health and backoff
reset in the admin API; remote profiles, aliases and directories in the
Client-Server API; a Complement federation profile. See
docs/federation.md, including its known gaps (no partial-state joins, no
key rotation).

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

511 lines
17 KiB
Go

// 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, which federation (PLAN.md phase 5)
// does not yet do; the registry rejects those rooms clearly until it does.
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, ", ")
}