Files
Warp/server/internal/matrix/auth/member.go
T
tajniak81andClaude Opus 5.5 2fbd5f53ce
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
Phase 2: the event core
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>
2026-10-15 22:23:35 +02:00

359 lines
13 KiB
Go

package auth
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
)
// memberContent is the part of an m.room.member event's content the rules
// read. The pointer and json.RawMessage fields are there so that "absent"
// can be told from "empty", which several of the rules turn on.
type memberContent struct {
Membership string `json:"membership"`
JoinAuthorisedViaUsersServer string `json:"join_authorised_via_users_server"`
ThirdPartyInvite *thirdPartyInvite `json:"third_party_invite"`
}
type thirdPartyInvite struct {
Signed json.RawMessage `json:"signed"`
}
// checkMember applies rule 5, the membership rules.
//
// These are the only rules a user who is not in the room may satisfy, so
// they carry the whole of who can get in: invitations, public rooms,
// knocking, restricted rooms, and who may throw whom out.
func checkMember(ctx context.Context, e *event.Event, st State, create *event.Event, pl *PowerLevels, verifier Verifier) error {
rules := e.Rules()
// Rule 5.1.
target, ok := e.StateKey()
if !ok {
return reject("an m.room.member event must have a state_key")
}
var content memberContent
if err := json.Unmarshal(e.Content(), &content); err != nil {
return reject("m.room.member content: %v", err)
}
if content.Membership == "" {
return reject("an m.room.member event must have a membership")
}
// Rule 5.2: a join authorised by another user must be signed by that
// user's server, not merely by the joining user's. Without it, anybody
// could claim to have been let into a restricted room.
if rules.RestrictedJoinRule && content.JoinAuthorisedViaUsersServer != "" {
if err := checkJoinAuthorisation(ctx, e, content.JoinAuthorisedViaUsersServer, verifier); err != nil {
return err
}
}
senderMembership := st.Membership(e.Sender())
targetMembership := st.Membership(target)
switch content.Membership {
case event.MembershipJoin:
return checkJoin(e, st, create, pl, target, targetMembership, content)
case event.MembershipInvite:
return checkInvite(e, st, pl, target, senderMembership, targetMembership, content)
case event.MembershipLeave:
return checkLeave(e, pl, target, senderMembership, targetMembership)
case event.MembershipBan:
return checkBan(e, pl, target, senderMembership)
case event.MembershipKnock:
if !rules.KnockJoinRule {
return reject("room version %s does not allow knocking", rules.Version)
}
return checkKnock(e, st, target, senderMembership)
default:
// Rule 5.8.
return reject("unknown membership %q", content.Membership)
}
}
// checkJoin applies rule 5.3.
func checkJoin(e *event.Event, st State, create *event.Event, pl *PowerLevels, target, targetMembership string, content memberContent) error {
rules := e.Rules()
// Rule 5.3.1: the creator's own join, the second event in the room.
// There is no state yet to authorise it against, so it is recognised
// by its shape: it follows the create event and nothing else.
prev := e.PrevEventIDs()
if len(prev) == 1 && prev[0] == create.EventID() && target == create.Sender() {
return nil
}
// Rule 5.3.2: you join yourself. Being dragged into a room is an
// invite, which the invited user then accepts with a join of their own.
if e.Sender() != target {
return reject("the sender may not join %q on their behalf", target)
}
// Rule 5.3.3.
if targetMembership == event.MembershipBan {
return reject("the user is banned from the room")
}
joinRule := st.JoinRule()
switch joinRule {
case JoinRuleInvite, JoinRuleKnock:
// Rule 5.3.4. A knock does not admit anybody by itself: it asks,
// and somebody with the power to invite still has to answer.
if targetMembership == event.MembershipInvite || targetMembership == event.MembershipJoin {
return nil
}
return reject("the room's join rule is %q and the user has not been invited (membership is %q)",
joinRule, targetMembership)
case JoinRuleRestricted, JoinRuleKnockRestricted:
if joinRule == JoinRuleRestricted && !rules.RestrictedJoinRule {
return reject("room version %s does not have the restricted join rule", rules.Version)
}
if joinRule == JoinRuleKnockRestricted && !rules.KnockRestrictedJoinRule {
return reject("room version %s does not have the knock_restricted join rule", rules.Version)
}
// Rule 5.3.5.1: an existing invite or join still works, so a
// restricted room is never *harder* to enter than an invite room.
if targetMembership == event.MembershipInvite || targetMembership == event.MembershipJoin {
return nil
}
// Rule 5.3.5.2. The authorising user has to be in the room and
// able to invite, because what they are really doing is vouching
// that the joining user is a member of one of the allowed rooms —
// something this server may be unable to see for itself.
authoriser := content.JoinAuthorisedViaUsersServer
if authoriser == "" {
return reject("the room's join rule is %q and the join is not authorised by any user", joinRule)
}
if m := st.Membership(authoriser); m != event.MembershipJoin {
return reject("the authorising user %q is not joined to the room (membership is %q)", authoriser, m)
}
if level := pl.UserLevel(authoriser); level < pl.Invite {
return reject("the authorising user %q has power level %s, below the invite level %d",
authoriser, levelString(level), pl.Invite)
}
// Rule 5.3.5.3.
return nil
case JoinRulePublic:
// Rule 5.3.6.
return nil
default:
// Rule 5.3.7. "private" lands here, and so does any join rule
// from a future room version: a rule we cannot evaluate must keep
// people out rather than let them in.
return reject("the room's join rule %q does not allow joining", joinRule)
}
}
// checkInvite applies rule 5.4.
func checkInvite(e *event.Event, st State, pl *PowerLevels, target, senderMembership, targetMembership string, content memberContent) error {
// Rule 5.4.1: a third-party invite is an invitation issued to an email
// address or phone number before the server knew which user it was.
// The identity server's signature stands in for the inviter's power.
if content.ThirdPartyInvite != nil {
return checkThirdPartyInvite(e, st, target, targetMembership, content)
}
// Rule 5.4.2.
if senderMembership != event.MembershipJoin {
return reject("the sender is not joined to the room (membership is %q)", senderMembership)
}
// Rule 5.4.3.
if targetMembership == event.MembershipJoin || targetMembership == event.MembershipBan {
return reject("the user is already %q in the room", targetMembership)
}
// Rule 5.4.4.
if level := pl.UserLevel(e.Sender()); level < pl.Invite {
return reject("the sender's power level %s is below the invite level %d", levelString(level), pl.Invite)
}
// Rule 5.4.5 is the negation of the above.
return nil
}
// checkThirdPartyInvite applies rule 5.4.1.
func checkThirdPartyInvite(e *event.Event, st State, target, targetMembership string, content memberContent) error {
// Rule 5.4.1.1.
if targetMembership == event.MembershipBan {
return reject("the user is banned from the room")
}
// Rule 5.4.1.2.
if len(content.ThirdPartyInvite.Signed) == 0 {
return reject("the third_party_invite has no signed property")
}
var signed struct {
MXID string `json:"mxid"`
Token string `json:"token"`
}
if err := json.Unmarshal(content.ThirdPartyInvite.Signed, &signed); err != nil {
return reject("the third_party_invite signed property: %v", err)
}
// Rule 5.4.1.3.
if signed.MXID == "" || signed.Token == "" {
return reject("the third_party_invite signed property needs both mxid and token")
}
// Rule 5.4.1.4.
if signed.MXID != target {
return reject("the third_party_invite names %q but the event invites %q", signed.MXID, target)
}
// Rule 5.4.1.5.
inviteEvent := st.ThirdPartyInvite(signed.Token)
if inviteEvent == nil {
return reject("there is no m.room.third_party_invite in the room state for token %q", signed.Token)
}
// Rule 5.4.1.6: the same user who issued the invitation must be the
// one redeeming it.
if inviteEvent.Sender() != e.Sender() {
return reject("the third-party invite was issued by %q, not by %q", inviteEvent.Sender(), e.Sender())
}
// Rules 5.4.1.7 and 5.4.1.8.
if !VerifyThirdPartyInviteSignature(content.ThirdPartyInvite.Signed, inviteEvent) {
return reject("no signature on the third-party invite matches a public key in %s", inviteEvent.EventID())
}
return nil
}
// checkLeave applies rule 5.5, which covers both leaving and being kicked.
func checkLeave(e *event.Event, pl *PowerLevels, target, senderMembership, targetMembership string) error {
rules := e.Rules()
// Rule 5.5.1: leaving on your own account, which also covers turning
// down an invitation and withdrawing a knock.
if e.Sender() == target {
switch targetMembership {
case event.MembershipInvite, event.MembershipJoin:
return nil
case event.MembershipKnock:
if rules.KnockJoinRule {
return nil
}
}
return reject("the user cannot leave from membership %q", targetMembership)
}
// Rule 5.5.2.
if senderMembership != event.MembershipJoin {
return reject("the sender is not joined to the room (membership is %q)", senderMembership)
}
senderLevel := pl.UserLevel(e.Sender())
// Rule 5.5.3: unbanning is a leave over a ban, and it takes the power
// to have banned in the first place.
if targetMembership == event.MembershipBan && senderLevel < pl.Ban {
return reject("the sender's power level %s is below the ban level %d needed to unban",
levelString(senderLevel), pl.Ban)
}
// Rule 5.5.4.
if senderLevel < pl.Kick {
return reject("the sender's power level %s is below the kick level %d", levelString(senderLevel), pl.Kick)
}
if targetLevel := pl.UserLevel(target); targetLevel >= senderLevel {
return reject("the sender's power level %s does not exceed %q's power level %s",
levelString(senderLevel), target, levelString(targetLevel))
}
// Rule 5.5.5 is the negation of the above.
return nil
}
// checkBan applies rule 5.6.
func checkBan(e *event.Event, pl *PowerLevels, target, senderMembership string) error {
// Rule 5.6.1.
if senderMembership != event.MembershipJoin {
return reject("the sender is not joined to the room (membership is %q)", senderMembership)
}
// Rule 5.6.2.
senderLevel := pl.UserLevel(e.Sender())
if senderLevel < pl.Ban {
return reject("the sender's power level %s is below the ban level %d", levelString(senderLevel), pl.Ban)
}
if targetLevel := pl.UserLevel(target); targetLevel >= senderLevel {
return reject("the sender's power level %s does not exceed %q's power level %s",
levelString(senderLevel), target, levelString(targetLevel))
}
// Rule 5.6.3 is the negation of the above.
return nil
}
// checkKnock applies rule 5.7.
func checkKnock(e *event.Event, st State, target, senderMembership string) error {
// Rule 5.7.1.
joinRule := st.JoinRule()
if joinRule != JoinRuleKnock && joinRule != JoinRuleKnockRestricted {
return reject("the room's join rule is %q, so it cannot be knocked on", joinRule)
}
// Rule 5.7.2.
if e.Sender() != target {
return reject("the sender may not knock on behalf of %q", target)
}
// Rule 5.7.3: knocking is allowed from anything but banned, invited or
// joined — there is no point knocking on a door that is already open.
switch senderMembership {
case event.MembershipBan, event.MembershipInvite, event.MembershipJoin:
// Rule 5.7.4.
return reject("the user cannot knock while their membership is %q", senderMembership)
default:
return nil
}
}
// checkJoinAuthorisation applies rule 5.2.
func checkJoinAuthorisation(ctx context.Context, e *event.Event, authoriser string, verifier Verifier) error {
serverName, err := id.ServerNameOf(authoriser)
if err != nil {
return reject("join_authorised_via_users_server: %v", err)
}
if verifier == nil {
return fmt.Errorf("%w: %s is authorised by %q, whose server's signature cannot be checked",
ErrNoVerifier, e.EventID(), authoriser)
}
if err := verifier.VerifyEventSignature(ctx, serverName, e); err != nil {
return reject("the join is authorised by %q but carries no valid signature from %q: %v",
authoriser, serverName, err)
}
return nil
}
// thirdPartyInviteKeys returns the public keys an m.room.third_party_invite
// event published, from either of the two places the spec allows.
func thirdPartyInviteKeys(e *event.Event) ([]ed25519.PublicKey, error) {
var content struct {
PublicKey string `json:"public_key"`
PublicKeys []struct {
PublicKey string `json:"public_key"`
} `json:"public_keys"`
}
if err := json.Unmarshal(e.Content(), &content); err != nil {
return nil, reject("m.room.third_party_invite content: %v", err)
}
encoded := make([]string, 0, len(content.PublicKeys)+1)
if content.PublicKey != "" {
encoded = append(encoded, content.PublicKey)
}
for _, k := range content.PublicKeys {
if k.PublicKey != "" {
encoded = append(encoded, k.PublicKey)
}
}
keys := make([]ed25519.PublicKey, 0, len(encoded))
for _, s := range encoded {
raw, err := signing.DecodeBase64(s)
if err != nil || len(raw) != ed25519.PublicKeySize {
// A key we cannot read cannot match a signature, and the rule
// asks whether *any* key matches — so a bad one is skipped
// rather than fatal.
continue
}
keys = append(keys, ed25519.PublicKey(raw))
}
return keys, nil
}