You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
matrix-spec-proposals/proposals/4117-reinstating-events.md

16 KiB

MSC4117: Reinstating Events (Reversible Redactions)

Under the UK's Online Safety Bill and similar upcoming legislation in the EU, US, and Canada, users who have action taken against their accounts must be provided an option to appeal that action and undo consequences if successful. In the context of Matrix, this means when a room moderator or server admin redacts a user's messages, the affected user must be able to "unredact" those messages with successful appeal.

When a redaction is applied in Matrix, servers and clients both remove protocol-insignificant information from the target event forever. Some server implementations retain the redacted fields for a short time within their database, but flag that the event should be redacted when served to clients or other servers. This is primarily for safety and platform security reasons, where server admins may need to review content after a room moderator has cleaned it up already, for example.

Redactions are a permanent and unrecoverable alteration to the room history under this design, which is clearly incompatible with above-mentioned (upcoming) legal requirements of server operators. This proposal allows servers to "reinstate" events after they've been redacted to cover the technical limitation, though does not create Client-Server API endpoints to invoke this behaviour or manage appeals. Such functionality is better scoped to another MSC.

Background

Redactions from room version 3 onwards are not tied to the authorization rules within a room. Instead, as described in the spec, redaction events are withheld from local clients until both the redaction and target event are received, and sender of the redaction has permission to affect the target event. Once those conditions are met, the redaction is applied to remove non-critical data from the event (typically the message content itself).

A related feature in Matrix is the integrity of content when sending events over federation. To ensure that events are not modified, a content hash is calculated, recorded by the origin server, and signed before the event is accepted by any other server. On the receiving side, the server validates the content hash and redacts the event upon failure before processing it further, as described here and here.

The content hash is protected by the redaction algorithm because it is critical to the protocol's operation. If the field were to be removed from the event upon redaction, servers would be unable to validate the signature on the event which could potentially lead to the event being rejected when it was previously accepted. In a worst case scenario, this could cause the majority of the room's events to also be rejected because events are rejected if they reference rejected events themselves. Instead, the content hash is protected by redaction to allow content itself to lose most/all of its fields.

Proposal

We can use the presence of the content hash to reinstate an event's content post-redaction. A new message event type, m.room.reinstate, is introduced with the following shape:

{
  "type": "m.room.reinstate",
  "sender": "@bob:example.org",
  "content": {
    "$event_id_to_be_reinstated": {
      // original event content
    }
  },
  // other fields excluded for brevity
}

Note: Design considerations for MSC2244 and MSC4084 are implied.

Similar to redactions, the reinstate event is withheld from local clients until the server has all the information required to validate the event. Those conditions are:

  1. The server has received all of the target events.
  2. For each of the target events, one of the following conditions is met (these are the same as redactions):
    1. The power level of the reinstate event's sender is greater than or equal to the redact level.
    2. The domain of the reinstate event's sender matches that of the target event's sender.
  3. For each of the target event contents, the calculated content hash must equal the target event's content hash.

If any of the conditions cannot be met, the reinstate event is withheld. Servers may wish to soft-fail the event if it can never be satisfied, such as in the case of condition 2 or 3.

Once the conditions are met, the reinstate event is applied to the target events as a virtual layer similar to message edits. This is done to allow the reinstate event itself to be redacted, which undoes the reinstation to return the event back to the previous state.

TODO: Or do we just apply the content directly, making redacting the reinstate event useless?

Note that the reinstate event does not have any fields protected from redaction. Such empty events affect nothing and so pass the above conditions.

Reinstate and redaction events affect the target event in topological DAG order. That's to say that if a redaction happens first, reinstate second, and another redaction third, the event will be redacted in the end. There may be an information leak due to the reinstate event not being redacted in that simplistic scenario, however.

TODO: Do we need to address the leak? Is it sufficient to have clients highlight the redacted event to say it's only partially redacted and offer a button to finish the job (redact the reinstate event)?

Client-side application of reinstate events

This proposal has a unique opportunity to avoid needing a net-new room version in order to be released to the world because it does not modify the operation or behaviour of the room model. This means the reinstate behaviour described above can be applied in all existing room versions, including v1 and v2 where redactions are special-cased.

However, it can take a while for servers to update to support new mechanics and behaviours. This can mean that a server which is unaware of m.room.reinstate's special meaning will forward the event down to clients as though it was any other event -- a desirable feature for most proposals. For this proposal, such behaviour would be non-ideal as clients would assume the event has been validated and can therefore apply the new content to their local cached copy. If the reinstate event was maliciously sent, the room history could be illegally mutated for users on old homeservers.

A new room version could potentially solve the issue, though with the m.room.reinstate event holding no authorization or security purpose within the room model, a room version is hardly justified. Further, clients may trivially fail to perform a room version check when receiving the event and apply the changes anyways (though this may be helped by servers soft-failing m.room.reinstate events in unsupported room versions).

