// 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, ", ") }