Files
Warp/server/internal/matrix/signing/json_test.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

257 lines
7.6 KiB
Go

package signing
import (
"crypto/ed25519"
"encoding/base64"
"errors"
"strings"
"testing"
)
// The signing key, server name and key ID from the spec appendices,
// section "Cryptographic Test Vectors". Every implementation that signs
// JSON should produce the signatures below from them.
const (
specSeed = "YJDBA9Xnr2sVqXD9Vj7XVUnmFZcZrlw8Md7kMW+3XA1"
specServerName = "domain"
specKeyID = "1"
)
func specKey(t *testing.T) *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 &KeyPair{ID: specKeyID, Private: priv, Public: pub}
}
// TestSpecJSONSigningVectors signs the two objects from the spec and
// compares the result byte for byte.
func TestSpecJSONSigningVectors(t *testing.T) {
key := specKey(t)
cases := []struct {
name string
in string
want string
}{
{
name: "empty object",
in: `{}`,
want: `{"signatures":{"domain":{"ed25519:1":"K8280/U9SSy9IVtjBuVeLr+HpOB4BQFWbg+UZaADMtTdGYI7Geitb76LTrr5QV/7Xg4ahLwYGYZzuHGZKM5ZAQ"}}}`,
},
{
name: "object with data",
in: `{"one":1,"two":"Two"}`,
want: `{"one":1,"signatures":{"domain":{"ed25519:1":"KqmLSbO39/Bzb0QIYE82zqLwsA+PDzYIpIRA2sRQ4sL53+sN6/fpNSoqE7BP7vBZhG6kYdD13EIMJpvhJI+6Bw"}},"two":"Two"}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := SignJSON(specServerName, key, []byte(tc.in))
if err != nil {
t.Fatalf("SignJSON: %v", err)
}
if string(got) != tc.want {
t.Errorf("SignJSON() =\n %s\nwant\n %s", got, tc.want)
}
if err := VerifyJSON(specServerName, "ed25519:"+specKeyID, key.Public, got); err != nil {
t.Errorf("VerifyJSON on our own signature: %v", err)
}
})
}
}
// TestSignatureIgnoresUnsignedAndSignatures is the property that lets a
// server add a signature, or a timestamp under unsigned, to an object
// without invalidating what is already there.
func TestSignatureIgnoresUnsignedAndSignatures(t *testing.T) {
key := specKey(t)
keyID := "ed25519:" + specKeyID
signed, err := SignJSON(specServerName, key, []byte(`{"a":1,"unsigned":{"age":5}}`))
if err != nil {
t.Fatalf("SignJSON: %v", err)
}
if !strings.Contains(string(signed), `"unsigned":{"age":5}`) {
t.Errorf("unsigned was dropped from the signed object: %s", signed)
}
// Rewrite unsigned and add another server's signature; ours must
// still verify.
tampered := strings.Replace(string(signed), `"age":5`, `"age":9999`, 1)
if err := VerifyJSON(specServerName, keyID, key.Public, []byte(tampered)); err != nil {
t.Errorf("changing unsigned broke the signature: %v", err)
}
other, err := Generate("other")
if err != nil {
t.Fatal(err)
}
countersigned, err := SignJSON("second.example", other, signed)
if err != nil {
t.Fatalf("SignJSON for a second server: %v", err)
}
if err := VerifyJSON(specServerName, keyID, key.Public, countersigned); err != nil {
t.Errorf("a second signature broke the first: %v", err)
}
if err := VerifyJSON("second.example", other.KeyID(), other.Public, countersigned); err != nil {
t.Errorf("the second signature does not verify: %v", err)
}
}
// TestSignJSONReplacesOurOwnSignature checks that signing twice with the
// same key leaves one signature, not two entries fighting over the slot.
func TestSignJSONReplacesOurOwnSignature(t *testing.T) {
key := specKey(t)
once, err := SignJSON(specServerName, key, []byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
twice, err := SignJSON(specServerName, key, once)
if err != nil {
t.Fatal(err)
}
if string(once) != string(twice) {
t.Errorf("signing twice changed the object:\n %s\n %s", once, twice)
}
}
func TestVerifyJSONRejects(t *testing.T) {
key := specKey(t)
keyID := "ed25519:" + specKeyID
signed, err := SignJSON(specServerName, key, []byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
t.Run("content changed", func(t *testing.T) {
tampered := strings.Replace(string(signed), `"a":1`, `"a":2`, 1)
err := VerifyJSON(specServerName, keyID, key.Public, []byte(tampered))
if !errors.Is(err, ErrBadSignature) {
t.Errorf("error = %v, want ErrBadSignature", err)
}
})
t.Run("wrong key", func(t *testing.T) {
other, err := Generate("other")
if err != nil {
t.Fatal(err)
}
err = VerifyJSON(specServerName, keyID, other.Public, signed)
if !errors.Is(err, ErrBadSignature) {
t.Errorf("error = %v, want ErrBadSignature", err)
}
})
t.Run("no signature from that server", func(t *testing.T) {
err := VerifyJSON("somebody.else", keyID, key.Public, signed)
if !errors.Is(err, ErrNoSignature) {
t.Errorf("error = %v, want ErrNoSignature", err)
}
})
t.Run("no signature with that key ID", func(t *testing.T) {
err := VerifyJSON(specServerName, "ed25519:other", key.Public, signed)
if !errors.Is(err, ErrNoSignature) {
t.Errorf("error = %v, want ErrNoSignature", err)
}
})
t.Run("signature is not base64", func(t *testing.T) {
broken := strings.Replace(string(signed), `"ed25519:1":"`, `"ed25519:1":"!!!`, 1)
err := VerifyJSON(specServerName, keyID, key.Public, []byte(broken))
if !errors.Is(err, ErrBadSignature) {
t.Errorf("error = %v, want ErrBadSignature", err)
}
})
t.Run("signature is the wrong length", func(t *testing.T) {
short, err := SignJSON(specServerName, key, []byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
broken := strings.Replace(string(short), `"ed25519:1":"`, `"ed25519:1":"AAAA`, 1)
err = VerifyJSON(specServerName, keyID, key.Public, []byte(broken))
if !errors.Is(err, ErrBadSignature) {
t.Errorf("error = %v, want ErrBadSignature", err)
}
})
}
func TestSigningBytes(t *testing.T) {
got, err := SigningBytes([]byte(`{"b":2,"signatures":{"x":{}},"unsigned":{"age":1},"a":1}`))
if err != nil {
t.Fatalf("SigningBytes: %v", err)
}
if want := `{"a":1,"b":2}`; string(got) != want {
t.Errorf("SigningBytes() = %s, want %s", got, want)
}
}
func TestExtractSignatures(t *testing.T) {
sigs, err := ExtractSignatures([]byte(`{"signatures":{"a.example":{"ed25519:1":"sig"}}}`))
if err != nil {
t.Fatalf("ExtractSignatures: %v", err)
}
if got, ok := sigs.Get("a.example", "ed25519:1"); !ok || got != "sig" {
t.Errorf("Get() = %q, %v; want \"sig\", true", got, ok)
}
if _, ok := sigs.Get("b.example", "ed25519:1"); ok {
t.Error("Get found a signature for a server that has none")
}
none, err := ExtractSignatures([]byte(`{}`))
if err != nil {
t.Fatalf("ExtractSignatures on an unsigned object: %v", err)
}
if none != nil {
t.Errorf("ExtractSignatures() = %v, want nil for an unsigned object", none)
}
}
func TestSplitKeyID(t *testing.T) {
cases := []struct {
in string
algorithm string
id string
ok bool
}{
{"ed25519:abc", "ed25519", "abc", true},
{"ed25519:", "", "", false},
{":abc", "", "", false},
{"ed25519", "", "", false},
{"", "", "", false},
}
for _, tc := range cases {
algorithm, id, ok := SplitKeyID(tc.in)
if algorithm != tc.algorithm || id != tc.id || ok != tc.ok {
t.Errorf("SplitKeyID(%q) = %q, %q, %v; want %q, %q, %v",
tc.in, algorithm, id, ok, tc.algorithm, tc.id, tc.ok)
}
}
}
func TestSignJSONRejectsBadInput(t *testing.T) {
key := specKey(t)
for _, tc := range []struct{ name, in string }{
{"not an object", `[1,2]`},
{"null", `null`},
{"not JSON", `{`},
{"a float somewhere", `{"a":1.5}`},
} {
if _, err := SignJSON(specServerName, key, []byte(tc.in)); err == nil {
t.Errorf("SignJSON(%s) succeeded, want an error", tc.name)
}
}
if _, err := SignJSON("", key, []byte(`{}`)); err == nil {
t.Error("SignJSON with no server name succeeded, want an error")
}
}