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
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>
251 lines
9.7 KiB
Go
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 waits for a room that needs it: joining the rooms of
|
|
// today over federation (PLAN.md phase 5) does not.
|
|
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
|
|
}
|