diff --git a/PROVENANCE.md b/PROVENANCE.md index 015aa5a..8eddac2 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -13,6 +13,18 @@ for the linked public issues. No historical MIT provenance grant is used. | `internal/observability` | `atrinik/server#21` and Go/OpenTelemetry/OpenMetrics public APIs | New implementation and synthetic bounded-input tests | Server maintainers | | `internal/publisher`, publisher configuration/lifecycle | `atrinik/server#68`, `atrinik/protocol@v1.3.0`, RFC 9421, and RFC 9530 | New Go HTTP/state scheduler implementation; protocol-owned MIT types, validators, and golden values | Server/protocol maintainers | +The access-policy publication update derives from the protocol-owned MIT +access-token and Game publisher v2 contracts at +`atrinik/protocol@1584053ee5f5bb1d96b59ff85f4035579bc17617`. +The certificate, body, digest and signature golden values in +`internal/publisher/client_test.go` come from that revision's synthetic MIT +`fixtures/metaserver-game-publisher-v2.json` (SHA-256 +`78eabb2287bd2f45e17e81c1eb071c807c2061687f93ca94ac03650df5f90a01`). Its configuration, scheduler, +identity-boundary tests and synthetic cases are independently authored here; +no Classic source, credentials, player state or generated bindings were copied. +This metadata adapter does not implement future gameplay admission or token +persistence. + The metaserver publisher consumes only the released MIT protocol package; it does not copy or hand-edit generated bindings. Future gameplay bindings remain subject to the same pinned generator/drift contract. diff --git a/README.md b/README.md index b94a514..d22800a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,21 @@ DER certificate, and sends the protocol-owned one-request signed body. HTTP redirects are forbidden and ordinary Go HTTPS certificate verification remains mandatory. +Admission metadata uses `-access-required` (default `false`) independently of +`-server-public`. Public open servers need no code; public protected servers +advertise that a code is required. Private servers stay out of public listings +regardless of their admission policy. The obsolete `-password-required` flag is +rejected. These flags describe the future gameplay admission policy: the M1 +foundation still has no gameplay listener, token administration, or private +rendezvous consumer, and setting a flag does not implement those services. + +The publisher consumes the protocol-owned Game publisher v2 body and +`accessRequired` field. This publisher advertises GP1 version 1.1; explicit +older or future minor versions are rejected before publication. Publisher identity remains SHA-256 of the exact DER leaf +certificate. The future QUIC transport's SPKI fingerprint is a distinct value; +reissuing a certificate for the same key changes the publisher identity and must +not silently rebind access routes. + The non-secret publish sequence is durably reserved before every request in `state/metaserver/publish-sequence-v1.log`. Ambiguous attempts consume their sequence. A valid replay response raises the local high-water mark before one diff --git a/cmd/atrinik-server/main_test.go b/cmd/atrinik-server/main_test.go index e8bffeb..3062806 100644 --- a/cmd/atrinik-server/main_test.go +++ b/cmd/atrinik-server/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "strconv" "strings" "testing" @@ -48,3 +49,40 @@ func TestCommandErrorsAreExplicit(t *testing.T) { } } } + +func TestAccessPolicyIsIndependentOfPublicationVisibility(t *testing.T) { + t.Parallel() + for _, public := range []bool{false, true} { + for _, required := range []bool{false, true} { + var output bytes.Buffer + args := []string{"config", "-server-public=" + strconv.FormatBool(public), "-access-required=" + strconv.FormatBool(required)} + if err := run(args, &output, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + var settings config.RedactedConfig + if err := json.Unmarshal(output.Bytes(), &settings); err != nil { + t.Fatal(err) + } + if settings.Publisher.Public != public || settings.Publisher.AccessRequired != required { + t.Fatal("configuration conflated visibility and admission policy") + } + if strings.Contains(output.String(), "password_required") || !strings.Contains(output.String(), "access_required") { + t.Fatal("configuration does not use the access-token policy field") + } + configuration := config.Default().Publisher + configuration.Public, configuration.AccessRequired = public, required + snapshot, err := configuredSnapshot(configuration) + if err != nil || snapshot.Public != public || snapshot.AccessRequired != required || snapshot.ProtocolMinor != 1 { + t.Fatal("publication snapshot lost admission policy") + } + } + } + for _, minor := range []string{"0", "2"} { + if err := run([]string{"config", "-publish-origin=https://publish.meta.atrinik.org", "-protocol-minor=" + minor}, &bytes.Buffer{}, &bytes.Buffer{}); err == nil { + t.Fatal("unsupported publisher protocol version was accepted") + } + } + if err := run([]string{"config", "-password-required=true"}, &bytes.Buffer{}, &bytes.Buffer{}); err == nil { + t.Fatal("obsolete shared-password flag was accepted") + } +} diff --git a/cmd/atrinik-server/publisher.go b/cmd/atrinik-server/publisher.go index 356c006..410481a 100644 --- a/cmd/atrinik-server/publisher.go +++ b/cmd/atrinik-server/publisher.go @@ -10,7 +10,7 @@ import ( "log/slog" "os" - metaserverv1 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v1" + metaserverv2 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v2" "github.com/atrinik/server/internal/config" "github.com/atrinik/server/internal/observability" "github.com/atrinik/server/internal/publisher" @@ -35,7 +35,7 @@ func configurePublisherFlags(flags *flag.FlagSet, configuration *config.Publishe flags.StringVar(&configuration.ContentRevisionSHA256, "content-revision-sha256", configuration.ContentRevisionSHA256, "compiled-content revision digest") flags.UintVar(&configuration.PlayersCapacity, "players-capacity", configuration.PlayersCapacity, "public player capacity") flags.BoolVar(&configuration.Public, "server-public", configuration.Public, "publish this server in the public directory") - flags.BoolVar(&configuration.PasswordRequired, "password-required", configuration.PasswordRequired, "require independent game join authentication") + flags.BoolVar(&configuration.AccessRequired, "access-required", configuration.AccessRequired, "advertise required access-token admission (gameplay listener unavailable in M1)") flags.StringVar(&configuration.DirectHostname, "direct-hostname", configuration.DirectHostname, "optional explicit public DNS fallback") flags.UintVar(&configuration.DirectPort, "direct-port", configuration.DirectPort, "explicit public DNS fallback UDP port") flags.DurationVar(&configuration.HeartbeatInterval, "publish-heartbeat", configuration.HeartbeatInterval, "slow liveness publication interval") @@ -106,14 +106,14 @@ func configuredSnapshot(configuration config.PublisherConfig) (publisher.Snapsho Name: configuration.Name, Description: configuration.Description, ProtocolMinor: uint32(configuration.ProtocolMinor), ContentID: configuration.ContentID, ContentRevisionSHA256: digest, PlayersCapacity: uint32(configuration.PlayersCapacity), - Status: metaserverv1.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_ONLINE, - Public: configuration.Public, PasswordRequired: configuration.PasswordRequired, + Status: metaserverv2.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_ONLINE, + Public: configuration.Public, AccessRequired: configuration.AccessRequired, } if configuration.Region != "" { snapshot.Region = &configuration.Region } if configuration.DirectHostname != "" { - snapshot.Endpoint = &metaserverv1.DirectEndpoint{Hostname: configuration.DirectHostname, Port: uint32(configuration.DirectPort)} + snapshot.Endpoint = &metaserverv2.DirectEndpoint{Hostname: configuration.DirectHostname, Port: uint32(configuration.DirectPort)} } return snapshot, nil } diff --git a/go.mod b/go.mod index a046e5c..9146249 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/atrinik/server go 1.26.6 require ( - github.com/atrinik/protocol v1.5.5 + github.com/atrinik/protocol v1.5.6-0.20261004163427-58ed75f13fab go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 golang.org/x/sys v0.48.0 @@ -15,7 +15,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 156998c..bd55019 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/atrinik/protocol v1.5.5 h1:QafJ5Gr8AqPEXNBbfh7yIC34Od5lNqv/5QL2HoJo9do= -github.com/atrinik/protocol v1.5.5/go.mod h1:0dgwIP+jrzj0ciI5f17H8ErmfP6Rqqnbkkx8AyK+vNk= +github.com/atrinik/protocol v1.5.6-0.20261004163427-58ed75f13fab h1:OoOggudXtgxbRcxJHFISj3Ehied/Ep5XQ28aYo3vBKo= +github.com/atrinik/protocol v1.5.6-0.20261004163427-58ed75f13fab/go.mod h1:LF3GaBvSjd06G6yrXKhs4nDFU1ZwjjAT1Ut3FAbvt4M= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -21,11 +21,11 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/config/config.go b/internal/config/config.go index bc10a88..5a7eca2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,8 +11,8 @@ import ( "strings" "time" - metaserverv1 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v1" - protocolmeta "github.com/atrinik/protocol/metaserver" + metaserverv2 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v2" + protocolmeta "github.com/atrinik/protocol/metaserver/v2" ) const ( @@ -47,7 +47,7 @@ type PublisherConfig struct { ContentRevisionSHA256 string `json:"content_revision_sha256"` PlayersCapacity uint `json:"players_capacity"` Public bool `json:"public"` - PasswordRequired bool `json:"password_required"` + AccessRequired bool `json:"access_required"` DirectHostname string `json:"direct_hostname"` DirectPort uint `json:"direct_port"` HeartbeatInterval time.Duration `json:"heartbeat_interval"` @@ -80,7 +80,7 @@ type RedactedPublisherConfig struct { ContentRevisionSHA256 string `json:"content_revision_sha256"` PlayersCapacity uint `json:"players_capacity"` Public bool `json:"public"` - PasswordRequired bool `json:"password_required"` + AccessRequired bool `json:"access_required"` DirectHostname string `json:"direct_hostname"` DirectPort uint `json:"direct_port"` HeartbeatInterval string `json:"heartbeat_interval"` @@ -100,6 +100,7 @@ func Default() Config { CertificatePath: "identity/certificate.pem", PrivateKeyPath: "identity/private-key.pem", Name: "Atrinik Server", + ProtocolMinor: 1, ContentID: "atrinik-main", ContentRevisionSHA256: strings.Repeat("0", 64), PlayersCapacity: 100, @@ -158,7 +159,8 @@ func (configuration PublisherConfig) Validate() error { if !safeRelativePath(configuration.CertificatePath) || !safeRelativePath(configuration.PrivateKeyPath) { return errors.New("publisher identity paths must be relative without traversal") } - if configuration.ProtocolMinor > 65_535 || configuration.PlayersCapacity < 1 || configuration.PlayersCapacity > protocolmeta.MaximumDirectoryPlayers || + // This build advertises the current access-token negotiation only. + if configuration.ProtocolMinor != 1 || configuration.PlayersCapacity < 1 || configuration.PlayersCapacity > protocolmeta.MaximumDirectoryPlayers || configuration.HeartbeatInterval < time.Hour || configuration.HeartbeatInterval > maximumPublisherHeartbeatInterval || configuration.ChangeDebounce <= 0 || configuration.ChangeDebounce > time.Minute || (configuration.DirectHostname == "") != (configuration.DirectPort == 0) || configuration.DirectPort > 65_535 { @@ -169,20 +171,20 @@ func (configuration PublisherConfig) Validate() error { return errors.New("publisher content revision must be lowercase SHA-256") } serverID := make([]byte, 32) - server := &metaserverv1.DirectoryServer{ + server := &metaserverv2.DirectoryServer{ ServerId: serverID, CertificateSha256: append([]byte(nil), serverID...), Name: configuration.Name, Description: configuration.Description, ProtocolMajor: 1, ProtocolMinor: uint32(configuration.ProtocolMinor), ContentId: configuration.ContentID, ContentRevisionSha256: contentRevision, - PlayersCapacity: uint32(configuration.PlayersCapacity), - Status: metaserverv1.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_ONLINE, - PasswordRequired: configuration.PasswordRequired, + PlayersCapacity: uint32(configuration.PlayersCapacity), + Status: metaserverv2.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_ONLINE, + AccessRequired: configuration.AccessRequired, } if configuration.Region != "" { server.Region = &configuration.Region } if configuration.DirectHostname != "" { - server.Endpoint = &metaserverv1.DirectEndpoint{Hostname: configuration.DirectHostname, Port: uint32(configuration.DirectPort)} + server.Endpoint = &metaserverv2.DirectEndpoint{Hostname: configuration.DirectHostname, Port: uint32(configuration.DirectPort)} } if !protocolmeta.DirectoryServerCompatible(server, 1, uint32(configuration.ProtocolMinor), configuration.ContentID, contentRevision) { return errors.New("publisher directory metadata is invalid") @@ -236,7 +238,7 @@ func (configuration PublisherConfig) Redacted() RedactedPublisherConfig { ProtocolMinor: configuration.ProtocolMinor, ContentID: configuration.ContentID, ContentRevisionSHA256: configuration.ContentRevisionSHA256, PlayersCapacity: configuration.PlayersCapacity, Public: configuration.Public, - PasswordRequired: configuration.PasswordRequired, DirectHostname: configuration.DirectHostname, + AccessRequired: configuration.AccessRequired, DirectHostname: configuration.DirectHostname, DirectPort: configuration.DirectPort, HeartbeatInterval: configuration.HeartbeatInterval.String(), ChangeDebounce: configuration.ChangeDebounce.String(), } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 24bd049..0619529 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,7 +21,7 @@ func TestRedactedNeverContainsToken(t *testing.T) { func TestPublisherConfigurationIsDisabledByDefaultAndStrictWhenEnabled(t *testing.T) { t.Parallel() configuration := Default() - if configuration.Publisher.Enabled() || configuration.Validate() != nil { + if configuration.Publisher.Enabled() || configuration.Publisher.ProtocolMinor != 1 || configuration.Validate() != nil { t.Fatal("default publisher configuration is not safely disabled") } configuration.Publisher.Origin = "https://publish.meta.atrinik.org" @@ -30,6 +30,8 @@ func TestPublisherConfigurationIsDisabledByDefaultAndStrictWhenEnabled(t *testin t.Fatalf("valid publisher configuration failed: %v", err) } for _, mutate := range []func(*PublisherConfig){ + func(value *PublisherConfig) { value.ProtocolMinor = 0 }, + func(value *PublisherConfig) { value.ProtocolMinor = 2 }, func(value *PublisherConfig) { value.Origin = "http://publish.meta.atrinik.org" }, func(value *PublisherConfig) { value.Origin = "https://publish.meta.atrinik.org/path" }, func(value *PublisherConfig) { value.CertificatePath = "../certificate.pem" }, diff --git a/internal/publisher/client.go b/internal/publisher/client.go index 9728e04..e3ddebf 100644 --- a/internal/publisher/client.go +++ b/internal/publisher/client.go @@ -19,8 +19,8 @@ import ( "strings" "time" - metaserverv1 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v1" - protocolmeta "github.com/atrinik/protocol/metaserver" + metaserverv2 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v2" + protocolmeta "github.com/atrinik/protocol/metaserver/v2" ) const ( @@ -47,10 +47,10 @@ type Snapshot struct { ContentRevisionSHA256 [32]byte PlayersOnline uint32 PlayersCapacity uint32 - Status metaserverv1.DirectoryServerStatus + Status metaserverv2.DirectoryServerStatus Public bool - PasswordRequired bool - Endpoint *metaserverv1.DirectEndpoint + AccessRequired bool + Endpoint *metaserverv2.DirectEndpoint } // ResultKind is a closed scheduling decision. It contains no response data. @@ -210,11 +210,15 @@ func (client *Client) buildRequest(ctx context.Context, snapshot Snapshot) (*htt } func (client *Client) bodyFor(snapshot Snapshot) ([]byte, error) { + // Metadata must not advertise historical or future negotiation for this build. + if snapshot.ProtocolMinor != 1 { + return nil, errors.New("publisher protocol version is unsupported") + } serverID, err := hex.DecodeString(client.identity.serverID) if err != nil { return nil, errors.New("publisher identity is invalid") } - server := &metaserverv1.DirectoryServer{ + server := &metaserverv2.DirectoryServer{ ServerId: serverID, CertificateSha256: append([]byte(nil), serverID...), Name: snapshot.Name, @@ -227,7 +231,7 @@ func (client *Client) bodyFor(snapshot Snapshot) ([]byte, error) { PlayersOnline: snapshot.PlayersOnline, PlayersCapacity: snapshot.PlayersCapacity, Status: snapshot.Status, - PasswordRequired: snapshot.PasswordRequired, + AccessRequired: snapshot.AccessRequired, Endpoint: cloneEndpoint(snapshot.Endpoint), } body, err := protocolmeta.MarshalGamePublishJSON(&protocolmeta.GamePublishRequest{ @@ -350,11 +354,11 @@ func cloneString(value *string) *string { return © } -func cloneEndpoint(value *metaserverv1.DirectEndpoint) *metaserverv1.DirectEndpoint { +func cloneEndpoint(value *metaserverv2.DirectEndpoint) *metaserverv2.DirectEndpoint { if value == nil { return nil } - return &metaserverv1.DirectEndpoint{Hostname: value.Hostname, Port: value.Port} + return &metaserverv2.DirectEndpoint{Hostname: value.Hostname, Port: value.Port} } func (kind ResultKind) String() string { return string(kind) } diff --git a/internal/publisher/client_test.go b/internal/publisher/client_test.go index 3b87b4c..5364000 100644 --- a/internal/publisher/client_test.go +++ b/internal/publisher/client_test.go @@ -17,13 +17,13 @@ import ( "testing" "time" - metaserverv1 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v1" - protocolmeta "github.com/atrinik/protocol/metaserver" + metaserverv2 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v2" + protocolmeta "github.com/atrinik/protocol/metaserver/v2" ) -const fixtureCertificateBase64 = "MIIBlDCCATqgAwIBAgIBAjAKBggqhkjOPQQDAjApMScwJQYDVQQDDB5BdHJpbmlrIGdhbWUgcHVibGlzaGVyIGZpeHR1cmUwHhcNMjYwODEwMDI0MjMwWhcNMzYwODA3MDI0MjMwWjApMScwJQYDVQQDDB5BdHJpbmlrIGdhbWUgcHVibGlzaGVyIGZpeHR1cmUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARp3V9S6hwQtev297vKo09IIjxFJ03bkJGWhINrtl02+qX74Y1fqMEglkyDsDS5uaw9wkEqAZFjvqGds9Nlh8mLo1MwUTAdBgNVHQ4EFgQUM2ynnFKEB/m1Ih384LbZpnCE6KcwHwYDVR0jBBgwFoAUM2ynnFKEB/m1Ih384LbZpnCE6KcwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiA+GS9rOiluma03pBE7eOsp8qQWF2x5LLzwIvHtOr9cQQIhALFjaapew4tGe7YyjGNwCqd7ga08+HeUd0L2+KBkaORJ" +const fixtureCertificateBase64 = "MIIBOjCB4KADAgECAgID6TAKBggqhkjOPQQDAjAmMSQwIgYDVQQDDBtBdHJpbmlrIGFjY2VzcyBmaXh0dXJlIG9ubHkwHhcNMjYwMTAxMDAwMDAwWhcNMzYwMTAxMDAwMDAwWjAmMSQwIgYDVQQDDBtBdHJpbmlrIGFjY2VzcyBmaXh0dXJlIG9ubHkwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1MAoGCCqGSM49BAMCA0kAMEYCIQDuJYjSE1s0zA8WTnf+zwhLUj7HiAN3I4u9Se0dmU2jvAIhALgq0zfa5cvIFi8xBKYqCN8gNsxnhvb2qPHKe/pUq8DT" -const fixtureBody = "{\"schema\":\"atrinik-game-publish-v1\",\"serverId\":\"0145f46149b8483d33b8e02c9495b3e4ff2dd5ce342a22bb40913bba7a457d39\",\"certificate\":\"" + fixtureCertificateBase64 + "\",\"name\":\"Atrinik Game Alpha\",\"description\":\"Cooperative Ω\",\"region\":\"eu-west\",\"protocol\":{\"major\":1,\"minor\":0},\"content\":{\"id\":\"atrinik-main\",\"revisionSha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"players\":{\"online\":3,\"capacity\":64},\"status\":\"online\",\"public\":true,\"passwordRequired\":false,\"endpoint\":{\"hostname\":\"xn--bcher-kva.example.org\",\"port\":13327}}" +const fixtureBody = "{\"schema\":\"atrinik-game-publish-v2\",\"serverId\":\"0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40\",\"certificate\":\"MIIBOjCB4KADAgECAgID6TAKBggqhkjOPQQDAjAmMSQwIgYDVQQDDBtBdHJpbmlrIGFjY2VzcyBmaXh0dXJlIG9ubHkwHhcNMjYwMTAxMDAwMDAwWhcNMzYwMTAxMDAwMDAwWjAmMSQwIgYDVQQDDBtBdHJpbmlrIGFjY2VzcyBmaXh0dXJlIG9ubHkwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1MAoGCCqGSM49BAMCA0kAMEYCIQDuJYjSE1s0zA8WTnf+zwhLUj7HiAN3I4u9Se0dmU2jvAIhALgq0zfa5cvIFi8xBKYqCN8gNsxnhvb2qPHKe/pUq8DT\",\"name\":\"Atrinik Game Alpha\",\"description\":\"Cooperative Ω\",\"region\":\"eu-west\",\"protocol\":{\"major\":1,\"minor\":0},\"content\":{\"id\":\"atrinik-main\",\"revisionSha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"players\":{\"online\":3,\"capacity\":64},\"status\":\"online\",\"public\":true,\"accessRequired\":false,\"endpoint\":{\"hostname\":\"xn--bcher-kva.example.org\",\"port\":13327}}" func TestProtocolGoldenVectorBuildsAndVerifies(t *testing.T) { t.Parallel() @@ -37,18 +37,18 @@ func TestProtocolGoldenVectorBuildsAndVerifies(t *testing.T) { } components, err := protocolmeta.Build(protocolmeta.Parameters{ Profile: protocolmeta.GameProfile, Authority: "publish.meta.atrinik.org", - ServerID: "0145f46149b8483d33b8e02c9495b3e4ff2dd5ce342a22bb40913bba7a457d39", + ServerID: "0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40", Sequence: 42, Nonce: nonce, Created: 1_800_000_000, }, []byte(fixtureBody)) if err != nil { t.Fatal(err) } - if components.ContentDigest != "sha-256=:Fzvnb28jWhv3lSDxHJKDzlJjNLnrc+1I+VLVdrqERGE=:" || - components.Path != "/v1/servers/0145f46149b8483d33b8e02c9495b3e4ff2dd5ce342a22bb40913bba7a457d39/publish" { + if components.ContentDigest != "sha-256=:kN4WaATIYYnp8uxnhiymVfxvnAej189TJqqlw1vu1ec=:" || + components.Path != "/v2/servers/0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40/publish" { t.Fatalf("golden components diverged: %+v", components) } - signature, err := base64.StdEncoding.DecodeString("PcGj3kxhPCqdxaEAUNkpVaj2Xu+b+3LkfIgu/pD8M+94GlUu/0EbjDVGgn41yqkzyLQZBtI6c3DzL/JzTG/QaQ==") - if err != nil || protocolmeta.VerifyCertificateSignature(certificate, "0145f46149b8483d33b8e02c9495b3e4ff2dd5ce342a22bb40913bba7a457d39", components.SignatureBase, signature) != nil { + signature, err := base64.StdEncoding.DecodeString("0ikEdkxrIEmpiwlYO1oTE2zF7UOVepL9VzaRppNhXnIARfex7YpQCnn5yAGpEYISEZJHHKXeGLXMPdpSp82Row==") + if err != nil || protocolmeta.VerifyCertificateSignature(certificate, "0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40", components.SignatureBase, signature) != nil { t.Fatal("protocol golden signature did not verify") } parsed, err := protocolmeta.ParseGamePublishJSON([]byte(fixtureBody)) @@ -155,7 +155,7 @@ func TestClientPublishesOnlyAnExplicitCanonicalEndpoint(t *testing.T) { } snapshot := testSnapshot() snapshot.Public = false - snapshot.Endpoint = &metaserverv1.DirectEndpoint{Hostname: "play.example.net", Port: 13327} + snapshot.Endpoint = &metaserverv2.DirectEndpoint{Hostname: "play.example.net", Port: 13327} body, err := client.bodyFor(snapshot) if err != nil { t.Fatal(err) @@ -165,6 +165,41 @@ func TestClientPublishesOnlyAnExplicitCanonicalEndpoint(t *testing.T) { parsed.Server.Endpoint.Hostname != "play.example.net" || parsed.Server.Endpoint.Port != 13327 { t.Fatalf("explicit endpoint body = %#v, %v", parsed, err) } + for _, public := range []bool{false, true} { + for _, required := range []bool{false, true} { + snapshot.Public, snapshot.AccessRequired = public, required + body, err := client.bodyFor(snapshot) + if err != nil { + t.Fatal(err) + } + parsed, err := protocolmeta.ParseGamePublishJSON(body) + if err != nil || parsed.Public != public || parsed.Server.AccessRequired != required || parsed.Server.ProtocolMinor != 1 { + t.Fatal("publisher changed independent visibility/admission policy") + } + if !bytes.Contains(body, []byte(`"schema":"atrinik-game-publish-v2"`)) || + !bytes.Contains(body, []byte(`"accessRequired":`)) || bytes.Contains(body, []byte(`"passwordRequired":`)) { + t.Fatal("publisher emitted an obsolete admission contract") + } + legacyField := bytes.Replace(body, []byte(`"accessRequired":`), []byte(`"passwordRequired":`), 1) + legacySchema := bytes.Replace(body, []byte(`atrinik-game-publish-v2`), []byte(`atrinik-game-publish-v1`), 1) + for _, legacy := range [][]byte{legacyField, legacySchema} { + if _, err := protocolmeta.ParseGamePublishJSON(legacy); err == nil { + t.Fatal("v2 parser accepted legacy admission metadata") + } + } + } + } + + for _, minor := range []uint32{0, 2} { + unsupported := snapshot + unsupported.ProtocolMinor = minor + if _, err := client.Publish(context.Background(), unsupported); err == nil { + t.Fatal("publisher accepted unsupported protocol negotiation") + } + if sequence.HighWater() != 0 { + t.Fatal("unsupported version consumed a sequence") + } + } snapshot.Endpoint.Hostname = "192.0.2.1" if err := client.ValidateSnapshot(snapshot); err == nil { t.Fatal("numeric endpoint was accepted") @@ -316,9 +351,9 @@ func testIdentity(t *testing.T) *Identity { func testSnapshot() Snapshot { digest := sha256.Sum256([]byte("content")) return Snapshot{ - Name: "Test Server", Description: "", ProtocolMinor: 0, ContentID: "atrinik-main", + Name: "Test Server", Description: "", ProtocolMinor: 1, ContentID: "atrinik-main", ContentRevisionSHA256: digest, PlayersOnline: 1, PlayersCapacity: 10, - Status: metaserverv1.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_ONLINE, + Status: metaserverv2.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_ONLINE, Public: true, } } diff --git a/internal/publisher/identity_test.go b/internal/publisher/identity_test.go index 2fd826d..c30014f 100644 --- a/internal/publisher/identity_test.go +++ b/internal/publisher/identity_test.go @@ -7,8 +7,10 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" "crypto/x509/pkix" + "encoding/hex" "encoding/pem" "math/big" "os" @@ -88,3 +90,35 @@ func testIdentityPEM(t *testing.T) ([]byte, []byte) { return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}) } + +func TestPublisherIdentityHashesExactLeafRatherThanPublicKey(t *testing.T) { + t.Parallel() + certificatePEM, privateKeyPEM := testIdentityPEM(t) + identity, err := ParseIdentityPEM(certificatePEM, privateKeyPEM) + if err != nil { + t.Fatal(err) + } + certificate, err := x509.ParseCertificate(identity.certificateDER) + if err != nil { + t.Fatal(err) + } + leafDigest := sha256.Sum256(certificate.Raw) + spkiDigest := sha256.Sum256(certificate.RawSubjectPublicKeyInfo) + if identity.serverID != hex.EncodeToString(leafDigest[:]) || identity.serverID == hex.EncodeToString(spkiDigest[:]) { + t.Fatal("publisher identity confused exact leaf and transport SPKI hashes") + } + // Reissuing a certificate for the same key deliberately changes publisher + // identity; transport SPKI equality must never silently rebind access routes. + certificate.SerialNumber = big.NewInt(2) + reissuedDER, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &identity.privateKey.PublicKey, identity.privateKey) + if err != nil { + t.Fatal(err) + } + reissued, err := ParseIdentityPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: reissuedDER}), privateKeyPEM) + if err != nil { + t.Fatal(err) + } + if identity.serverID == reissued.serverID { + t.Fatal("reissued certificate retained publisher identity") + } +} diff --git a/internal/publisher/service.go b/internal/publisher/service.go index 029e32c..6f2b652 100644 --- a/internal/publisher/service.go +++ b/internal/publisher/service.go @@ -12,7 +12,7 @@ import ( "sync" "time" - metaserverv1 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v1" + metaserverv2 "github.com/atrinik/protocol/gen/go/atrinik/metaserver/v2" ) const ( @@ -293,7 +293,7 @@ func (service *Service) observe(event ServiceEvent) { func validateSnapshot(snapshot Snapshot) error { if snapshot.PlayersCapacity == 0 || snapshot.PlayersOnline > snapshot.PlayersCapacity || - snapshot.Status == metaserverv1.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_UNSPECIFIED { + snapshot.Status == metaserverv2.DirectoryServerStatus_DIRECTORY_SERVER_STATUS_UNSPECIFIED { return errors.New("publisher snapshot is invalid") } return nil @@ -311,14 +311,14 @@ func snapshotsEqual(left, right Snapshot) bool { left.ContentID == right.ContentID && left.ContentRevisionSHA256 == right.ContentRevisionSHA256 && left.PlayersOnline == right.PlayersOnline && left.PlayersCapacity == right.PlayersCapacity && left.Status == right.Status && left.Public == right.Public && - left.PasswordRequired == right.PasswordRequired && endpointsEqual(left.Endpoint, right.Endpoint) + left.AccessRequired == right.AccessRequired && endpointsEqual(left.Endpoint, right.Endpoint) } func optionalStringsEqual(left, right *string) bool { return left == nil && right == nil || left != nil && right != nil && *left == *right } -func endpointsEqual(left, right *metaserverv1.DirectEndpoint) bool { +func endpointsEqual(left, right *metaserverv2.DirectEndpoint) bool { return left == nil && right == nil || left != nil && right != nil && left.Hostname == right.Hostname && left.Port == right.Port } diff --git a/internal/publisher/service_test.go b/internal/publisher/service_test.go index 28465b8..89aedaa 100644 --- a/internal/publisher/service_test.go +++ b/internal/publisher/service_test.go @@ -274,3 +274,35 @@ func assertNoAttempt[T any](t *testing.T, channel <-chan T, duration time.Durati case <-time.After(duration): } } + +func TestServicePublishesAdmissionPolicyChange(t *testing.T) { + t.Parallel() + attempts := make(chan Snapshot, 3) + service, err := NewService(attemptPublisherFunc(func(_ context.Context, snapshot Snapshot) (Result, error) { + attempts <- snapshot + return Result{Kind: ResultAccepted}, nil + }), testServiceConfig()) + if err != nil { + t.Fatal(err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Close(ctx); err != nil { + t.Error(err) + } + }() + snapshot := testSnapshot() + if err := service.Start(context.Background(), snapshot); err != nil { + t.Fatal(err) + } + receiveSnapshot(t, attempts) + snapshot.AccessRequired = true + if err := service.Update(snapshot); err != nil { + t.Fatal(err) + } + got := receiveSnapshot(t, attempts) + if !got.AccessRequired || got.Public != snapshot.Public { + t.Fatal("policy-only update was suppressed or changed visibility") + } +}