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>
946 lines
33 KiB
Go
946 lines
33 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|