feat: sign reconnect authorisations

This commit is contained in:
Josh Creek
2026-08-31 21:44:27 +01:00
parent 4b5e40bff7
commit 7b6e22292a
4 changed files with 82 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
package domain
import (
"fmt"
"time"
)
// SignedJoinAuthorisation is the transport envelope. The signing primitive is
// supplied by the backend signer so this policy stays independent of key
// storage and cryptographic algorithm choice.
type SignedJoinAuthorisation struct {
Authorisation JoinAuthorisation
Signature []byte
}
func JoinAuthorisationBytes(auth JoinAuthorisation) []byte {
return []byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%s\x00%d\x00%s",
auth.MatchID, auth.ServerID, auth.PlayerID, auth.SteamID, auth.Slot, auth.Team, auth.Protocol, auth.Generation, auth.ExpiresAt.UTC().Format(time.RFC3339Nano)))
}
func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, error)) (SignedJoinAuthorisation, error) {
if sign == nil {
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
}
signature, err := sign(JoinAuthorisationBytes(auth))
if err != nil || len(signature) == 0 {
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
}
return SignedJoinAuthorisation{Authorisation: auth, Signature: append([]byte(nil), signature...)}, nil
}
func (r *RankedConnections) AdmitSigned(signed SignedJoinAuthorisation, verify func([]byte, []byte) bool, now time.Time) (uint64, error) {
if len(signed.Signature) == 0 || verify == nil || !verify(JoinAuthorisationBytes(signed.Authorisation), signed.Signature) {
return 0, ErrJoinAuthorisation
}
return r.Admit(signed.Authorisation, now)
}
+41
View File
@@ -1,6 +1,8 @@
package domain
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"testing"
"time"
@@ -92,3 +94,42 @@ func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) {
t.Fatalf("abandonment repeated: %+v", again)
}
}
func TestSignedJoinAuthorisationBindsEveryClaimBeforeReclaim(t *testing.T) {
now := time.Unix(1000, 0).UTC()
r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now))
if err != nil {
t.Fatal(err)
}
key := []byte("test-signing-key")
sign := func(payload []byte) ([]byte, error) {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write(payload)
return mac.Sum(nil), nil
}
verify := func(payload, signature []byte) bool {
expected, _ := sign(payload)
return hmac.Equal(expected, signature)
}
signed, err := SignJoinAuthorisation(testRoster(now)[0], sign)
if err != nil {
t.Fatal(err)
}
if gen, err := r.AdmitSigned(signed, verify, now); err != nil || gen != 1 {
t.Fatalf("signed initial admit = %d, %v", gen, err)
}
if err := r.Disconnect("a", 1, now); err != nil {
t.Fatal(err)
}
tampered := signed
tampered.Authorisation.Slot = 1
if _, err := r.AdmitSigned(tampered, verify, now.Add(time.Second)); !errors.Is(err, ErrJoinAuthorisation) {
t.Fatalf("tampered slot accepted: %v", err)
}
if _, err := r.AdmitSigned(signed, func([]byte, []byte) bool { return false }, now.Add(RankedReconnectGrace)); !errors.Is(err, ErrJoinAuthorisation) {
t.Fatalf("unverified signature accepted: %v", err)
}
if gen, err := r.AdmitSigned(signed, verify, now.Add(RankedReconnectGrace)); err != nil || gen != 2 {
t.Fatalf("signed reclaim = %d, %v", gen, err)
}
}