Files
Warp/server/internal/matrix/event/event.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

251 lines
9.7 KiB
Go

// 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
}