Phase 2: the event core
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
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>
This commit is contained in:
1 parent
6004bb01de
commit
2fbd5f53ce
30 files changed
+8928
-9
No files matched your search
@@ -0,0 +1,203 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
||||
)
|
||||
|
||||
// The signing key from the spec appendices, section "Cryptographic Test
|
||||
// Vectors".
|
||||
const specSeed = "YJDBA9Xnr2sVqXD9Vj7XVUnmFZcZrlw8Md7kMW+3XA1"
|
||||
|
||||
func specKey(t *testing.T) *signing.KeyPair {
|
||||
t.Helper()
|
||||
seed, err := base64.RawStdEncoding.DecodeString(specSeed)
|
||||
if err != nil {
|
||||
t.Fatalf("decode the spec seed: %v", err)
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
pub, ok := priv.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
t.Fatal("unexpected public key type")
|
||||
}
|
||||
return &signing.KeyPair{ID: "1", Private: priv, Public: pub}
|
||||
}
|
||||
|
||||
func testKey(t *testing.T) *signing.KeyPair {
|
||||
t.Helper()
|
||||
key, err := signing.Generate("test")
|
||||
if err != nil {
|
||||
t.Fatalf("generate a signing key: %v", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// TestSpecEventSigningVectors runs the event signing vectors from the spec
|
||||
// appendices. They were produced for the original event format, so they go
|
||||
// through a room version whose redaction rules still protect the top-level
|
||||
// origin and event_id properties — the point of the test is the hashing
|
||||
// and signing, which has not changed since.
|
||||
func TestSpecEventSigningVectors(t *testing.T) {
|
||||
key := specKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V3)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "minimal event",
|
||||
in: `{
|
||||
"room_id": "!x:domain",
|
||||
"sender": "@a:domain",
|
||||
"origin": "domain",
|
||||
"origin_server_ts": 1000000,
|
||||
"signatures": {},
|
||||
"hashes": {},
|
||||
"type": "X",
|
||||
"content": {},
|
||||
"prev_events": [],
|
||||
"auth_events": [],
|
||||
"depth": 3,
|
||||
"unsigned": {"age_ts": 1000000}
|
||||
}`,
|
||||
want: `{"auth_events":[],"content":{},"depth":3,` +
|
||||
`"hashes":{"sha256":"5jM4wQpv6lnBo7CLIghJuHdW+s2CMBJPUOGOC89ncos"},` +
|
||||
`"origin":"domain","origin_server_ts":1000000,"prev_events":[],` +
|
||||
`"room_id":"!x:domain","sender":"@a:domain","signatures":{"domain":{"ed25519:1":` +
|
||||
`"KxwGjPSDEtvnFgU00fwFz+l6d2pJM6XBIaMEn81SXPTRl16AqLAYqfIReFGZlHi5KLjAWbOoMszkwsQma+lYAg"}},` +
|
||||
`"type":"X","unsigned":{"age_ts":1000000}}`,
|
||||
},
|
||||
{
|
||||
name: "event with redactable content",
|
||||
in: `{
|
||||
"content": {"body": "Here is the message content"},
|
||||
"event_id": "$0:domain",
|
||||
"origin": "domain",
|
||||
"origin_server_ts": 1000000,
|
||||
"type": "m.room.message",
|
||||
"room_id": "!r:domain",
|
||||
"sender": "@u:domain",
|
||||
"signatures": {},
|
||||
"unsigned": {"age_ts": 1000000}
|
||||
}`,
|
||||
want: `{"content":{"body":"Here is the message content"},"event_id":"$0:domain",` +
|
||||
`"hashes":{"sha256":"onLKD1bGljeBWQhWZ1kaP9SorVmRQNdN5aM2JYU2n/g"},` +
|
||||
`"origin":"domain","origin_server_ts":1000000,"room_id":"!r:domain",` +
|
||||
`"sender":"@u:domain","signatures":{"domain":{"ed25519:1":` +
|
||||
`"Wm+VzmOUOz08Ds+0NTWb1d4CZrVsJSikkeRxh6aCcUwu6pNC78FunoD7KNWzqFn241eYHYMGCA5McEiVPdhzBA"}},` +
|
||||
`"type":"m.room.message","unsigned":{"age_ts":1000000}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := hashAndSign([]byte(tc.in), rules, "domain", key)
|
||||
if err != nil {
|
||||
t.Fatalf("hashAndSign: %v", err)
|
||||
}
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("hashAndSign() =\n %s\nwant\n %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildEvent is the shorthand the tests below use for a signed event in a
|
||||
// given room version.
|
||||
func buildEvent(t *testing.T, rules roomversion.Rules, key *signing.KeyPair, b Builder) *Event {
|
||||
t.Helper()
|
||||
if b.Sender == "" {
|
||||
b.Sender = "@alice:warp.test"
|
||||
}
|
||||
// A version 12 create event has no room ID: the room ID comes from it.
|
||||
derivesRoomID := rules.RoomIDIsCreateEventID && b.Type == TypeCreate
|
||||
if b.RoomID == "" && !derivesRoomID {
|
||||
b.RoomID = "!room:warp.test"
|
||||
}
|
||||
e, err := b.Build(time.UnixMilli(1700000000000), key, rules)
|
||||
if err != nil {
|
||||
t.Fatalf("Build %s: %v", b.Type, err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestBuildAndParse(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: "m.room.message",
|
||||
Content: json.RawMessage(`{"body":"hello","msgtype":"m.text"}`),
|
||||
PrevEvents: []string{"$abc"},
|
||||
AuthEvents: []string{"$create"},
|
||||
Depth: 7,
|
||||
})
|
||||
|
||||
if !strings.HasPrefix(e.EventID(), "$") {
|
||||
t.Errorf("event ID %q does not start with $", e.EventID())
|
||||
}
|
||||
if e.Type() != "m.room.message" {
|
||||
t.Errorf("Type() = %q", e.Type())
|
||||
}
|
||||
if e.Sender() != "@alice:warp.test" {
|
||||
t.Errorf("Sender() = %q", e.Sender())
|
||||
}
|
||||
if e.RoomID() != "!room:warp.test" {
|
||||
t.Errorf("RoomID() = %q", e.RoomID())
|
||||
}
|
||||
if e.Depth() != 7 {
|
||||
t.Errorf("Depth() = %d", e.Depth())
|
||||
}
|
||||
if e.OriginServerTS() != 1700000000000 {
|
||||
t.Errorf("OriginServerTS() = %d", e.OriginServerTS())
|
||||
}
|
||||
if _, ok := e.StateKey(); ok {
|
||||
t.Error("a message event should have no state key")
|
||||
}
|
||||
if e.IsState() {
|
||||
t.Error("IsState() = true for a message event")
|
||||
}
|
||||
if err := e.VerifyContentHash(); err != nil {
|
||||
t.Errorf("VerifyContentHash: %v", err)
|
||||
}
|
||||
if err := e.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil {
|
||||
t.Errorf("VerifySignature: %v", err)
|
||||
}
|
||||
|
||||
// Re-parsing the event's own JSON must produce the same ID, which is
|
||||
// what makes it safe to store events as JSON and parse them back.
|
||||
again, err := Parse(e.JSON(), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
if again.EventID() != e.EventID() {
|
||||
t.Errorf("re-parsed event ID = %q, want %q", again.EventID(), e.EventID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStateEventWithEmptyStateKey(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
empty := ""
|
||||
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypePowerLevels,
|
||||
StateKey: &empty,
|
||||
Content: json.RawMessage(`{"users":{"@alice:warp.test":100}}`),
|
||||
})
|
||||
sk, ok := e.StateKey()
|
||||
if !ok {
|
||||
t.Fatal("StateKey() reports no state key")
|
||||
}
|
||||
if sk != "" {
|
||||
t.Errorf("StateKey() = %q, want the empty string", sk)
|
||||
}
|
||||
if !e.IsState() {
|
||||
t.Error("IsState() = false for a power levels event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventIDFormatByVersion checks the two encodings of the reference
|
||||
// hash. Room version 3 uses standard base64 and 4 onwards URL-safe, which
|
||||
// is why the same event has two different IDs in the two rooms.
|
||||
func TestEventIDFormatByVersion(t *testing.T) {
|
||||
key := specKey(t)
|
||||
body := Builder{
|
||||
Type: "m.room.message",
|
||||
Sender: "@a:domain",
|
||||
RoomID: "!r:domain",
|
||||
Content: json.RawMessage(`{"body":"ÿþýü"}`),
|
||||
}
|
||||
|
||||
ids := map[roomversion.Version]string{}
|
||||
for _, v := range []roomversion.Version{roomversion.V3, roomversion.V4} {
|
||||
e, err := body.Build(time.UnixMilli(1000000), key, roomversion.MustGet(v))
|
||||
if err != nil {
|
||||
t.Fatalf("Build for version %s: %v", v, err)
|
||||
}
|
||||
ids[v] = e.EventID()
|
||||
}
|
||||
|
||||
// Both encode the same 32-byte hash, so the IDs are the same length
|
||||
// and differ only where the alphabets differ.
|
||||
if len(ids[roomversion.V3]) != len(ids[roomversion.V4]) {
|
||||
t.Errorf("IDs have different lengths: %q and %q", ids[roomversion.V3], ids[roomversion.V4])
|
||||
}
|
||||
std := strings.TrimPrefix(ids[roomversion.V3], "$")
|
||||
urlSafe := strings.TrimPrefix(ids[roomversion.V4], "$")
|
||||
if decoded, err := base64.RawStdEncoding.DecodeString(std); err != nil || len(decoded) != 32 {
|
||||
t.Errorf("version 3 ID is not 32 bytes of standard base64: %q (%v)", std, err)
|
||||
}
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(urlSafe); err != nil || len(decoded) != 32 {
|
||||
t.Errorf("version 4 ID is not 32 bytes of URL-safe base64: %q (%v)", urlSafe, err)
|
||||
}
|
||||
if strings.ContainsAny(urlSafe, "+/") {
|
||||
t.Errorf("version 4 ID contains a character that is not URL-safe: %q", urlSafe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactionKeepsEventIDAndSignature is the property the whole hashing
|
||||
// scheme exists for: a redacted event is still provably the event it claims
|
||||
// to be.
|
||||
func TestRedactionKeepsEventIDAndSignature(t *testing.T) {
|
||||
key := testKey(t)
|
||||
for _, v := range []roomversion.Version{roomversion.V3, roomversion.V10, roomversion.V11, roomversion.V12} {
|
||||
t.Run(string(v), func(t *testing.T) {
|
||||
rules := roomversion.MustGet(v)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: "m.room.message",
|
||||
Content: json.RawMessage(`{"body":"something private","msgtype":"m.text"}`),
|
||||
})
|
||||
|
||||
redacted, err := e.Redact()
|
||||
if err != nil {
|
||||
t.Fatalf("Redact: %v", err)
|
||||
}
|
||||
if redacted.EventID() != e.EventID() {
|
||||
t.Errorf("redaction changed the event ID: %q became %q", e.EventID(), redacted.EventID())
|
||||
}
|
||||
if err := redacted.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil {
|
||||
t.Errorf("the signature on the redacted event does not verify: %v", err)
|
||||
}
|
||||
if string(redacted.Content()) != "{}" {
|
||||
t.Errorf("redacted content = %s, want {}", redacted.Content())
|
||||
}
|
||||
|
||||
// The content hash no longer matches, which is exactly how a
|
||||
// receiver tells a redacted copy from a tampered one.
|
||||
if err := redacted.VerifyContentHash(); !errors.Is(err, ErrContentHashMismatch) {
|
||||
t.Errorf("VerifyContentHash on a redacted event = %v, want ErrContentHashMismatch", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactionProtectedKeys walks the keys each room version protects.
|
||||
func TestRedactionProtectedKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
version roomversion.Version
|
||||
event string
|
||||
wantKeys []string
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "version 10 protects the legacy top-level keys",
|
||||
version: roomversion.V10,
|
||||
event: `{"type":"m.room.message","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"body":"x"},"origin":"domain","membership":"join","prev_state":[],
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{},"age_ts":5}`,
|
||||
wantKeys: []string{
|
||||
"content", "hashes", "membership", "origin", "origin_server_ts",
|
||||
"prev_state", "room_id", "sender", "signatures", "type",
|
||||
},
|
||||
wantContent: `{}`,
|
||||
},
|
||||
{
|
||||
name: "version 11 drops the legacy top-level keys",
|
||||
version: roomversion.V11,
|
||||
event: `{"type":"m.room.message","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"body":"x"},"origin":"domain","membership":"join","prev_state":[],
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{},"age_ts":5}`,
|
||||
wantKeys: []string{
|
||||
"content", "hashes", "origin_server_ts", "room_id",
|
||||
"sender", "signatures", "type",
|
||||
},
|
||||
wantContent: `{}`,
|
||||
},
|
||||
{
|
||||
name: "version 10 keeps only the creator of a create event",
|
||||
version: roomversion.V10,
|
||||
event: `{"type":"m.room.create","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"creator":"@u:domain","room_version":"10","m.federate":false},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"creator":"@u:domain"}`,
|
||||
},
|
||||
{
|
||||
name: "version 11 keeps all of a create event's content",
|
||||
version: roomversion.V11,
|
||||
event: `{"type":"m.room.create","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"room_version":"11","m.federate":false},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"m.federate":false,"room_version":"11"}`,
|
||||
},
|
||||
{
|
||||
name: "version 8 keeps the join rule and allow list",
|
||||
version: roomversion.V8,
|
||||
event: `{"type":"m.room.join_rules","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"join_rule":"restricted","allow":[{"type":"m.room_membership"}],"extra":1},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"allow":[{"type":"m.room_membership"}],"join_rule":"restricted"}`,
|
||||
},
|
||||
{
|
||||
name: "version 7 keeps only the join rule",
|
||||
version: roomversion.V7,
|
||||
event: `{"type":"m.room.join_rules","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"join_rule":"restricted","allow":[{"type":"m.room_membership"}]},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"join_rule":"restricted"}`,
|
||||
},
|
||||
{
|
||||
name: "version 8 redacts the restricted join authorisation",
|
||||
version: roomversion.V8,
|
||||
event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain",
|
||||
"content":{"membership":"join","join_authorised_via_users_server":"@mod:other"},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"membership":"join"}`,
|
||||
},
|
||||
{
|
||||
name: "version 9 keeps the restricted join authorisation",
|
||||
version: roomversion.V9,
|
||||
event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain",
|
||||
"content":{"membership":"join","join_authorised_via_users_server":"@mod:other","displayname":"U"},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"join_authorised_via_users_server":"@mod:other","membership":"join"}`,
|
||||
},
|
||||
{
|
||||
name: "version 10 drops a third-party invite entirely",
|
||||
version: roomversion.V10,
|
||||
event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain",
|
||||
"content":{"membership":"invite","third_party_invite":{"display_name":"d","signed":{"token":"t"}}},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"membership":"invite"}`,
|
||||
},
|
||||
{
|
||||
name: "version 11 keeps the signed part of a third-party invite",
|
||||
version: roomversion.V11,
|
||||
event: `{"type":"m.room.member","room_id":"!r:domain","sender":"@u:domain","state_key":"@u:domain",
|
||||
"content":{"membership":"invite","third_party_invite":{"display_name":"d","signed":{"token":"t"}}},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"membership":"invite","third_party_invite":{"signed":{"token":"t"}}}`,
|
||||
},
|
||||
{
|
||||
name: "version 10 loses the target of a redaction",
|
||||
version: roomversion.V10,
|
||||
event: `{"type":"m.room.redaction","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"reason":"spam"},"redacts":"$target",
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantKeys: []string{"content", "hashes", "origin_server_ts", "room_id", "sender", "signatures", "type"},
|
||||
wantContent: `{}`,
|
||||
},
|
||||
{
|
||||
name: "version 11 keeps the target of a redaction",
|
||||
version: roomversion.V11,
|
||||
event: `{"type":"m.room.redaction","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"redacts":"$target","reason":"spam"},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"redacts":"$target"}`,
|
||||
},
|
||||
{
|
||||
name: "version 11 keeps the invite level of a power levels event",
|
||||
version: roomversion.V11,
|
||||
event: `{"type":"m.room.power_levels","room_id":"!r:domain","sender":"@u:domain","state_key":"",
|
||||
"content":{"invite":50,"ban":50,"notifications":{"room":20}},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"ban":50,"invite":50}`,
|
||||
},
|
||||
{
|
||||
name: "version 10 drops the invite level of a power levels event",
|
||||
version: roomversion.V10,
|
||||
event: `{"type":"m.room.power_levels","room_id":"!r:domain","sender":"@u:domain","state_key":"",
|
||||
"content":{"invite":50,"ban":50,"notifications":{"room":20}},
|
||||
"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{"ban":50}`,
|
||||
},
|
||||
{
|
||||
name: "an unknown type keeps no content at all",
|
||||
version: roomversion.V11,
|
||||
event: `{"type":"com.example.thing","room_id":"!r:domain","sender":"@u:domain",
|
||||
"content":{"anything":1},"origin_server_ts":1,"hashes":{"sha256":"x"},"signatures":{}}`,
|
||||
wantContent: `{}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rules := roomversion.MustGet(tc.version)
|
||||
got, err := redactJSON([]byte(tc.event), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("redactJSON: %v", err)
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(got, &obj); err != nil {
|
||||
t.Fatalf("the redacted event is not an object: %v", err)
|
||||
}
|
||||
if tc.wantKeys != nil {
|
||||
var keys []string
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if got, want := joinSorted(keys), strings.Join(tc.wantKeys, ","); got != want {
|
||||
t.Errorf("top-level keys = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
if content := string(obj["content"]); content != tc.wantContent {
|
||||
t.Errorf("content = %s, want %s", content, tc.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func joinSorted(keys []string) string {
|
||||
// canonicaljson sorts object keys, so the redacted event already has
|
||||
// them in order; this is only for a readable failure message.
|
||||
out := make([]string, len(keys))
|
||||
copy(out, keys)
|
||||
for i := range out {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[j] < out[i] {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// TestRedactsLocation checks that the builder puts a redaction's target
|
||||
// where the room version keeps it, and that Redacts reads it back from
|
||||
// either place.
|
||||
func TestRedactsLocation(t *testing.T) {
|
||||
key := testKey(t)
|
||||
|
||||
t.Run("version 10 uses the top level", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V10)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeRedaction,
|
||||
Redacts: "$target",
|
||||
Content: json.RawMessage(`{"reason":"spam"}`),
|
||||
})
|
||||
if e.Redacts() != "$target" {
|
||||
t.Errorf("Redacts() = %q, want $target", e.Redacts())
|
||||
}
|
||||
if !strings.Contains(string(e.JSON()), `"redacts":"$target"`) {
|
||||
t.Errorf("redacts is missing from the event: %s", e.JSON())
|
||||
}
|
||||
if strings.Contains(string(e.Content()), "redacts") {
|
||||
t.Errorf("redacts should not be in the content: %s", e.Content())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version 11 uses the content", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeRedaction,
|
||||
Redacts: "$target",
|
||||
Content: json.RawMessage(`{"reason":"spam"}`),
|
||||
})
|
||||
if e.Redacts() != "$target" {
|
||||
t.Errorf("Redacts() = %q, want $target", e.Redacts())
|
||||
}
|
||||
if !strings.Contains(string(e.Content()), `"redacts":"$target"`) {
|
||||
t.Errorf("redacts is missing from the content: %s", e.Content())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateEventRoomIDByVersion covers the room version 12 change that
|
||||
// makes the room ID the create event's own ID.
|
||||
func TestCreateEventRoomIDByVersion(t *testing.T) {
|
||||
key := testKey(t)
|
||||
|
||||
t.Run("version 11 carries a room ID", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeCreate,
|
||||
Content: json.RawMessage(`{"room_version":"11"}`),
|
||||
})
|
||||
if e.RoomID() != "!room:warp.test" {
|
||||
t.Errorf("RoomID() = %q", e.RoomID())
|
||||
}
|
||||
roomID, err := RoomIDOfCreateEvent(e)
|
||||
if err != nil {
|
||||
t.Fatalf("RoomIDOfCreateEvent: %v", err)
|
||||
}
|
||||
if roomID != "!room:warp.test" {
|
||||
t.Errorf("RoomIDOfCreateEvent() = %q", roomID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version 12 derives the room ID", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V12)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeCreate,
|
||||
Content: json.RawMessage(`{"room_version":"12"}`),
|
||||
})
|
||||
if e.RoomID() != "" {
|
||||
t.Errorf("RoomID() = %q, want it to be absent", e.RoomID())
|
||||
}
|
||||
roomID, err := RoomIDOfCreateEvent(e)
|
||||
if err != nil {
|
||||
t.Fatalf("RoomIDOfCreateEvent: %v", err)
|
||||
}
|
||||
if want := "!" + strings.TrimPrefix(e.EventID(), "$"); roomID != want {
|
||||
t.Errorf("RoomIDOfCreateEvent() = %q, want %q", roomID, want)
|
||||
}
|
||||
if strings.Contains(roomID, ":") {
|
||||
t.Errorf("a version 12 room ID should carry no domain: %q", roomID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version 12 rejects a create event with a room ID", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V12)
|
||||
b := Builder{
|
||||
Type: TypeCreate,
|
||||
Sender: "@alice:warp.test",
|
||||
RoomID: "!room:warp.test",
|
||||
Content: json.RawMessage(`{"room_version":"12"}`),
|
||||
}
|
||||
if _, err := b.Build(time.UnixMilli(1), key, rules); err == nil {
|
||||
t.Error("Build accepted a room_id on a version 12 create event")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyContentHashDetectsTampering(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: "m.room.message",
|
||||
Content: json.RawMessage(`{"body":"the original","msgtype":"m.text"}`),
|
||||
})
|
||||
|
||||
tampered := strings.Replace(string(e.JSON()), "the original", "a forgery!!!", 1)
|
||||
if tampered == string(e.JSON()) {
|
||||
t.Fatal("the test did not change anything")
|
||||
}
|
||||
parsed, err := Parse([]byte(tampered), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if err := parsed.VerifyContentHash(); !errors.Is(err, ErrContentHashMismatch) {
|
||||
t.Errorf("VerifyContentHash = %v, want ErrContentHashMismatch", err)
|
||||
}
|
||||
|
||||
// The signature still verifies, and that is not a hole: it covers the
|
||||
// redacted event, whose content is {} either way. Rewriting content is
|
||||
// therefore indistinguishable from being sent a redacted copy, and the
|
||||
// receiver's answer to a failed hash check is to use the redacted form
|
||||
// — so the rewritten content never reaches anybody.
|
||||
if err := parsed.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil {
|
||||
t.Errorf("the signature should still cover the redacted event: %v", err)
|
||||
}
|
||||
|
||||
// Rewriting the hash to match the new content is what the signature
|
||||
// does catch, because the signature covers hashes.
|
||||
sum, err := contentHash(parsed.JSON())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reHashed := strings.Replace(string(parsed.JSON()),
|
||||
`"sha256":"`+parsed.ContentHash()+`"`,
|
||||
`"sha256":"`+base64.RawStdEncoding.EncodeToString(sum[:])+`"`, 1)
|
||||
forged, err := Parse([]byte(reHashed), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if err := forged.VerifyContentHash(); err != nil {
|
||||
t.Fatalf("the test did not recompute the hash correctly: %v", err)
|
||||
}
|
||||
if err := forged.VerifySignature("warp.test", key.KeyID(), key.Public); err == nil {
|
||||
t.Error("the signature verifies on an event whose content and hash were both rewritten")
|
||||
}
|
||||
if forged.EventID() == e.EventID() {
|
||||
t.Error("rewriting the hash should change the event ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAddsASecondSignature(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: "m.room.member",
|
||||
Content: json.RawMessage(`{"membership":"invite"}`),
|
||||
})
|
||||
|
||||
other, err := signing.Generate("other")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
countersigned, err := e.Sign("other.example", other)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if countersigned.EventID() != e.EventID() {
|
||||
t.Errorf("countersigning changed the event ID: %q became %q", e.EventID(), countersigned.EventID())
|
||||
}
|
||||
if err := countersigned.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil {
|
||||
t.Errorf("the original signature no longer verifies: %v", err)
|
||||
}
|
||||
if err := countersigned.VerifySignature("other.example", other.KeyID(), other.Public); err != nil {
|
||||
t.Errorf("the second signature does not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejects(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
const sigs = `"hashes":{"sha256":"x"},"signatures":{"d":{"ed25519:1":"s"}}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"no type", `{"room_id":"!r:d","sender":"@u:d","content":{},` + sigs + `}`, "type is required"},
|
||||
{"no sender", `{"type":"m.x","room_id":"!r:d","content":{},` + sigs + `}`, "sender"},
|
||||
{"sender is not a user ID", `{"type":"m.x","room_id":"!r:d","sender":"alice","content":{},` + sigs + `}`, "sender"},
|
||||
{"no room ID", `{"type":"m.x","sender":"@u:d","content":{},` + sigs + `}`, "room_id"},
|
||||
{"room ID with the wrong sigil", `{"type":"m.x","room_id":"@r:d","sender":"@u:d","content":{},` + sigs + `}`, "room_id"},
|
||||
{"no content", `{"type":"m.x","room_id":"!r:d","sender":"@u:d",` + sigs + `}`, "content is required"},
|
||||
{"content is not an object", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":[],` + sigs + `}`, "content must be an object"},
|
||||
{"content is null", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":null,` + sigs + `}`, "content must be an object"},
|
||||
{"no content hash", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"signatures":{"d":{"e:1":"s"}}}`, "hashes.sha256 is required"},
|
||||
{"no signatures", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"hashes":{"sha256":"x"}}`, "signatures is required"},
|
||||
{"a float in the content", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{"a":1.5},` + sigs + `}`, "floats"},
|
||||
{"a prev event that is not an event ID", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"prev_events":["abc"],` + sigs + `}`, "prev_events"},
|
||||
{"depth above the limit", `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"depth":9007199254740992,` + sigs + `}`, "outside the allowed range"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(tc.in), rules)
|
||||
if err == nil {
|
||||
t.Fatal("Parse succeeded, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("error = %q, want it to mention %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsOversizedEvent(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
|
||||
b := Builder{
|
||||
Type: "m.room.message",
|
||||
Sender: "@alice:warp.test",
|
||||
RoomID: "!room:warp.test",
|
||||
Content: json.RawMessage(`{"body":"` + strings.Repeat("x", MaxEventSize) + `"}`),
|
||||
}
|
||||
_, err := b.Build(time.UnixMilli(1), key, rules)
|
||||
if err == nil {
|
||||
t.Fatal("Build accepted an event over the size limit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "the limit is") {
|
||||
t.Errorf("error = %q, want it to mention the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsTooManyDAGReferences(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
ids := make([]string, MaxPrevEvents+1)
|
||||
for i := range ids {
|
||||
ids[i] = "$event" + string(rune('a'+i))
|
||||
}
|
||||
encoded, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := `{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},"prev_events":` +
|
||||
string(encoded) + `,"hashes":{"sha256":"x"},"signatures":{"d":{"ed25519:1":"s"}}}`
|
||||
if _, err := Parse([]byte(raw), rules); err == nil {
|
||||
t.Error("Parse accepted more than the maximum number of prev_events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembership(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
target := "@bob:warp.test"
|
||||
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeMember,
|
||||
StateKey: &target,
|
||||
Content: json.RawMessage(`{"membership":"invite"}`),
|
||||
})
|
||||
got, err := e.Membership()
|
||||
if err != nil {
|
||||
t.Fatalf("Membership: %v", err)
|
||||
}
|
||||
if got != MembershipInvite {
|
||||
t.Errorf("Membership() = %q, want %q", got, MembershipInvite)
|
||||
}
|
||||
|
||||
other := buildEvent(t, rules, key, Builder{Type: "m.room.message", Content: json.RawMessage(`{}`)})
|
||||
if _, err := other.Membership(); err == nil {
|
||||
t.Error("Membership on a message event succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderServer(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{Type: "m.room.message", Content: json.RawMessage(`{}`)})
|
||||
|
||||
got, err := e.SenderServer()
|
||||
if err != nil {
|
||||
t.Fatalf("SenderServer: %v", err)
|
||||
}
|
||||
if got != "warp.test" {
|
||||
t.Errorf("SenderServer() = %q, want warp.test", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderRejects(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
b Builder
|
||||
}{
|
||||
{"no sender", Builder{Type: "m.x", RoomID: "!r:d"}},
|
||||
{"sender is not a user ID", Builder{Type: "m.x", RoomID: "!r:d", Sender: "alice"}},
|
||||
{"no type", Builder{RoomID: "!r:d", Sender: "@u:d"}},
|
||||
{"no room ID", Builder{Type: "m.x", Sender: "@u:d"}},
|
||||
{"negative depth", Builder{Type: "m.x", RoomID: "!r:d", Sender: "@u:d", Depth: -1}},
|
||||
{"redacts is not an event ID", Builder{Type: TypeRedaction, RoomID: "!r:d", Sender: "@u:d", Redacts: "nope"}},
|
||||
{"content is not an object", Builder{Type: "m.x", RoomID: "!r:d", Sender: "@u:d", Content: json.RawMessage(`[]`)}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := tc.b.Build(time.UnixMilli(1), key, rules); err == nil {
|
||||
t.Error("Build succeeded, want an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnsignedIsNotCoveredBySignature mirrors the federation case where a
|
||||
// server rewrites unsigned.age in transit.
|
||||
func TestUnsignedIsNotCoveredBySignature(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: "m.room.message",
|
||||
Content: json.RawMessage(`{"body":"hi"}`),
|
||||
Unsigned: json.RawMessage(`{"age":10}`),
|
||||
})
|
||||
|
||||
rewritten := strings.Replace(string(e.JSON()), `"age":10`, `"age":99`, 1)
|
||||
parsed, err := Parse([]byte(rewritten), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if parsed.EventID() != e.EventID() {
|
||||
t.Errorf("rewriting unsigned changed the event ID: %q became %q", e.EventID(), parsed.EventID())
|
||||
}
|
||||
if err := parsed.VerifySignature("warp.test", key.KeyID(), key.Public); err != nil {
|
||||
t.Errorf("rewriting unsigned broke the signature: %v", err)
|
||||
}
|
||||
if err := parsed.VerifyContentHash(); err != nil {
|
||||
t.Errorf("rewriting unsigned broke the content hash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzParse checks that no input makes Parse panic, and that any event it
|
||||
// accepts round-trips through its own JSON to the same ID.
|
||||
func FuzzParse(f *testing.F) {
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
f.Add([]byte(`{"type":"m.x","room_id":"!r:d","sender":"@u:d","content":{},` +
|
||||
`"hashes":{"sha256":"x"},"signatures":{"d":{"ed25519:1":"s"}}}`))
|
||||
f.Add([]byte(`{}`))
|
||||
f.Fuzz(func(t *testing.T, in []byte) {
|
||||
e, err := Parse(in, rules)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
again, err := Parse(e.JSON(), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parsing an accepted event failed: %v\nevent: %s", err, e.JSON())
|
||||
}
|
||||
if again.EventID() != e.EventID() {
|
||||
t.Fatalf("event ID is not stable: %q then %q\nevent: %s", e.EventID(), again.EventID(), e.JSON())
|
||||
}
|
||||
if _, err := e.Redact(); err != nil {
|
||||
t.Fatalf("redacting an accepted event failed: %v\nevent: %s", err, e.JSON())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccessors walks the plain field accessors, which the rest of the
|
||||
// server reads an event through.
|
||||
func TestAccessors(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: "m.room.message",
|
||||
Content: json.RawMessage(`{"body":"hi"}`),
|
||||
PrevEvents: []string{"$prev"},
|
||||
AuthEvents: []string{"$auth"},
|
||||
Depth: 4,
|
||||
Unsigned: json.RawMessage(`{"age":7}`),
|
||||
})
|
||||
|
||||
if got := e.PrevEventIDs(); len(got) != 1 || got[0] != "$prev" {
|
||||
t.Errorf("PrevEventIDs() = %v", got)
|
||||
}
|
||||
if got := e.AuthEventIDs(); len(got) != 1 || got[0] != "$auth" {
|
||||
t.Errorf("AuthEventIDs() = %v", got)
|
||||
}
|
||||
if got := string(e.Unsigned()); got != `{"age":7}` {
|
||||
t.Errorf("Unsigned() = %s", got)
|
||||
}
|
||||
if _, ok := e.Signatures().Get("warp.test", key.KeyID()); !ok {
|
||||
t.Errorf("Signatures() has no entry for warp.test: %v", e.Signatures())
|
||||
}
|
||||
if e.Rules().Version != roomversion.V11 {
|
||||
t.Errorf("Rules().Version = %q", e.Rules().Version)
|
||||
}
|
||||
if e.Version() != roomversion.V11 {
|
||||
t.Errorf("Version() = %q", e.Version())
|
||||
}
|
||||
if e.ContentHash() == "" {
|
||||
t.Error("ContentHash() is empty")
|
||||
}
|
||||
|
||||
// The reference hash is what the event ID is made of, so it must agree
|
||||
// with the ID the event already has.
|
||||
hash, err := e.ReferenceHash()
|
||||
if err != nil {
|
||||
t.Fatalf("ReferenceHash: %v", err)
|
||||
}
|
||||
if len(hash) != 32 {
|
||||
t.Errorf("ReferenceHash() is %d bytes, want 32", len(hash))
|
||||
}
|
||||
if want := "$" + base64.RawURLEncoding.EncodeToString(hash); want != e.EventID() {
|
||||
t.Errorf("the reference hash encodes to %q, but the event ID is %q", want, e.EventID())
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentHashAcceptsPaddedBase64 covers the leniency the spec asks for
|
||||
// when decoding: values get copied between implementations by hand, so a
|
||||
// padded hash still has to verify.
|
||||
func TestContentHashAcceptsPaddedBase64(t *testing.T) {
|
||||
key := testKey(t)
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{Type: "m.room.message", Content: json.RawMessage(`{"body":"hi"}`)})
|
||||
|
||||
sum, err := contentHash(e.JSON())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
padded := strings.Replace(string(e.JSON()),
|
||||
`"sha256":"`+e.ContentHash()+`"`,
|
||||
`"sha256":"`+base64.StdEncoding.EncodeToString(sum[:])+`"`, 1)
|
||||
|
||||
parsed, err := Parse([]byte(padded), rules)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if err := parsed.VerifyContentHash(); err != nil {
|
||||
t.Errorf("a padded content hash did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactARedaction checks that the target of a redaction survives
|
||||
// being redacted itself, which is why the property moved into the content.
|
||||
func TestRedactARedaction(t *testing.T) {
|
||||
key := testKey(t)
|
||||
|
||||
t.Run("version 11 keeps it", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V11)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeRedaction,
|
||||
Redacts: "$target",
|
||||
Content: json.RawMessage(`{"reason":"spam"}`),
|
||||
})
|
||||
redacted, err := e.Redact()
|
||||
if err != nil {
|
||||
t.Fatalf("Redact: %v", err)
|
||||
}
|
||||
if redacted.Redacts() != "$target" {
|
||||
t.Errorf("Redacts() = %q after redaction, want $target", redacted.Redacts())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version 10 loses it", func(t *testing.T) {
|
||||
rules := roomversion.MustGet(roomversion.V10)
|
||||
e := buildEvent(t, rules, key, Builder{
|
||||
Type: TypeRedaction,
|
||||
Redacts: "$target",
|
||||
Content: json.RawMessage(`{"reason":"spam"}`),
|
||||
})
|
||||
redacted, err := e.Redact()
|
||||
if err != nil {
|
||||
t.Fatalf("Redact: %v", err)
|
||||
}
|
||||
if redacted.Redacts() != "" {
|
||||
t.Errorf("Redacts() = %q after redaction, want it to be gone", redacted.Redacts())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
||||
)
|
||||
|
||||
// ErrContentHashMismatch means an event's content does not match the hash
|
||||
// it carries.
|
||||
//
|
||||
// Over federation this is not necessarily an attack and not necessarily
|
||||
// fatal: the usual cause is being sent a redacted copy of an event whose
|
||||
// signature still verifies, because the signature covers only the keys
|
||||
// redaction keeps. The caller's answer is to use the redacted form rather
|
||||
// than to drop the event.
|
||||
var ErrContentHashMismatch = errors.New("event content does not match its content hash")
|
||||
|
||||
// contentHash computes the content hash of an event: SHA-256 over the
|
||||
// canonical JSON of the event with unsigned, signatures and hashes removed.
|
||||
//
|
||||
// Each of the three is excluded for its own reason. unsigned is explicitly
|
||||
// rewritable in transit; signatures depend on the hash, so including it
|
||||
// would be circular; and hashes is left out so that a future algorithm can
|
||||
// be added alongside sha256 without changing what sha256 covers.
|
||||
func contentHash(raw []byte) ([sha256.Size]byte, error) {
|
||||
obj, err := decodeObject(raw)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
delete(obj, "unsigned")
|
||||
delete(obj, "signatures")
|
||||
delete(obj, "hashes")
|
||||
|
||||
encoded, err := canonicaljson.Marshal(obj)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, fmt.Errorf("%w: content hash: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
return sha256.Sum256(encoded), nil
|
||||
}
|
||||
|
||||
// referenceHash computes the reference hash of an event, which is what its
|
||||
// ID is made of: the event is redacted, then signatures and unsigned are
|
||||
// removed, then it is canonicalised and hashed.
|
||||
//
|
||||
// Redacting first is what makes an event ID survive redaction — the ID
|
||||
// covers only the keys a redaction keeps, so redacting an event does not
|
||||
// rename it.
|
||||
func referenceHash(raw []byte, rules roomversion.Rules) ([sha256.Size]byte, error) {
|
||||
redacted, err := redactJSON(raw, rules)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
obj, err := decodeObject(redacted)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
delete(obj, "signatures")
|
||||
delete(obj, "unsigned")
|
||||
|
||||
encoded, err := canonicaljson.Marshal(obj)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, fmt.Errorf("%w: reference hash: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
return sha256.Sum256(encoded), nil
|
||||
}
|
||||
|
||||
// computeEventID derives an event ID from the event's reference hash.
|
||||
func computeEventID(raw []byte, rules roomversion.Rules) (string, error) {
|
||||
sum, err := referenceHash(raw, rules)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch rules.EventIDFormat {
|
||||
case roomversion.EventIDFormatSHA256:
|
||||
return "$" + base64.RawStdEncoding.EncodeToString(sum[:]), nil
|
||||
case roomversion.EventIDFormatSHA256URLSafe:
|
||||
// URL-safe, because an ID with a '/' in it cannot be put in a path
|
||||
// — which is where event IDs ended up in the federation API.
|
||||
return "$" + base64.RawURLEncoding.EncodeToString(sum[:]), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: room version %s does not derive event IDs from a hash",
|
||||
ErrInvalidEvent, rules.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// ReferenceHash returns the event's reference hash, unencoded. Federation
|
||||
// needs it for the room version 1 and 2 event format and for auth chain
|
||||
// comparisons.
|
||||
func (e *Event) ReferenceHash() ([]byte, error) {
|
||||
sum, err := referenceHash(e.raw, e.rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sum[:], nil
|
||||
}
|
||||
|
||||
// VerifyContentHash checks the event against the content hash it carries.
|
||||
//
|
||||
// It returns ErrContentHashMismatch when the hash does not match, which a
|
||||
// federation caller should answer by using the redacted copy of the event
|
||||
// rather than by rejecting it.
|
||||
func (e *Event) VerifyContentHash() error {
|
||||
want, err := signing.DecodeBase64(e.fields.Hashes.SHA256)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: hashes.sha256: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
got, err := contentHash(e.raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(want, got[:]) {
|
||||
return ErrContentHashMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeObject parses an event into its properties, leaving every value as
|
||||
// the bytes it arrived as so that nothing we do not understand is lost.
|
||||
func decodeObject(raw []byte) (map[string]json.RawMessage, error) {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("%w: event is null, not an object", ErrInvalidEvent)
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
||||
)
|
||||
|
||||
// hashAndSign adds a content hash and a signature to a bare event.
|
||||
//
|
||||
// The order matters and is not the obvious one. The content hash is added
|
||||
// first, so that it covers every property of the unredacted event. The
|
||||
// event is then *redacted* and that copy is signed, so the signature covers
|
||||
// only the properties a redaction keeps — plus the hashes, which cover the
|
||||
// rest. The signature is then copied back onto the full event.
|
||||
//
|
||||
// The result is an event whose signature still verifies after someone
|
||||
// redacts it, which is what lets a server hand out a redacted event and
|
||||
// have the recipient believe it.
|
||||
func hashAndSign(raw []byte, rules roomversion.Rules, serverName string, key *signing.KeyPair) ([]byte, error) {
|
||||
obj, err := decodeObject(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sum, err := contentHash(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj["hashes"], err = json.Marshal(hashes{SHA256: base64.RawStdEncoding.EncodeToString(sum[:])})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: encode hashes: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
|
||||
hashed, err := canonicaljson.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
redacted, err := redactJSON(hashed, rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signed, err := signing.SignJSON(serverName, key, redacted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sigs, err := signing.ExtractSignatures(signed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if obj["signatures"], err = json.Marshal(sigs); err != nil {
|
||||
return nil, fmt.Errorf("%w: encode signatures: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
return canonicaljson.Marshal(obj)
|
||||
}
|
||||
|
||||
// VerifySignature checks the event for a valid signature from serverName
|
||||
// made with keyID.
|
||||
//
|
||||
// The event is redacted first, because that is the form that was signed.
|
||||
// This holds whether we were given the full event or a redacted copy, which
|
||||
// is why a redacted event can still be shown to be genuine.
|
||||
func (e *Event) VerifySignature(serverName, keyID string, pub ed25519.PublicKey) error {
|
||||
redacted, err := redactJSON(e.raw, e.rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return signing.VerifyJSON(serverName, keyID, pub, redacted)
|
||||
}
|
||||
|
||||
// Sign returns the event with an additional signature from serverName.
|
||||
//
|
||||
// A normal event is signed once, by the sender's own server, when it is
|
||||
// built. A second signature is needed in the few places where another
|
||||
// server has to vouch for an event it did not send: the invited server
|
||||
// countersigning an invite, and a third-party invite exchanged through a
|
||||
// server other than the sender's.
|
||||
func (e *Event) Sign(serverName string, key *signing.KeyPair) (*Event, error) {
|
||||
redacted, err := redactJSON(e.raw, e.rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signed, err := signing.SignJSON(serverName, key, redacted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sigs, err := signing.ExtractSignatures(signed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := decodeObject(e.raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if obj["signatures"], err = json.Marshal(sigs); err != nil {
|
||||
return nil, fmt.Errorf("%w: encode signatures: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
out, err := canonicaljson.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
// Re-parse rather than patching the Event in place: an Event is
|
||||
// immutable, and the signature block is one of the things Parse
|
||||
// validates.
|
||||
return Parse(out, e.rules)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// The limits the spec puts on an event.
|
||||
const (
|
||||
// MaxEventSize is the largest an event may be as canonical JSON,
|
||||
// signatures included. 64 KiB is generous for a message and small
|
||||
// enough that a room's state cannot be used to exhaust a server.
|
||||
MaxEventSize = 65536
|
||||
|
||||
// MaxFieldLength caps the type and state_key properties.
|
||||
MaxFieldLength = 255
|
||||
|
||||
// MaxPrevEvents and MaxAuthEvents cap the DAG references an event may
|
||||
// carry. The auth events selection algorithm never needs more than a
|
||||
// handful, so a longer list is a sign of something wrong rather than a
|
||||
// legitimate event.
|
||||
MaxPrevEvents = 20
|
||||
MaxAuthEvents = 10
|
||||
|
||||
// MaxDepth is the largest depth an event may claim. Once a room gets
|
||||
// here every later event stays at the limit.
|
||||
MaxDepth = canonicaljson.MaxInt
|
||||
)
|
||||
|
||||
// validate applies the structural rules: the properties the spec marks
|
||||
// required are present and of the right type, the identifiers are
|
||||
// well-formed, and nothing is over its size limit.
|
||||
//
|
||||
// It deliberately does not look at the room state. Everything checkable
|
||||
// from the event alone is checked here so that the authorisation rules can
|
||||
// assume a well-formed event and concern themselves only with permission.
|
||||
func (e *Event) validate() error {
|
||||
if e.rules.EventIDFormat == roomversion.EventIDFormatExplicit {
|
||||
// Unreachable today: Get refuses to return rules for room versions
|
||||
// 1 and 2. The check is here so that adding them to the registry
|
||||
// fails loudly rather than computing nonsense event IDs.
|
||||
return fmt.Errorf("%w: room version %s carries event IDs in the event, which Warp cannot read yet",
|
||||
ErrInvalidEvent, e.rules.Version)
|
||||
}
|
||||
|
||||
if n := len(e.raw); n > MaxEventSize {
|
||||
return fmt.Errorf("%w: event is %d bytes, the limit is %d", ErrInvalidEvent, n, MaxEventSize)
|
||||
}
|
||||
|
||||
if e.fields.Type == "" {
|
||||
return fmt.Errorf("%w: type is required", ErrInvalidEvent)
|
||||
}
|
||||
if n := len(e.fields.Type); n > MaxFieldLength {
|
||||
return fmt.Errorf("%w: type is %d bytes, the limit is %d", ErrInvalidEvent, n, MaxFieldLength)
|
||||
}
|
||||
if err := id.ValidateUserID(e.fields.Sender); err != nil {
|
||||
return fmt.Errorf("%w: sender: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
if sk, ok := e.StateKey(); ok {
|
||||
if n := len(sk); n > MaxFieldLength {
|
||||
return fmt.Errorf("%w: state_key is %d bytes, the limit is %d", ErrInvalidEvent, n, MaxFieldLength)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.validateRoomID(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.validateContent(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.validateDAGReferences(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if e.fields.Depth > MaxDepth {
|
||||
return fmt.Errorf("%w: depth %d is above the limit of %d", ErrInvalidEvent, e.fields.Depth, MaxDepth)
|
||||
}
|
||||
if e.fields.Hashes.SHA256 == "" {
|
||||
return fmt.Errorf("%w: hashes.sha256 is required", ErrInvalidEvent)
|
||||
}
|
||||
if len(e.fields.Signatures) == 0 {
|
||||
return fmt.Errorf("%w: signatures is required", ErrInvalidEvent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoomID checks the room_id property, which from room version 12 is
|
||||
// absent on the create event because the room ID is derived from it.
|
||||
func (e *Event) validateRoomID() error {
|
||||
isCreate := e.fields.Type == TypeCreate
|
||||
if e.rules.RoomIDIsCreateEventID && isCreate {
|
||||
if e.fields.RoomID != "" {
|
||||
// Rule 1.2 for this room version rejects it as well; failing
|
||||
// here too means we never compute an ID for an event whose
|
||||
// room is ambiguous.
|
||||
return fmt.Errorf("%w: an m.room.create event in a version %s room must not have a room_id",
|
||||
ErrInvalidEvent, e.rules.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := id.ValidateRoomID(e.fields.RoomID); err != nil {
|
||||
return fmt.Errorf("%w: room_id: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateContent checks that content is present and is an object. The
|
||||
// spec marks it required on every event, and the authorisation rules and
|
||||
// the redaction algorithm both read into it.
|
||||
func (e *Event) validateContent() error {
|
||||
if len(e.fields.Content) == 0 {
|
||||
return fmt.Errorf("%w: content is required", ErrInvalidEvent)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(e.fields.Content, &obj); err != nil {
|
||||
return fmt.Errorf("%w: content must be an object: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
if obj == nil {
|
||||
return fmt.Errorf("%w: content must be an object, not null", ErrInvalidEvent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDAGReferences checks prev_events and auth_events: within their
|
||||
// limits, and every entry a well-formed event ID.
|
||||
func (e *Event) validateDAGReferences() error {
|
||||
if n := len(e.fields.PrevEvents); n > MaxPrevEvents {
|
||||
return fmt.Errorf("%w: %d prev_events, the limit is %d", ErrInvalidEvent, n, MaxPrevEvents)
|
||||
}
|
||||
if n := len(e.fields.AuthEvents); n > MaxAuthEvents {
|
||||
return fmt.Errorf("%w: %d auth_events, the limit is %d", ErrInvalidEvent, n, MaxAuthEvents)
|
||||
}
|
||||
for _, eventID := range e.fields.PrevEvents {
|
||||
if err := id.ValidateEventID(eventID); err != nil {
|
||||
return fmt.Errorf("%w: prev_events: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
}
|
||||
for _, eventID := range e.fields.AuthEvents {
|
||||
if err := id.ValidateEventID(eventID); err != nil {
|
||||
return fmt.Errorf("%w: auth_events: %w", ErrInvalidEvent, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user