Some options to continue avoiding a new room version and not cause undesirable client behaviour are explored in the following sections.

Option 1: Server-resent events

When /syncing, the client does not receive the m.room.reinstate event. Instead, the clients receive the reinstated events again with the content unredacted.

For example:

  1. Client /syncs, receives two events:
    • {"event_id": "$a", "type": "m.room.message", "content": {"body": "hi"}}
    • {"event_id": "$b", "type": "m.room.redaction", "content": {"redacts": "$a"}}
  2. The client locally redacts $a to have content: {}.
  3. The server receives event $c which reinstates $a's original content: {"event_id": "$c", "type": "m.room.reinstate", "content": {"$a": {"body": "hi"}}}
  4. Because we're on a happy path, the event is valid and passes all conditions.
  5. Client /syncs, receives $a with metadata to indicate it has been reinstated. The client does not receive the reinstate event, $c, directly.
    • {"event_id": "$a", "type": "m.room.message", "content": {"body": "hi"}, "unsigned": {"reinstated_by": "$c"}}
  6. Client unredacts its local copy of $a using the duplicate event.

The client would still receive $c during /messages, GET /event, etc, but does not attach behaviour to receipt of the event.

Likely, a sync filter flag similar to lazy loading would also be required to opt-in to this duplicate event behaviour, otherwise the client may get confused about seeing events twice. Clients which fail to opt-in would either simply not receive the reinstate event (forming a "gap" in the timeline), or would receive the reinstate event as-is and not be permitted to attach behaviour to the event's receipt.

Option 2: Client verifies server support

Assuming /sync is unmodified and serves m.room.reinstate directly, the client would verify the server supports this proposal's requirements before reinstating the target events locally. This would likely be done with a call to /versions to check for an unstable/stable feature flag or released specification version to indicate the server is processing the reinstate event correctly.

If the server does not outwardly show support for reinstate events, the client would not apply the event and simply ignore it.

It's unclear how a client should handle a server advertising support for reinstantiation after already receiving a reinstate event, or how the client would verify the reinstate event is valid.

This particular approach has a risk similar to the room version check described earlier in this proposal, where clients may fail to detect feature support on the server (or in the room) and apply the reinstate event regardless, leading to a visual security issue for the user.

Worked examples

