Skip to content

Matrix DM session-key lowercasing collides case-distinct MXIDs, sharing one peer's DM transcript with another under per-peer scope #102313

Description

@yetval

Summary

Under a per-peer DM session scope, the direct-message session-key builder force-lowercases the peer id before building the key. For Matrix (and Signal), where user ids are opaque and can be case-distinct, two different peers whose ids differ only in case collapse to the SAME session key. The second peer's DM turn then resolves to the first peer's session and loads the first peer's persisted DM transcript with the bot. This is the direct-message sibling of the case-preservation hardening shipped for group/room ids in #75670 / #87366, which was applied to only one of the two branches.

Environment

  • Commit: d3ff48c (origin/main at time of filing)
  • Surface: routing session-key builder, direct/DM branch; reached by the in-tree Matrix extension (Signal shares the same exposure)

Steps to reproduce

  1. Enable the Matrix extension and set the DM session scope to a per-peer variant (per-peer, per-channel-peer, or per-account-channel-peer). The default is main, which shares one session and is unaffected.
  2. Have two distinct Matrix users whose MXIDs differ only in case DM the same bot, for example @Alice:hs.example and @alice:hs.example.
  3. Observe that the second user's turn resolves to the same session key as the first user, so the bot loads and exposes the first user's prior DM history to the second user.

Real-runtime driver: call the production buildAgentPeerSessionKey (and the higher-level buildAgentSessionKey) with peerKind: "direct", a per-peer dmScope, and the two case-distinct MXIDs, exactly as the Matrix extension supplies them.

Expected

A per-peer DM scope must isolate distinct peer identities. Two case-distinct MXIDs are two distinct principals on a homeserver that permits them, so they must map to two distinct session keys and two separate transcripts.

Actual

Both MXIDs map to one session key agent:main:direct:@alice:hs.example (and the channel/account-scoped variants), so they share one session and one persisted transcript. The group/room path does not have this problem: it preserves case and keeps the two ids distinct.

Root cause

src/routing/session-key.ts direct branch (line 248 on the pinned commit):

if (peerKind === "direct") {
  const dmScope = params.dmScope ?? "main";
  let peerId = (params.peerId ?? "").trim();
  ...
  peerId = normalizeLowercaseStringOrEmpty(peerId);
  ...
  if (dmScope === "per-peer" && peerId) {
    return `agent:${normalizeAgentId(params.agentId)}:direct:${peerId}`;
  }

The blanket normalizeLowercaseStringOrEmpty(peerId) lowercases the opaque MXID before it is embedded in the DM key. The group branch of the same function instead routes peerId through normalizeSessionPeerId (src/sessions/session-key-utils.ts), which honors CASE_PRESERVING_PEERS so opaque channels keep case:

const CASE_PRESERVING_PEERS = [
  { channel: "signal", peerKinds: new Set(["group"]), span: "segment", unscoped: true },
  { channel: "matrix", peerKinds: new Set(["channel", "group"]), span: "tail", unscoped: true },
];

#75670 / #87366 added Matrix case preservation, but only for channel/group peer kinds and only on the group branch. The direct branch still uses the blanket lowercase, and direct is not in the preserving set, so DM ids for Matrix and Signal still fold.

Reachability: the in-tree Matrix extension builds a direct peer with the raw case-sensitive MXID both inbound (extensions/matrix/src/matrix/monitor/handler.ts) and outbound (extensions/matrix/src/session-route.ts, peer: { kind: "direct", id: target.id }). normalizeRouteBindingId (src/routing/binding-scope.ts) only trims, so the case survives all the way to line 248.

Suggested fix: route the direct branch's peerId through normalizeSessionPeerId (like the group branch) and add direct to the Matrix and Signal descriptors in CASE_PRESERVING_PEERS, so opaque-id channels keep case for DMs while case-insensitive channels (Telegram etc.) keep folding.

Sibling surfaces

Signal is the other CASE_PRESERVING_PEERS channel and shares the same DM-branch exposure. Case-insensitive channels (Telegram, Discord numeric ids) are correct to fold and are unaffected. The default main DM scope is unaffected because it does not reach the per-peer key path.

Real behavior proof

Behavior addressed: per-peer DM session keys for Matrix must isolate case-distinct MXIDs; on current main they collide and share one transcript.
Real environment tested: production buildAgentPeerSessionKey and buildAgentSessionKey driven directly with peerKind: "direct" and case-distinct MXIDs, at commit d3ff48c
Exact steps or command run after this patch: drive the real key builders with @Alice:hs.example and @alice:hs.example across the three per-peer dmScopes and the group path; compare keys on the pinned commit, then with the direct branch routed through the case-preserving normalizer on identical inputs
Evidence after fix:

# OBSERVED (buggy, origin/main d3ff48c)
DM  per-peer  @Alice: agent:main:direct:@alice:hs.example
DM  per-peer  @alice: agent:main:direct:@alice:hs.example
DM  per-peer  COLLIDES: true

DM  per-channel-peer  @Alice: agent:main:matrix:direct:@alice:hs.example
DM  per-channel-peer  @alice: agent:main:matrix:direct:@alice:hs.example
DM  per-channel-peer  COLLIDES: true

DM  per-account-channel-peer  @Alice: agent:main:matrix:acct1:direct:@alice:hs.example
DM  per-account-channel-peer  @alice: agent:main:matrix:acct1:direct:@alice:hs.example
DM  per-account-channel-peer  COLLIDES: true

GRP channel  @Alice: agent:main:matrix:channel:@Alice:hs.example
GRP channel  @alice: agent:main:matrix:channel:@alice:hs.example
GRP channel  COLLIDES: false

# EXPECTED (direct branch case-aware, identical inputs)
DM  per-peer  @Alice: agent:main:direct:@Alice:hs.example
DM  per-peer  @alice: agent:main:direct:@alice:hs.example
DM  per-peer  COLLIDES: false

DM  per-channel-peer  @Alice: agent:main:matrix:direct:@Alice:hs.example
DM  per-channel-peer  @alice: agent:main:matrix:direct:@alice:hs.example
DM  per-channel-peer  COLLIDES: false

DM  per-account-channel-peer  @Alice: agent:main:matrix:acct1:direct:@Alice:hs.example
DM  per-account-channel-peer  @alice: agent:main:matrix:acct1:direct:@alice:hs.example
DM  per-account-channel-peer  COLLIDES: false

Observed result after fix: on the pinned commit two case-distinct Matrix MXIDs produce one identical per-peer DM key (COLLIDES true) so the second peer reads the first peer's transcript; with the direct branch made case-aware the keys are distinct (COLLIDES false) while the group path was already correct.
What was not tested: two preconditions limit real-world exposure and were not staged end to end against a live homeserver. First, the operator must have opted into a non-default per-peer DM scope; the default main scope does not hit this path. Second, the homeserver must permit two case-distinct MXIDs as distinct users. Standard Synapse canonicalizes and lowercases localparts at registration, so it does NOT fire there; this requires a permissive or non-standard homeserver (for example Dendrite/Conduit or custom setups), legacy pre-canonicalization accounts, or federated ids that preserve case. I did not verify a full inbound-to-transcript-load flow on a live server; the proof drives the real session-key builders that decide which transcript is loaded.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions