diff --git a/deploy/compose/appsettings.Production.json b/deploy/compose/appsettings.Production.json index a5d3c80..2c0dc0e 100644 --- a/deploy/compose/appsettings.Production.json +++ b/deploy/compose/appsettings.Production.json @@ -32,6 +32,16 @@ "NotBefore": "2026-01-01T00:00:00Z", "SignUntil": "2100-01-01T00:00:00Z", "VerifyUntil": "2100-01-02T00:00:00Z" + }, + { + "KeyId": "local-smoke-unscouted-1", + "SecretReference": "file:/run/secrets/rendezvous-signing-key", + "CredentialKinds": ["DedicatedPublisher"], + "GameId": "unscouted", + "EnvironmentId": "smoke", + "NotBefore": "2026-01-01T00:00:00Z", + "SignUntil": "2100-01-01T00:00:00Z", + "VerifyUntil": "2100-01-02T00:00:00Z" } ], "Games": [ @@ -53,6 +63,27 @@ "MaxAnonymousListingsPerAddress": 0, "MaxActiveJoinAttempts": 100, "FallbackPolicy": "DedicatedEndpointAllowed" + }, + { + "GameId": "unscouted", + "EnvironmentId": "smoke", + "Enabled": true, + "ProtocolVersions": [1], + "Regions": ["local"], + "VisibilityModes": ["Public"], + "PublisherTrustModes": ["ManagedDedicated"], + "MetadataValueMaxBytes": { + "mode": 32, + "world": 64, + "mods": 64 + }, + "RequiredMetadataKeys": ["mode", "world", "mods"], + "MetadataMaxBytes": 512, + "MetadataMaxKeys": 3, + "MaxListingsPerPrincipal": 10, + "MaxAnonymousListingsPerAddress": 0, + "MaxActiveJoinAttempts": 100, + "FallbackPolicy": "DedicatedEndpointAllowed" } ] } diff --git a/scripts/mint-local-publisher-credential.sh b/scripts/mint-local-publisher-credential.sh index b6151ac..5383055 100755 --- a/scripts/mint-local-publisher-credential.sh +++ b/scripts/mint-local-publisher-credential.sh @@ -3,9 +3,25 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}" +GAME_ID="${RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID:-space-game}" + +case "$GAME_ID" in + space-game) + KEY_ID="local-smoke-1" + SUBJECT="local-smoke-host" + ;; + unscouted) + KEY_ID="local-smoke-unscouted-1" + SUBJECT="local-smoke-unscouted-host" + ;; + *) + printf 'RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted.\n' >&2 + exit 2 + ;; +esac if (( $# != 0 )); then - printf 'This helper accepts no arguments and mints only the fixed local Compose smoke scope.\n' >&2 + printf 'This helper accepts no arguments; select only a provisioned local game through RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID.\n' >&2 exit 2 fi @@ -17,7 +33,7 @@ command -v python3 >/dev/null || { # This is deliberately a local-fixture tool, not a general credential issuer. # Python reads the raw key from the protected file; key material never appears in # a child process argument, environment value, temporary file, or command output. -python3 - "$LOCAL_KEY" <<'PY' +python3 - "$LOCAL_KEY" "$GAME_ID" "$KEY_ID" "$SUBJECT" <<'PY' import base64 import hashlib import hmac @@ -29,6 +45,9 @@ import sys import time key_path = sys.argv[1] +game_id = sys.argv[2] +key_id = sys.argv[3] +subject = sys.argv[4] try: metadata = os.lstat(key_path) except FileNotFoundError: @@ -55,9 +74,9 @@ payload = { "version": 1, "issuer": "final-factory-rendezvous-smoke", "audience": "rendezvous-service", - "subject": "local-smoke-host", + "subject": subject, "kind": "dedicatedPublisher", - "gameId": "space-game", + "gameId": game_id, "environmentId": "smoke", "regions": ["local"], "permissions": [], @@ -71,7 +90,7 @@ def base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) -signed = f"rv1.local-smoke-1.{encoded}" +signed = f"rv1.{key_id}.{encoded}" signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest()) print(f"{signed}.{signature}") PY diff --git a/tests/FinalFactory.Rendezvous.Tests/Deployment/ProductionProcessTests.cs b/tests/FinalFactory.Rendezvous.Tests/Deployment/ProductionProcessTests.cs index 9410a34..849600f 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Deployment/ProductionProcessTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Deployment/ProductionProcessTests.cs @@ -238,6 +238,10 @@ public sealed class ProductionProcessTests "--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"), "--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"), "--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"), + "--Rendezvous:Provisioning:SigningKeys:1:SecretReference", $"file:{secretPath}", + "--Rendezvous:Provisioning:SigningKeys:1:NotBefore", now.AddHours(-1).ToString("O"), + "--Rendezvous:Provisioning:SigningKeys:1:SignUntil", now.AddHours(1).ToString("O"), + "--Rendezvous:Provisioning:SigningKeys:1:VerifyUntil", now.AddHours(2).ToString("O"), "--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture), "--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture), }, diff --git a/tests/FinalFactory.Rendezvous.Tests/Documentation/DocumentationContractTests.cs b/tests/FinalFactory.Rendezvous.Tests/Documentation/DocumentationContractTests.cs index 0b1858d..e5cf04c 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Documentation/DocumentationContractTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Documentation/DocumentationContractTests.cs @@ -40,14 +40,19 @@ public sealed partial class DocumentationContractTests } [Fact] - public void LocalCredentialHelperIsFixedScopeAndSmokeDelegatesToIt() + public void LocalCredentialHelperWhitelistsProvisionedGameScopesAndSmokeDelegatesToIt() { string root = FindRepositoryRoot(); string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh")); string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh")); Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal); - Assert.Contains("\"gameId\": \"space-game\"", helper, StringComparison.Ordinal); + Assert.Contains("space-game)", helper, StringComparison.Ordinal); + Assert.Contains("unscouted)", helper, StringComparison.Ordinal); + Assert.Contains("KEY_ID=\"local-smoke-1\"", helper, StringComparison.Ordinal); + Assert.Contains("KEY_ID=\"local-smoke-unscouted-1\"", helper, StringComparison.Ordinal); + Assert.Contains("RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted", helper, StringComparison.Ordinal); + Assert.Contains("\"gameId\": game_id", helper, StringComparison.Ordinal); Assert.Contains("\"environmentId\": \"smoke\"", helper, StringComparison.Ordinal); Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal); Assert.Contains("now + 600", helper, StringComparison.Ordinal); @@ -60,6 +65,32 @@ public sealed partial class DocumentationContractTests Assert.DoesNotContain("openssl dgst", smoke, StringComparison.Ordinal); } + [Fact] + public void UnscoutedComposeTenantIsGameScopedAndMetadataBounded() + { + string root = FindRepositoryRoot(); + using JsonDocument settings = JsonDocument.Parse(File.ReadAllText( + Path.Combine(root, "deploy", "compose", "appsettings.Production.json"))); + JsonElement provisioning = settings.RootElement.GetProperty("Rendezvous").GetProperty("Provisioning"); + JsonElement game = provisioning.GetProperty("Games").EnumerateArray().Single( + static item => item.GetProperty("GameId").GetString() == "unscouted"); + JsonElement key = provisioning.GetProperty("SigningKeys").EnumerateArray().Single( + static item => item.GetProperty("KeyId").GetString() == "local-smoke-unscouted-1"); + + Assert.Equal("smoke", game.GetProperty("EnvironmentId").GetString()); + Assert.Equal([1], game.GetProperty("ProtocolVersions").EnumerateArray().Select(static value => value.GetInt32())); + Assert.Equal(["mode", "mods", "world"], game.GetProperty("MetadataValueMaxBytes") + .EnumerateObject().Select(static property => property.Name).Order(StringComparer.Ordinal)); + Assert.Equal(["mode", "mods", "world"], game.GetProperty("RequiredMetadataKeys") + .EnumerateArray().Select(static value => value.GetString()).Order(StringComparer.Ordinal)); + Assert.Equal(3, game.GetProperty("MetadataMaxKeys").GetInt32()); + Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString()); + Assert.Equal("unscouted", key.GetProperty("GameId").GetString()); + Assert.Equal("smoke", key.GetProperty("EnvironmentId").GetString()); + Assert.Equal(["DedicatedPublisher"], key.GetProperty("CredentialKinds") + .EnumerateArray().Select(static value => value.GetString())); + } + [Fact] public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates() {