Files
Warp/server/internal/matrix/event/hash.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

137 lines
4.6 KiB
Go

package event
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
)
// ErrContentHashMismatch means an event's content does not match the hash
// it carries.
//
// Over federation this is not necessarily an attack and not necessarily
// fatal: the usual cause is being sent a redacted copy of an event whose
// signature still verifies, because the signature covers only the keys
// redaction keeps. The caller's answer is to use the redacted form rather
// than to drop the event.
var ErrContentHashMismatch = errors.New("event content does not match its content hash")
// contentHash computes the content hash of an event: SHA-256 over the
// canonical JSON of the event with unsigned, signatures and hashes removed.
//
// Each of the three is excluded for its own reason. unsigned is explicitly
// rewritable in transit; signatures depend on the hash, so including it
// would be circular; and hashes is left out so that a future algorithm can
// be added alongside sha256 without changing what sha256 covers.
func contentHash(raw []byte) ([sha256.Size]byte, error) {
obj, err := decodeObject(raw)
if err != nil {
return [sha256.Size]byte{}, err
}
delete(obj, "unsigned")
delete(obj, "signatures")
delete(obj, "hashes")
encoded, err := canonicaljson.Marshal(obj)
if err != nil {
return [sha256.Size]byte{}, fmt.Errorf("%w: content hash: %w", ErrInvalidEvent, err)
}
return sha256.Sum256(encoded), nil
}
// referenceHash computes the reference hash of an event, which is what its
// ID is made of: the event is redacted, then signatures and unsigned are
// removed, then it is canonicalised and hashed.
//
// Redacting first is what makes an event ID survive redaction — the ID
// covers only the keys a redaction keeps, so redacting an event does not
// rename it.
func referenceHash(raw []byte, rules roomversion.Rules) ([sha256.Size]byte, error) {
redacted, err := redactJSON(raw, rules)
if err != nil {
return [sha256.Size]byte{}, err
}
obj, err := decodeObject(redacted)
if err != nil {
return [sha256.Size]byte{}, err
}
delete(obj, "signatures")
delete(obj, "unsigned")
encoded, err := canonicaljson.Marshal(obj)
if err != nil {
return [sha256.Size]byte{}, fmt.Errorf("%w: reference hash: %w", ErrInvalidEvent, err)
}
return sha256.Sum256(encoded), nil
}
// computeEventID derives an event ID from the event's reference hash.
func computeEventID(raw []byte, rules roomversion.Rules) (string, error) {
sum, err := referenceHash(raw, rules)
if err != nil {
return "", err
}
switch rules.EventIDFormat {
case roomversion.EventIDFormatSHA256:
return "$" + base64.RawStdEncoding.EncodeToString(sum[:]), nil
case roomversion.EventIDFormatSHA256URLSafe:
// URL-safe, because an ID with a '/' in it cannot be put in a path
// — which is where event IDs ended up in the federation API.
return "$" + base64.RawURLEncoding.EncodeToString(sum[:]), nil
default:
return "", fmt.Errorf("%w: room version %s does not derive event IDs from a hash",
ErrInvalidEvent, rules.Version)
}
}
// ReferenceHash returns the event's reference hash, unencoded. Federation
// needs it for the room version 1 and 2 event format and for auth chain
// comparisons.
func (e *Event) ReferenceHash() ([]byte, error) {
sum, err := referenceHash(e.raw, e.rules)
if err != nil {
return nil, err
}
return sum[:], nil
}
// VerifyContentHash checks the event against the content hash it carries.
//
// It returns ErrContentHashMismatch when the hash does not match, which a
// federation caller should answer by using the redacted copy of the event
// rather than by rejecting it.
func (e *Event) VerifyContentHash() error {
want, err := signing.DecodeBase64(e.fields.Hashes.SHA256)
if err != nil {
return fmt.Errorf("%w: hashes.sha256: %w", ErrInvalidEvent, err)
}
got, err := contentHash(e.raw)
if err != nil {
return err
}
if !bytes.Equal(want, got[:]) {
return ErrContentHashMismatch
}
return nil
}
// decodeObject parses an event into its properties, leaving every value as
// the bytes it arrived as so that nothing we do not understand is lost.
func decodeObject(raw []byte) (map[string]json.RawMessage, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidEvent, err)
}
if obj == nil {
return nil, fmt.Errorf("%w: event is null, not an object", ErrInvalidEvent)
}
return obj, nil
}