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>
223 lines
6.6 KiB
Go
223 lines
6.6 KiB
Go
package event
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
|
)
|
|
|
|
// Redact returns the event with everything a redaction removes stripped
|
|
// off, leaving the properties the room version protects.
|
|
//
|
|
// The redacted event keeps the same event ID, because the ID is the
|
|
// reference hash of the redacted form in the first place — redacting an
|
|
// event does not rename it, and the signature on it still verifies. That is
|
|
// the whole point of the algorithm: a server can prove a redacted event is
|
|
// genuine without ever having seen its content.
|
|
//
|
|
// Redact says nothing about whether the redaction is *allowed*. That
|
|
// depends on the sender's power level or on their server having sent the
|
|
// original, and belongs to the roomserver along with the decision to apply
|
|
// it at all.
|
|
func (e *Event) Redact() (*Event, error) {
|
|
redacted, err := redactJSON(e.raw, e.rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &Event{rules: e.rules, raw: redacted, id: e.id}
|
|
if err := json.Unmarshal(redacted, &out.fields); err != nil {
|
|
return nil, fmt.Errorf("%w: redacted event: %w", ErrInvalidEvent, err)
|
|
}
|
|
if e.rules.RedactsInContent && out.fields.Type == TypeRedaction {
|
|
var content struct {
|
|
Redacts string `json:"redacts"`
|
|
}
|
|
if err := json.Unmarshal(out.fields.Content, &content); err != nil {
|
|
return nil, fmt.Errorf("%w: redacted m.room.redaction content: %w", ErrInvalidEvent, err)
|
|
}
|
|
out.fields.Redacts = content.Redacts
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// redactJSON applies the redaction algorithm to raw event JSON and returns
|
|
// canonical JSON.
|
|
func redactJSON(raw []byte, rules roomversion.Rules) ([]byte, error) {
|
|
obj, err := decodeObject(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var header struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(raw, &header); err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err)
|
|
}
|
|
|
|
keep := protectedTopLevelKeys(rules)
|
|
for k := range obj {
|
|
if !keep[k] {
|
|
delete(obj, k)
|
|
}
|
|
}
|
|
|
|
content, err := redactContent(header.Type, obj["content"], rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
obj["content"] = content
|
|
|
|
out, err := canonicaljson.Marshal(obj)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: redact: %w", ErrInvalidEvent, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// protectedTopLevelKeys returns the event properties a redaction keeps.
|
|
func protectedTopLevelKeys(rules roomversion.Rules) map[string]bool {
|
|
keep := map[string]bool{
|
|
"event_id": true,
|
|
"type": true,
|
|
"room_id": true,
|
|
"sender": true,
|
|
"state_key": true,
|
|
"content": true,
|
|
"hashes": true,
|
|
"signatures": true,
|
|
"depth": true,
|
|
"prev_events": true,
|
|
"auth_events": true,
|
|
"origin_server_ts": true,
|
|
}
|
|
if rules.RedactionProtectsLegacyKeys {
|
|
// Room versions up to 10 also protected these. None was
|
|
// load-bearing, and the top-level membership in particular let a
|
|
// redacted event keep asserting a membership its content no longer
|
|
// supported, so version 11 stopped protecting them.
|
|
keep["prev_state"] = true
|
|
keep["origin"] = true
|
|
keep["membership"] = true
|
|
}
|
|
return keep
|
|
}
|
|
|
|
// redactContent strips the content of an event down to the keys its type is
|
|
// allowed to keep.
|
|
func redactContent(eventType string, content json.RawMessage, rules roomversion.Rules) (json.RawMessage, error) {
|
|
empty := json.RawMessage("{}")
|
|
if len(content) == 0 {
|
|
return empty, nil
|
|
}
|
|
|
|
// m.room.create keeps all of its content from room version 11, so
|
|
// there is nothing to strip and nothing to re-encode.
|
|
if eventType == TypeCreate && rules.RedactionKeepsWholeCreateContent {
|
|
return content, nil
|
|
}
|
|
|
|
keep := protectedContentKeys(eventType, rules)
|
|
keepsThirdPartyInvite := eventType == TypeMember && rules.RedactionKeepsThirdPartyInviteSigned
|
|
if len(keep) == 0 && !keepsThirdPartyInvite {
|
|
return empty, nil
|
|
}
|
|
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(content, &obj); err != nil {
|
|
return nil, fmt.Errorf("%w: content: %w", ErrInvalidEvent, err)
|
|
}
|
|
for k := range obj {
|
|
if !keep[k] {
|
|
delete(obj, k)
|
|
}
|
|
}
|
|
|
|
if keepsThirdPartyInvite {
|
|
if signed, ok := thirdPartyInviteSigned(content); ok {
|
|
obj["third_party_invite"] = signed
|
|
}
|
|
}
|
|
|
|
out, err := json.Marshal(obj)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: redact content: %w", ErrInvalidEvent, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// protectedContentKeys returns the content keys a redaction keeps for an
|
|
// event type. An unlisted type keeps nothing.
|
|
func protectedContentKeys(eventType string, rules roomversion.Rules) map[string]bool {
|
|
keep := map[string]bool{}
|
|
switch eventType {
|
|
case TypeMember:
|
|
keep["membership"] = true
|
|
if rules.RedactionKeepsJoinAuthorisedViaUsersServer {
|
|
// Without this, a redacted join in a restricted room could
|
|
// never be re-authorised, because the proof that somebody with
|
|
// the power to invite had allowed it was in the key that went
|
|
// away.
|
|
keep["join_authorised_via_users_server"] = true
|
|
}
|
|
case TypeCreate:
|
|
// Before room version 11 only the creator survived, which threw
|
|
// away room_version — the one property a server needs in order to
|
|
// read the rest of the room.
|
|
keep["creator"] = true
|
|
case TypeJoinRules:
|
|
keep["join_rule"] = true
|
|
if rules.RedactionKeepsJoinRulesAllow {
|
|
keep["allow"] = true
|
|
}
|
|
case TypePowerLevels:
|
|
for _, k := range []string{
|
|
"ban", "events", "events_default", "kick",
|
|
"redact", "state_default", "users", "users_default",
|
|
} {
|
|
keep[k] = true
|
|
}
|
|
if rules.RedactionKeepsPowerLevelsInvite {
|
|
keep["invite"] = true
|
|
}
|
|
case TypeHistoryVisibility:
|
|
keep["history_visibility"] = true
|
|
case TypeRedaction:
|
|
if rules.RedactionKeepsRedacts {
|
|
keep["redacts"] = true
|
|
}
|
|
case TypeAliases:
|
|
if rules.RedactionKeepsAliases {
|
|
keep["aliases"] = true
|
|
}
|
|
}
|
|
return keep
|
|
}
|
|
|
|
// thirdPartyInviteSigned returns {"signed": ...} from an m.room.member
|
|
// event's third_party_invite, if it has one.
|
|
//
|
|
// From room version 11 this much of a third-party invite survives
|
|
// redaction: it is the signature chain that proves the invite was issued by
|
|
// an identity server, and an event that loses it can never be authorised
|
|
// again.
|
|
func thirdPartyInviteSigned(content json.RawMessage) (json.RawMessage, bool) {
|
|
var parsed struct {
|
|
ThirdPartyInvite map[string]json.RawMessage `json:"third_party_invite"`
|
|
}
|
|
if err := json.Unmarshal(content, &parsed); err != nil {
|
|
return nil, false
|
|
}
|
|
signed, ok := parsed.ThirdPartyInvite["signed"]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
out, err := json.Marshal(map[string]json.RawMessage{"signed": signed})
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
}
|