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()) } }) }