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 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>
204 lines
6.3 KiB
Go
204 lines
6.3 KiB
Go
package event
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Builder assembles a new event for this server to send.
|
|
//
|
|
// The caller fills in what the event says; Build fills in what the room
|
|
// version says — where the redaction target goes, whether the create event
|
|
// carries a room ID — then hashes, signs and validates the result. The
|
|
// roomserver (PLAN.md phase 3) is the only thing that should build events,
|
|
// because it is the only thing that knows the right prev_events, depth and
|
|
// auth_events.
|
|
type Builder struct {
|
|
// RoomID is the room the event belongs to. It must be empty for an
|
|
// m.room.create event in a room version where the room ID is derived
|
|
// from that event; use RoomIDOfCreateEvent on the built event to find
|
|
// out what the room ID turned out to be.
|
|
RoomID string
|
|
|
|
// Type is the event type, such as "m.room.message".
|
|
Type string
|
|
|
|
// StateKey makes this a state event when it is non-nil. The empty
|
|
// string is a perfectly good state key, which is why this is a pointer.
|
|
StateKey *string
|
|
|
|
// Sender is the user ID sending the event. Its server name is the one
|
|
// the event is signed as, since an event must be signed by its
|
|
// sender's server.
|
|
Sender string
|
|
|
|
// Content is the event content. An empty Content becomes {}.
|
|
Content json.RawMessage
|
|
|
|
// PrevEvents and AuthEvents are the event IDs this event refers to.
|
|
PrevEvents []string
|
|
AuthEvents []string
|
|
|
|
// Depth is one more than the greatest depth of the prev_events.
|
|
Depth int64
|
|
|
|
// Redacts is the event an m.room.redaction event targets. Build puts
|
|
// it wherever this room version keeps it, so callers do not have to
|
|
// know which versions moved it into the content.
|
|
Redacts string
|
|
|
|
// Unsigned is attached to the event but covered by neither its hashes
|
|
// nor its signatures, so anything here can be changed in transit by
|
|
// any server that passes the event on.
|
|
Unsigned json.RawMessage
|
|
}
|
|
|
|
// Build hashes, signs and validates the event.
|
|
//
|
|
// now becomes origin_server_ts. It is passed in rather than read from the
|
|
// clock so that tests are deterministic and so that a caller sending a
|
|
// batch can stamp them all alike.
|
|
func (b *Builder) Build(now time.Time, key *signing.KeyPair, rules roomversion.Rules) (*Event, error) {
|
|
serverName, err := id.ServerNameOf(b.Sender)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build event: sender: %w", err)
|
|
}
|
|
if b.Type == "" {
|
|
return nil, fmt.Errorf("build event: type is required")
|
|
}
|
|
if b.Depth < 0 {
|
|
return nil, fmt.Errorf("build event: depth %d is negative", b.Depth)
|
|
}
|
|
|
|
obj := map[string]json.RawMessage{}
|
|
set := func(k string, v any) error {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("build event: %s: %w", k, err)
|
|
}
|
|
obj[k] = raw
|
|
return nil
|
|
}
|
|
|
|
content := b.Content
|
|
if len(content) == 0 {
|
|
content = json.RawMessage("{}")
|
|
}
|
|
if b.Redacts != "" {
|
|
if content, err = b.placeRedacts(content, obj, rules); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := b.setRoomID(obj, rules); err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range map[string]any{
|
|
"type": b.Type,
|
|
"sender": b.Sender,
|
|
"depth": b.Depth,
|
|
"origin_server_ts": now.UnixMilli(),
|
|
// prev_events and auth_events are required, and must be [] rather
|
|
// than absent when there are none — which is the case for the
|
|
// create event and for nothing else.
|
|
"prev_events": orEmpty(b.PrevEvents),
|
|
"auth_events": orEmpty(b.AuthEvents),
|
|
} {
|
|
if err := set(k, v); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
obj["content"] = content
|
|
if b.StateKey != nil {
|
|
if err := set("state_key", *b.StateKey); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(b.Unsigned) > 0 {
|
|
obj["unsigned"] = b.Unsigned
|
|
}
|
|
|
|
raw, err := canonicaljson.Marshal(obj)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build event: %w", err)
|
|
}
|
|
signed, err := hashAndSign(raw, rules, serverName, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build event: %w", err)
|
|
}
|
|
// Parse is the one gate every event goes through, built here or
|
|
// received over federation, so a bug in the builder surfaces as a
|
|
// failure to build rather than as an invalid event in a room.
|
|
return Parse(signed, rules)
|
|
}
|
|
|
|
// setRoomID writes the room_id property, or leaves it out for a create
|
|
// event in a room version that derives the room ID from it.
|
|
func (b *Builder) setRoomID(obj map[string]json.RawMessage, rules roomversion.Rules) error {
|
|
if rules.RoomIDIsCreateEventID && b.Type == TypeCreate {
|
|
if b.RoomID != "" {
|
|
return fmt.Errorf("build event: an m.room.create event in a version %s room must not have a room_id; it becomes the room ID",
|
|
rules.Version)
|
|
}
|
|
return nil
|
|
}
|
|
if err := id.ValidateRoomID(b.RoomID); err != nil {
|
|
return fmt.Errorf("build event: room_id: %w", err)
|
|
}
|
|
raw, err := json.Marshal(b.RoomID)
|
|
if err != nil {
|
|
return fmt.Errorf("build event: room_id: %w", err)
|
|
}
|
|
obj["room_id"] = raw
|
|
return nil
|
|
}
|
|
|
|
// placeRedacts puts Redacts where this room version keeps it: in the
|
|
// content from room version 11, at the top level before that.
|
|
func (b *Builder) placeRedacts(content json.RawMessage, obj map[string]json.RawMessage, rules roomversion.Rules) (json.RawMessage, error) {
|
|
if err := id.ValidateEventID(b.Redacts); err != nil {
|
|
return nil, fmt.Errorf("build event: redacts: %w", err)
|
|
}
|
|
if !rules.RedactsInContent {
|
|
raw, err := json.Marshal(b.Redacts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build event: redacts: %w", err)
|
|
}
|
|
obj["redacts"] = raw
|
|
return content, nil
|
|
}
|
|
|
|
var parsed map[string]json.RawMessage
|
|
if err := json.Unmarshal(content, &parsed); err != nil {
|
|
return nil, fmt.Errorf("build event: content: %w", err)
|
|
}
|
|
if parsed == nil {
|
|
parsed = map[string]json.RawMessage{}
|
|
}
|
|
raw, err := json.Marshal(b.Redacts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build event: redacts: %w", err)
|
|
}
|
|
parsed["redacts"] = raw
|
|
out, err := json.Marshal(parsed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build event: content: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// orEmpty turns a nil slice into an empty one, so that it encodes as []
|
|
// rather than null.
|
|
func orEmpty(s []string) []string {
|
|
if s == nil {
|
|
return []string{}
|
|
}
|
|
return s
|
|
}
|