feat: verify allocated join authorisations with hmac

This commit is contained in:
Josh Creek
2026-09-01 08:48:48 +01:00
parent 66a1ee8007
commit e25d61d80e
7 changed files with 64 additions and 5 deletions
+25 -1
View File
@@ -56,6 +56,7 @@ var require_join_authorisation := false
var _allowed_join_authorisations: Dictionary = {}
var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id
var _join_authorisation_context: Dictionary = {}
var _join_signing_key := PackedByteArray()
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
@@ -92,10 +93,11 @@ func _on_shutting_down() -> void:
_allowed_join_authorisations.clear()
_active_join_peers.clear()
_join_authorisation_context.clear()
_join_signing_key = PackedByteArray()
require_join_authorisation = false
func configure_join_authorisations(tokens: Array, context: Dictionary) -> bool:
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_key: PackedByteArray = PackedByteArray()) -> bool:
var allowed := {}
for token in tokens:
if not token is String or String(token).is_empty():
@@ -105,6 +107,7 @@ func configure_join_authorisations(tokens: Array, context: Dictionary) -> bool:
return false
_allowed_join_authorisations = allowed
_join_authorisation_context = context.duplicate(true)
_join_signing_key = signing_key.duplicate()
require_join_authorisation = true
return true
@@ -233,6 +236,27 @@ func _valid_join_authorisation(token: String) -> bool:
var protocol := str(claims.get("Protocol", ""))
var expires_at := str(claims.get("ExpiresAt", ""))
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
if not _join_signing_key.is_empty():
var signature_token := str(envelope["Signature"])
var signature := Marshalls.base64_to_raw(signature_token)
if signature.size() != 32:
return false
var canonical := PackedByteArray()
var fields := [
str(claims.get("MatchID", "")), str(claims.get("ServerID", "")),
str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")),
str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol,
str(int(claims.get("Generation", 0))), expires_at,
]
for index in fields.size():
canonical.append_array(String(fields[index]).to_utf8_buffer())
if index < fields.size() - 1:
canonical.append(0)
var hmac := HMACContext.new()
hmac.start(HashingContext.HASH_SHA256, _join_signing_key)
hmac.update(canonical)
if hmac.finish() != signature:
return false
return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \
and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \
and protocol == str(_join_authorisation_context.get("protocol", "")) \
+4 -2
View File
@@ -64,14 +64,16 @@ func _ready() -> void:
return
if allocated_mode:
var roster_file := String(config.get_value("join-authorisations-file"))
var key_file := String(config.get_value("join-authorisations-key-file"))
var roster_json := FileAccess.get_file_as_string(roster_file)
var signing_key := FileAccess.get_file_as_bytes(key_file)
var roster_tokens = JSON.parse_string(roster_json)
if not roster_tokens is Array or roster_tokens.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
if not roster_tokens is Array or roster_tokens.is_empty() or signing_key.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
"match_id": String(config.get_value("match-id")),
"server_id": String(config.get_value("server-id")),
"protocol": str(NetCodec.PROTOCOL_VERSION),
"protocol_version": NetCodec.PROTOCOL_VERSION,
}):
}, signing_key):
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
get_tree().quit(1)
return
+3
View File
@@ -76,6 +76,7 @@ static func specs() -> Array[Spec]:
out.append(Spec.new("transport", Kind.STRING, "", "allocation", "Assigned transport: steam_sdr or enet"))
out.append(Spec.new("region", Kind.STRING, "", "allocation", "Assigned region: EU or NA"))
out.append(Spec.new("join-authorisations-file", Kind.STRING, "", "allocation", "JSON array of control-plane signed join envelopes mounted for this match"))
out.append(Spec.new("join-authorisations-key-file", Kind.STRING, "", "allocation", "HMAC-SHA256 key file for verifying mounted join envelopes"))
return out
@@ -269,6 +270,8 @@ func _validate() -> void:
errors.append("--assignment-expiry-unix must be in the future")
if String(values["join-authorisations-file"]).is_empty():
errors.append("--join-authorisations-file is required in allocated mode")
if String(values["join-authorisations-key-file"]).is_empty():
errors.append("--join-authorisations-key-file is required in allocated mode")
var digest := String(values["server-image-digest"])
if not _is_sha256_digest(digest):
errors.append("--server-image-digest must be sha256:<64 hex characters>")
+16
View File
@@ -57,3 +57,19 @@ func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> v
assert_true(not match_net.is_join_authorisation_active(token), "validated token is not active before admission")
match_net._active_join_peers[token] = 42
assert_true(match_net.is_join_authorisation_active(token), "active token is visible to the duplicate-admission guard")
func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
# This envelope is generated from server/domain.JoinAuthorisationBytes with
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
# canonical representation rather than merely checking token membership.
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjIsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIn0sIlNpZ25hdHVyZSI6IkQ0VmVEejJheVh3Y1J3bFZUc3JkUW1YS3FYYzRmVG05RnByTjRYK3ZzM1k9In0="
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, "test-key".to_utf8_buffer()), "HMAC roster configures")
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
var tampered_payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(token).get_string_from_utf8())
tampered_payload["Signature"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
var tampered_token := Marshalls.raw_to_base64(JSON.stringify(tampered_payload).to_utf8_buffer())
var tampered_match_net := MatchNet.new()
assert_true(tampered_match_net.configure_join_authorisations([tampered_token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, "test-key".to_utf8_buffer()), "tampered roster fixture configures")
assert_true(not tampered_match_net._valid_join_authorisation(tampered_token), "allowlisted but forged signature is rejected")
+1 -1
View File
@@ -137,7 +137,7 @@ func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void
var valid = _parse([
"--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456",
"--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64),
"--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json"
"--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
])
assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors))