mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: verify allocated join authorisations with hmac
This commit is contained in:
@@ -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", "")) \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+1
-1
@@ -1229,7 +1229,7 @@ the local/CI/community transport, not a silent production fallback.
|
||||
|---|---|---|
|
||||
| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu; authenticated proposal recovery now reconciles missed proposal events and expires them at read time; the Go API publishes targeted authenticated revisioned queue/proposal/assignment events and the Godot client consumes state/proposal/assignment events, fetching the authoritative assignment after assignment readiness; the API can now use the durable participant-scoped proposal provider for both recovery and accept/decline mutations | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd`, `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary`, `TestAuthenticatedWebSocketDeliversOnlyTargetedRevisionedEvents`, `TestStateChangingAPIActionsPublishTargetedEvents`, `TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped` and store proposal SQL tests reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; wiring durable allocation events into the outbox, wait/latency explanations and Godot runtime verification remain |
|
||||
| 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read; Godot client projection persists non-secret ticket/proposal state, forces authoritative recovery after restart, and can replay a lost queue-create response with the original ticket/idempotency key; the Go API now exposes an authenticated `/v1/events` WebSocket with bounded per-player queues, strict upgrade/vocabulary validation and REST-resync-safe slow-client failure; Godot `ControlPlaneClient` can connect to the stream using Godot 4.7's handshake-header API, consume validated events, automatically reconnect with bounded backoff, and trigger REST recovery on projection gaps and stream return; `OutboxDispatcher` now provides ordered at-least-once delivery after durable commit; assignment recovery events carry the durable assignment revision without leaking it into REST JSON; prediction startup now distinguishes the sequence-0 warm-up acknowledgement from genuine missing/evicted history, preventing a false hard-snap during a live match | `server/domain/sync.go`, `server/api/events.go`, `server/api/service.go`, `server/store/outbox.go`, `service_test.go`, `outbox_test.go`, `matchmaking_state.gd`, `control_plane_client.gd`, `local_prediction_history.gd`, `net_ship_predictor.gd` and tests cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling, malformed restart snapshots, API-level duplicate-create replay/conflict, authenticated handshake/key rejection, targeted event delivery, exactly-once slow-subscriber closure, invalid-event rejection, delivery-before-ack failure ordering, assignment event/response revision separation and prediction warm-up/hard-resync separation; Godot 4.7.1 headless project parse and 143-test unit harness pass with compatibility rendering, and a two-process ENet authoritative match smoke passes spawn, movement, prediction, 0% snapshot loss and 0 hard snaps; wiring the dispatcher to a production WebSocket/Redis worker and live multi-process control-plane/game verification remain |
|
||||
| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` now starts only the validated ENet/Steam transport after assignment readiness; the opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; allocated Godot servers now fail closed unless an operator-mounted JSON roster of control-plane signed envelopes is present, and MatchNet checks exact token membership plus match/server/protocol/slot/expiry claims before admitting a peer, rejects concurrent reuse of an active token, and releases it on disconnect; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `server_boot.gd`, `server_config.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `test_match_net.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting, allowlisted claim rejection, duplicate active-token rejection and 145-test Godot compatibility coverage; cryptographic signature verification inside the Godot process, SDR relay-ticket installation, reconnect generation fencing and live Godot/PostgreSQL verification remain |
|
||||
| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` now starts only the validated ENet/Steam transport after assignment readiness; the opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; allocated Godot servers now fail closed unless an operator-mounted JSON roster of control-plane signed envelopes and an HMAC-SHA256 key are present, and MatchNet verifies the canonical Go claim bytes, checks exact token membership plus match/server/protocol/slot/expiry claims before admitting a peer, rejects concurrent reuse of an active token, and releases it on disconnect; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `server_boot.gd`, `server_config.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `test_match_net.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting, allowlisted claim rejection, canonical HMAC interoperability, forged-signature rejection, duplicate active-token rejection and 146-test Godot compatibility coverage; SDR relay-ticket installation, reconnect generation fencing and live Godot/PostgreSQL verification remain |
|
||||
| 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; ranked profile fetch/display, committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification |
|
||||
| 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; decline, version mismatch, regional outage retry UI, failed reconnect, duplicate-action recovery and live Godot verification remain |
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +31,18 @@ func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, er
|
||||
return SignedJoinAuthorisation{Authorisation: auth, Signature: append([]byte(nil), signature...)}, nil
|
||||
}
|
||||
|
||||
// SignJoinAuthorisationHMAC is the interoperable production profile used by
|
||||
// the Godot allocated server. The key is mounted out-of-band; the signed
|
||||
// bytes remain the same canonical claim bytes used by the generic signer.
|
||||
func SignJoinAuthorisationHMAC(auth JoinAuthorisation, key []byte) (SignedJoinAuthorisation, error) {
|
||||
if len(key) == 0 {
|
||||
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write(JoinAuthorisationBytes(auth))
|
||||
return SignedJoinAuthorisation{Authorisation: auth, Signature: mac.Sum(nil)}, 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
|
||||
|
||||
Reference in New Issue
Block a user