feat(join-auth): add key-ID rotation to signed join authorisations

Prerequisite for wiring the allocator to publish rosters. The signing
key is a shared HMAC secret mounted into both the allocator and the
allocated game server; without a key ID, rotating it would invalidate
every authorisation already issued for an in-flight match, because a
server holding only the new key cannot verify a token signed with the
old one.

Add KeyID to JoinAuthorisation and append it to the canonical claim
bytes, so it is covered by the signature and cannot be repointed at a
different key than the one that actually signed. Allocated servers now
hold a set of currently-valid keys and select by ID: a rotation
publishes the new key alongside the old, and the old is dropped once no
live match can still reference it.

The key file becomes a JSON map of key ID to base64 key. A file of raw
key bytes is still accepted as a single key under the empty ID, which is
what an unrotated deployment and the kind fixture use.

Game/scripts/match_net.gd builds the canonical bytes independently, so
it changes in lockstep; the cross-language golden token in
test_match_net.gd is regenerated from the Go implementation and now
carries a key ID. Added tests cover accepting either key mid-rotation,
rejecting a retired key ID, and rejecting a token whose key ID was
swapped to name a key the server does hold.

Go suite and 223 Godot tests pass.
This commit is contained in:
Josh Creek
2026-09-05 10:36:06 +01:00
parent 5453e19761
commit b8bcc1f3c1
6 changed files with 135 additions and 18 deletions
+7 -3
View File
@@ -31,12 +31,16 @@ import base64, hashlib, hmac, json, pathlib, sys, time
directory = pathlib.Path(sys.argv[1])
key = b"compose-join-signing-key"
key_id = "compose-key-1"
expires = "2099-12-31T00:00:00Z"
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires]
# Field order and the trailing key ID must match
# server/domain.JoinAuthorisationBytes and Game/scripts/match_net.gd.
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires, key_id]
canonical = b"\0".join(field.encode() for field in fields)
signature = base64.urlsafe_b64encode(hmac.new(key, canonical, hashlib.sha256).digest()).rstrip(b"=").decode()
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires}, "Signature": signature}
(directory / "join-signing-key").write_bytes(key)
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires, "KeyID": key_id}, "Signature": signature}
# The key file maps key ID -> base64 key so a rotation can publish several.
(directory / "join-signing-key").write_text(json.dumps({key_id: base64.b64encode(key).decode()}) + "\n")
(directory / "join-roster.json").write_text(json.dumps([base64.urlsafe_b64encode(json.dumps(envelope, separators=(",", ":")).encode()).rstrip(b"=").decode()]) + "\n")
PY