Verbose basic example & test vectors

  1. A message is sent in a room:

    $bjW27hy4RlE6vhfboLMvUr_vxY8Dd7nYKof44nAhEkQ

    {
      "auth_events": [
        "$VPKbOoGaxXQaEsN_IiNvedVvWEXfN8u3uLn0LPMr8Ig",
        "$pN5FOV6ATa3PKgJ9xCNeSBgXaDjxy7plUzM2DHSKLgY",
        "$R6VJDJTanX4fJTmYYL0DPkseZjXeQI2qQpAp9K766xk"
      ],
      "content": {
        "body": "Hello world!",
        "m.mentions": {},
        "msgtype": "m.text"
      },
      "depth": 8,
      "hashes": {
        "sha256": "i3A/7ePt5si1fh+PuAi0oFPEQyOipoOhsGppLvvXDik"
      },
      "origin": "t2l.io",
      "origin_server_ts": 1709587032028,
      "prev_events": [
        "$9S3H3_jwtX7z0tEcvIqEh9VlAdgrIBVAIaT-Cpg0Uok"
      ],
      "room_id": "!bbPGWpTyDYppmybMgi:t2l.io",
      "sender": "@travis:t2l.io",
      "type": "m.room.message",
      "signatures": {
        "t2l.io": {
          "ed25519:a_iRjt": "l0NtAKOjjpjqgI4wy1K/gUSxSZednzw7/7sLalZNdEZ7z/X7jBHXP5I2RZYup1Mkkld27b61Y8RWV7y/Ox30Aw"
        }
      },
      "unsigned": {
        "age_ts": 1709587032028
      }
    }
    
  2. It is redacted:

    $1qjgT7LCSjGS3Dfs7VnitlPmpjI175rDfr_nhopLCP8

    {
      "auth_events": [
        "$VPKbOoGaxXQaEsN_IiNvedVvWEXfN8u3uLn0LPMr8Ig",
        "$pN5FOV6ATa3PKgJ9xCNeSBgXaDjxy7plUzM2DHSKLgY",
        "$R6VJDJTanX4fJTmYYL0DPkseZjXeQI2qQpAp9K766xk"
      ],
      "content": {},
      "depth": 9,
      "hashes": {
        "sha256": "WAFAW8aAAHIX5P3zAfQDaBgf1YJKouXKtdErRWuEq6Y"
      },
      "origin": "t2l.io",
      "origin_server_ts": 1709587154240,
      "prev_events": [
        "$bjW27hy4RlE6vhfboLMvUr_vxY8Dd7nYKof44nAhEkQ"
      ],
      "redacts": "$bjW27hy4RlE6vhfboLMvUr_vxY8Dd7nYKof44nAhEkQ",
      "room_id": "!bbPGWpTyDYppmybMgi:t2l.io",
      "sender": "@travis:t2l.io",
      "type": "m.room.redaction",
      "signatures": {
        "t2l.io": {
          "ed25519:a_iRjt": "7ukdLahzeIcWztPR0Ohylbu1cKWa163FfqECupjo9wA+Wtt/cv+vRV8S4juct6KaJ3CCP5kNdxya7IpG/zZjCA"
        }
      },
      "unsigned": {
        "age_ts": 1709587154240
      }
    }
    
  3. It is reinstated:

    $5jUO9TBHJ5j1NmrDKHlF3sTjHydYFEICwB3s8Vu3stk

    {
      "auth_events": [
        "$VPKbOoGaxXQaEsN_IiNvedVvWEXfN8u3uLn0LPMr8Ig",
        "$pN5FOV6ATa3PKgJ9xCNeSBgXaDjxy7plUzM2DHSKLgY",
        "$R6VJDJTanX4fJTmYYL0DPkseZjXeQI2qQpAp9K766xk"
      ],
      "content": {
        "$bjW27hy4RlE6vhfboLMvUr_vxY8Dd7nYKof44nAhEkQ": {
          "body": "Hello world!",
          "m.mentions": {},
          "msgtype": "m.text"
        }
      },
      "depth": 10,
      "hashes": {
        "sha256": "KEc6kmVY6mMLEzXHtJztXCxVwTirU3XHKngLuD9AdyE"
      },
      "origin": "t2l.io",
      "origin_server_ts": 1709587447747,
      "prev_events": [
        "$1qjgT7LCSjGS3Dfs7VnitlPmpjI175rDfr_nhopLCP8"
      ],
      "room_id": "!bbPGWpTyDYppmybMgi:t2l.io",
      "sender": "@travis:t2l.io",
      "type": "m.room.reinstate",
      "signatures": {
        "t2l.io": {
          "ed25519:a_iRjt": "aNzbc8auJNTQ9TjyEn/2ztnansDI7O3lmlhSC4SZyXEt36liJ+x+/q50AydbBTvbxu1uGFrzdQvWR6zulbBuDQ"
        }
      },
      "unsigned": {
        "age_ts": 1709587447747
      }
    }
    
  4. Combining the original (now-redacted) event with the reinstate event's content reveals the same content hash, therefore allowing the server and client to use the reinstate event's content.

    NOTE: Except it doesn't match because of depth - see Potential Issues section.

Edge case: Reinstating unredacted events

  1. An event is sent.
  2. A reinstate event is sent for that event.

There's technically nothing illegal about this, it's just weird. Redacting the reinstate event would have no effect, though redacting the original event would hide the event contents.

Edge case: Redaction after reinstating

  1. An event is sent.
  2. It is redacted.
  3. It is reinstated.
  4. It is redacted, again.

The target event is redacted at the end of this chain, though the reinstate event is not which may reveal event contents to the room still. See potential issues for more information.

Edge case: Redacting the reinstate event

  1. An event is sent.
  2. It is redacted.
  3. It is reinstated.
  4. The reinstate event is redacted.

Both the target event and reinstate event are redacted, hiding the message contents.

Potential issues

This proposal only supports reinstating events based on content, though the content hash covers top-level fields on an event as well. If an event had a custom top-level field, it is not possible to reinstate it under this proposal. This is done intentionally to avoid specifying a merge strategy for JSON objects or having total duplication of events inside m.room.reinstate events.

TODO: This^ is a massive problem because Synapse populates depth on events still, which serves no purpose and is an unprotected field. To make this proposal work in all existing room versions, and ideally with older events too, this proposal needs to handle top-level fields too.

Large events may not be possible to reinstate due to the overhead of the event ID in the m.room.reinstate content, as after signatures and hashes the reinstate event could be over the size limits. This is only expected to impact an extraordinarily small, but non-zero, number of events.

Discussed earlier in this proposal, if a target event is redacted after being reinstated but the reinstate event is left unredacted (either because the client doesn't know about it or a race condition), the contents of the target event are left exposed to the room. See the earlier TODO for solution context.

Alternatives

MSC3531 approaches the problem space in a different way, building a holding system before committing to a redaction.

MSC2815 additionally handles concepts related to this MSC's scope.

Security considerations

Implied throughout.

TODO: Reiterate here.

Unstable prefix

While this proposal is not considered stable, implementations should use org.matrix.msc4117.room.reinstate in place of m.room.reinstate throughout.

Dependencies

This proposal has no direct dependencies.