Files
Warp/server/internal/matrix/roomversion/roomversion.go
T
tajniak81andClaude Opus 5.5 2fbd5f53ce
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 2: the event core
Implements PLAN.md Phase 2: the library every later phase is built on,
as pure code under server/internal/matrix with no HTTP and no storage.

canonicaljson: the canonical JSON encoding, hand-written because
encoding/json escapes <, > and & and accepts floats. Integers only, in
[-(2^53)+1, 2^53-1]; lone surrogate escapes rejected rather than turned
into U+FFFD.

signing: the Signing JSON algorithm alongside the existing key handling,
and one lenient base64 decoder shared by the packages above it.

id: identifier grammars as they appear inside events, deliberately laxer
than userapi's account rules because rooms carry historical user IDs.

roomversion: the registry. Versions 3 to 12 are supported, each written
out in full as a Rules value with the flags the other packages read.
Versions 1 and 2 are known but refused: their event format and state
resolution v1 wait for federation. createRoom will default to 11, as the
spec asks for a while after 1.16, with 12 available.

event: PDU parsing and validation, content and reference hashes, event
IDs, the redaction algorithm for every supported version, signing and
verification, and a Builder.

auth: the authorisation rules in spec order, power levels with their
defaults, v12 creators with infinite power, the auth events selection
algorithm, rule 3's auth_events checks, and the separate test for
whether a redaction applies. Comments use the room version 12 rule
numbering, with the version 11 numbers given where they differ.

stateres: state resolution v2, and version 12's amendment (an empty
starting state and the conflicted state subgraph).

Tested against the spec's own vectors byte for byte: the canonical JSON
examples, both JSON signing vectors and both event signing vectors.
State resolution ports scenarios from Synapse's suite. canonicaljson
and event.Parse have fuzz targets.

The capabilities endpoint now advertises the registry instead of a
hard-coded "11". docs/event-core.md describes the packages and the
known gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:23:35 +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, 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, ", ")
}