From 5fcd58701f1fedcf2c0dbc4e4c3bcb1abd441f6d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 02:14:51 -0500 Subject: [PATCH 1/3] feat(access): replace password discovery with access tokens --- .gitattributes | 6 +- Cargo.lock | 101 ++- Cargo.toml | 4 +- SECURITY.md | 6 + THIRD_PARTY_NOTICES.md | 2 + crates/atrinik-directory/Cargo.toml | 2 + crates/atrinik-directory/src/access.rs | 599 ++++++++++++++++++ crates/atrinik-directory/src/cache.rs | 2 +- crates/atrinik-directory/src/lib.rs | 11 +- crates/atrinik-protocol-adapter/Cargo.toml | 3 + crates/atrinik-protocol-adapter/src/access.rs | 237 +++++++ .../atrinik-protocol-adapter/src/directory.rs | 40 +- crates/atrinik-protocol-adapter/src/lib.rs | 2 + crates/atrinik-protocol-adapter/src/trust.rs | 136 ++++ crates/atrinik-session/src/lib.rs | 26 +- crates/atrinik-session/tests/session.rs | 68 ++ docs/DIRECTORY.md | 51 +- fixtures/README.md | 29 +- fixtures/access-resolve-v1/canonical.json | 1 + fixtures/access-resolve-v1/synthetic-p256.der | Bin 0 -> 318 bytes fixtures/metaserver-directory-v1.json | 38 -- .../metaserver-directory-v1/canonical.json | 1 - .../negative-duplicate-server.json | 1 - .../negative-expired-at-generation.json | 1 - .../negative-identity-mismatch.json | 1 - .../negative-invalid-alabel.json | 1 - .../negative-noncanonical-whitespace.json | 1 - .../negative-numeric-endpoint.json | 1 - .../negative-private-field.json | 1 - .../negative-status-count.json | 1 - .../negative-unordered-servers.json | 1 - .../negative-unsupported-schema.json | 1 - .../negative-xml-noncharacter.json | 1 - .../negative-zero-generation.json | 1 - fixtures/metaserver-directory-v2.json | 74 +++ .../metaserver-directory-v2/canonical.json | 1 + .../negative-duplicate-server.json | 1 + .../negative-expired-at-generation.json | 1 + .../negative-identity-mismatch.json | 1 + .../negative-invalid-alabel.json | 1 + .../negative-noncanonical-whitespace.json | 1 + .../negative-numeric-endpoint.json | 1 + .../negative-private-field.json | 1 + .../negative-status-count.json | 1 + .../negative-unordered-servers.json | 1 + .../negative-unsupported-schema.json | 1 + .../negative-xml-noncharacter.json | 1 + .../negative-zero-generation.json | 1 + .../projection-semantics.json | 4 +- .../projection.xml | 6 +- policy/dependencies.json | 8 +- tools/check-architecture.sh | 6 +- tools/check-foundations.sh | 11 +- 53 files changed, 1374 insertions(+), 125 deletions(-) create mode 100644 crates/atrinik-directory/src/access.rs create mode 100644 crates/atrinik-protocol-adapter/src/access.rs create mode 100644 crates/atrinik-protocol-adapter/src/trust.rs create mode 100644 fixtures/access-resolve-v1/canonical.json create mode 100644 fixtures/access-resolve-v1/synthetic-p256.der delete mode 100644 fixtures/metaserver-directory-v1.json delete mode 100644 fixtures/metaserver-directory-v1/canonical.json delete mode 100644 fixtures/metaserver-directory-v1/negative-duplicate-server.json delete mode 100644 fixtures/metaserver-directory-v1/negative-expired-at-generation.json delete mode 100644 fixtures/metaserver-directory-v1/negative-identity-mismatch.json delete mode 100644 fixtures/metaserver-directory-v1/negative-invalid-alabel.json delete mode 100644 fixtures/metaserver-directory-v1/negative-noncanonical-whitespace.json delete mode 100644 fixtures/metaserver-directory-v1/negative-numeric-endpoint.json delete mode 100644 fixtures/metaserver-directory-v1/negative-private-field.json delete mode 100644 fixtures/metaserver-directory-v1/negative-status-count.json delete mode 100644 fixtures/metaserver-directory-v1/negative-unordered-servers.json delete mode 100644 fixtures/metaserver-directory-v1/negative-unsupported-schema.json delete mode 100644 fixtures/metaserver-directory-v1/negative-xml-noncharacter.json delete mode 100644 fixtures/metaserver-directory-v1/negative-zero-generation.json create mode 100644 fixtures/metaserver-directory-v2.json create mode 100644 fixtures/metaserver-directory-v2/canonical.json create mode 100644 fixtures/metaserver-directory-v2/negative-duplicate-server.json create mode 100644 fixtures/metaserver-directory-v2/negative-expired-at-generation.json create mode 100644 fixtures/metaserver-directory-v2/negative-identity-mismatch.json create mode 100644 fixtures/metaserver-directory-v2/negative-invalid-alabel.json create mode 100644 fixtures/metaserver-directory-v2/negative-noncanonical-whitespace.json create mode 100644 fixtures/metaserver-directory-v2/negative-numeric-endpoint.json create mode 100644 fixtures/metaserver-directory-v2/negative-private-field.json create mode 100644 fixtures/metaserver-directory-v2/negative-status-count.json create mode 100644 fixtures/metaserver-directory-v2/negative-unordered-servers.json create mode 100644 fixtures/metaserver-directory-v2/negative-unsupported-schema.json create mode 100644 fixtures/metaserver-directory-v2/negative-xml-noncharacter.json create mode 100644 fixtures/metaserver-directory-v2/negative-zero-generation.json rename fixtures/{metaserver-directory-v1 => metaserver-directory-v2}/projection-semantics.json (90%) rename fixtures/{metaserver-directory-v1 => metaserver-directory-v2}/projection.xml (78%) diff --git a/.gitattributes b/.gitattributes index 63ab670..7ed5e0f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ -fixtures/metaserver-directory-v1.json text eol=lf -fixtures/metaserver-directory-v1/* text eol=lf +fixtures/metaserver-directory-v2.json text eol=lf +fixtures/metaserver-directory-v2/* text eol=lf +fixtures/access-resolve-v1/*.json text eol=lf +fixtures/access-resolve-v1/*.der binary # Large binary assets use Git LFS. *.png filter=lfs diff=lfs merge=lfs -text diff --git a/Cargo.lock b/Cargo.lock index 8658c90..5000777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,10 +42,12 @@ dependencies = [ name = "atrinik-directory" version = "0.1.0" dependencies = [ + "atrinik-protocol", "atrinik-protocol-adapter", "httpdate", "sha2", "ureq", + "zeroize", ] [[package]] @@ -58,9 +60,7 @@ dependencies = [ [[package]] name = "atrinik-protocol" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413c4da6c1b304d4a622065efe0d36c3f591041972f1a5ee76c538926f3c0b6b" +version = "0.2.0" dependencies = [ "bytes", "idna", @@ -74,6 +74,9 @@ dependencies = [ "atrinik-actions", "atrinik-protocol", "atrinik-session", + "sha2", + "x509-cert", + "zeroize", ] [[package]] @@ -112,6 +115,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.1" @@ -191,6 +200,29 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.11.3" @@ -225,6 +257,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -669,6 +707,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -724,6 +772,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "typenum" version = "1.20.1" @@ -883,6 +952,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yoke" version = "0.8.3" @@ -932,6 +1013,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 6cbca66..4951b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ atrinik-actions = { path = "crates/atrinik-actions", version = "=0.1.0" } atrinik-config-cache = { path = "crates/atrinik-config-cache", version = "=0.1.0" } atrinik-directory = { path = "crates/atrinik-directory", version = "=0.1.0" } atrinik-platform = { path = "crates/atrinik-platform", version = "=0.1.0" } -atrinik-protocol = "=0.1.0" +atrinik-protocol = "=0.2.0" atrinik-protocol-adapter = { path = "crates/atrinik-protocol-adapter", version = "=0.1.0" } atrinik-scene-adapter = { path = "crates/atrinik-scene-adapter", version = "=0.1.0" } atrinik-session = { path = "crates/atrinik-session", version = "=0.1.0" } @@ -35,6 +35,8 @@ httpdate = "1.0.3" sdl3 = { version = "0.20.0", default-features = false, features = ["build-from-source-static"] } sha2 = "0.11.0" ureq = { version = "3.4.0", default-features = false, features = ["gzip", "rustls"] } +x509-cert = { version = "0.3.0", default-features = false, features = ["std"] } +zeroize = "1.9.0" [workspace.lints.rust] unsafe_code = "deny" diff --git a/SECURITY.md b/SECURITY.md index 03d2458..ddeebd5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,6 +9,12 @@ media types are bounded before commit. Server-selected executable formats, plugins, shaders, and native libraries are forbidden. Credentials, trust, settings, layout, cache, logs, screenshots, and crashes never share a root. +Access codes and their derived route capabilities, nonces, one-use grants, and +private resolve bodies are ephemeral connection-attempt state. They are never +placed in URLs, caches, logs, diagnostics, or serialized client models. Secret +types omit printing and serialization traits, and owned buffers are cleared on +drop as a best-effort reduction of their process-memory lifetime. + The session remains server-authority preserving: local intent cannot claim gameplay success, hidden state is not reconstructed, and malformed/stale input cannot partially mutate the visible snapshot. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2822019..2f74f23 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,6 +10,8 @@ Resolved versions are recorded only in `Cargo.lock`. | `sdl3` | SDL 3.4.18 | `MIT AND Zlib` | https://crates.io/crates/sdl3 | | `sha2` | none | `MIT OR Apache-2.0` | https://crates.io/crates/sha2 | | `ureq` | none | `MIT OR Apache-2.0` | https://crates.io/crates/ureq | +| `x509-cert` | none | `Apache-2.0 OR MIT` | https://crates.io/crates/x509-cert | +| `zeroize` | none | `Apache-2.0 OR MIT` | https://crates.io/crates/zeroize | Cargo.lock and each release SBOM contain the complete transitive graph. Bundled authored assets: none. This summary does not replace upstream license texts. diff --git a/crates/atrinik-directory/Cargo.toml b/crates/atrinik-directory/Cargo.toml index 1c50d32..5fbae8f 100644 --- a/crates/atrinik-directory/Cargo.toml +++ b/crates/atrinik-directory/Cargo.toml @@ -7,10 +7,12 @@ license.workspace = true repository.workspace = true [dependencies] +atrinik-protocol.workspace = true atrinik-protocol-adapter.workspace = true httpdate.workspace = true sha2.workspace = true ureq.workspace = true +zeroize.workspace = true [lints] workspace = true diff --git a/crates/atrinik-directory/src/access.rs b/crates/atrinik-directory/src/access.rs new file mode 100644 index 0000000..e8970fa --- /dev/null +++ b/crates/atrinik-directory/src/access.rs @@ -0,0 +1,599 @@ +//! Ephemeral access-code handling for private discovery and game admission. + +use atrinik_protocol::metaserver::access::{ + AccessEndpoint, AccessProfile, is_access_unavailable, marshal_access_resolve_request, + parse_access_resolved, +}; +use atrinik_protocol_adapter::trust::{CertificateIdentities, verify_certificate_identity}; +use sha2::{Digest, Sha256}; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io::Read; +use std::time::Duration; +use ureq::Agent; +use zeroize::Zeroize; + +pub const ACCESS_CODE_BYTES: usize = 16; +pub const ACCESS_RESOLVE_REQUEST_BYTES: usize = 204; +pub const ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT: usize = 8 * 1024; +pub const ACCESS_RESOLVE_URL: &str = "https://meta.atrinik.org/v1/access/resolve"; +const ACCESS_MEDIA_TYPE: &str = "application/json; charset=utf-8"; +const MAXIMUM_RESPONSE_HEADER_BYTES: usize = 8 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const ACCESS_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const ROUTE_DOMAIN: &[u8] = b"atrinik-access-route-v1\0"; + +/// A canonical access code retained only for the lifetime of one connection attempt. +/// +/// Deliberately does not implement `Debug`, `Display`, `Clone`, or serialization. +pub struct AccessCode([u8; ACCESS_CODE_BYTES]); + +impl AccessCode { + pub fn parse_user_input(input: &str) -> Result { + let trimmed = input.trim_matches(|value: char| value.is_ascii_whitespace()); + if !trimmed.is_ascii() || trimmed.len() != ACCESS_CODE_BYTES { + return Err(AccessCodeError::Invalid); + } + let mut canonical = [0u8; ACCESS_CODE_BYTES]; + for (output, input) in canonical.iter_mut().zip(trimmed.bytes()) { + let value = input.to_ascii_uppercase(); + if !ACCESS_ALPHABET.contains(&value) { + canonical.zeroize(); + return Err(AccessCodeError::Invalid); + } + *output = value; + } + Ok(Self(canonical)) + } + + #[must_use] + pub fn route_capability(&self) -> RouteCapability { + let mut hasher = Sha256::new(); + hasher.update(ROUTE_DOMAIN); + hasher.update(self.0); + RouteCapability(hasher.finalize().into()) + } + + /// Exposes the canonical bytes only to the encrypted GP1 access adapter. + pub fn with_canonical_bytes(&self, use_bytes: impl FnOnce(&[u8; 16]) -> T) -> T { + use_bytes(&self.0) + } +} + +impl Drop for AccessCode { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccessCodeError { + Invalid, +} + +impl Display for AccessCodeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str("access code is invalid") + } +} + +impl Error for AccessCodeError {} + +/// A discovery capability derived from an access code. +/// +/// This value remains private even though it cannot authenticate a game connection. +pub struct RouteCapability([u8; 32]); + +impl Drop for RouteCapability { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// A fixed-shape access resolve request. It deliberately has no logging traits. +pub struct AccessResolveRequest { + body: Vec, + client_nonce: [u8; 32], +} + +impl AccessResolveRequest { + #[must_use] + pub fn new(route: &RouteCapability, client_nonce: [u8; 32]) -> Self { + let body = marshal_access_resolve_request(&route.0, &client_nonce); + debug_assert_eq!(body.len(), ACCESS_RESOLVE_REQUEST_BYTES); + Self { body, client_nonce } + } + + pub(crate) fn body(&self) -> &[u8] { + &self.body + } + + fn client_nonce(&self) -> &[u8; 32] { + &self.client_nonce + } +} + +impl Drop for AccessResolveRequest { + fn drop(&mut self) { + self.body.zeroize(); + self.client_nonce.zeroize(); + } +} + +/// A bounded access response. Its grant and private routing data must not be logged. +pub struct AccessResolveResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl AccessResolveResponse { + #[must_use] + pub fn header_values(&self, name: &str) -> Vec<&str> { + self.headers + .iter() + .filter(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + .collect() + } +} + +impl Drop for AccessResolveResponse { + fn drop(&mut self) { + self.body.zeroize(); + } +} + +/// A successful private-discovery result whose one-use grant is never printable. +pub struct ResolvedAccess { + pub server_id: [u8; 32], + pub certificate_der: Vec, + pub certificate_identities: CertificateIdentities, + pub name: String, + pub generation: [u8; 32], + pub expires_at: u64, + pub endpoint: Option, + grant: [u8; 32], +} + +impl ResolvedAccess { + /// Exposes the grant only to the existing rendezvous adapter for one attempt. + pub fn with_grant(&self, use_grant: impl FnOnce(&[u8; 32]) -> T) -> T { + use_grant(&self.grant) + } +} + +impl Drop for ResolvedAccess { + fn drop(&mut self) { + self.certificate_der.zeroize(); + self.generation.zeroize(); + self.grant.zeroize(); + } +} + +pub enum AccessResolution { + Resolved(Box), + Unavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccessResponseError { + Protocol, + Identity, +} + +impl Display for AccessResponseError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Protocol => "access service response is invalid", + Self::Identity => "access service returned an untrusted server identity", + }) + } +} + +impl Error for AccessResponseError {} + +/// Validates one uncached response and binds its certificate and grant to this request. +pub fn validate_access_response( + response: &AccessResolveResponse, + request: &AccessResolveRequest, + now: u64, + expected_server_id: Option<&[u8; 32]>, +) -> Result { + validate_response_headers(response)?; + if response.status == 404 { + return if is_access_unavailable(&response.body) { + Ok(AccessResolution::Unavailable) + } else { + Err(AccessResponseError::Protocol) + }; + } + if response.status != 200 { + return Err(AccessResponseError::Protocol); + } + + let mut resolved = parse_access_resolved(&response.body, request.client_nonce(), now) + .map_err(|_| AccessResponseError::Protocol)?; + if resolved.profile != AccessProfile::Game + || expected_server_id.is_some_and(|expected| expected != &resolved.server_id) + { + clear_resolved_secrets(&mut resolved); + return Err(AccessResponseError::Identity); + } + let Ok(certificate_identities) = + verify_certificate_identity(&resolved.certificate_der, &resolved.server_id) + else { + clear_resolved_secrets(&mut resolved); + return Err(AccessResponseError::Identity); + }; + + let output = ResolvedAccess { + server_id: resolved.server_id, + certificate_der: std::mem::take(&mut resolved.certificate_der), + certificate_identities, + name: std::mem::take(&mut resolved.name), + generation: std::mem::take(&mut resolved.generation), + expires_at: resolved.expires_at, + endpoint: resolved.endpoint.take(), + grant: std::mem::take(&mut resolved.grant), + }; + clear_resolved_secrets(&mut resolved); + Ok(AccessResolution::Resolved(Box::new(output))) +} + +fn validate_response_headers(response: &AccessResolveResponse) -> Result<(), AccessResponseError> { + if response.header_values("cache-control") != ["no-store"] + || response.header_values("content-type") != [ACCESS_MEDIA_TYPE] + { + return Err(AccessResponseError::Protocol); + } + let content_lengths = response.header_values("content-length"); + if content_lengths.len() > 1 + || content_lengths + .first() + .is_some_and(|value| value.parse::().ok() != Some(response.body.len())) + { + return Err(AccessResponseError::Protocol); + } + Ok(()) +} + +fn clear_resolved_secrets(resolved: &mut atrinik_protocol::metaserver::access::AccessResolved) { + resolved.certificate_der.zeroize(); + resolved.generation.zeroize(); + resolved.client_nonce.zeroize(); + resolved.grant.zeroize(); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccessTransportError { + Offline, + Timeout, + Tls, + Protocol, + BodyTooLarge, +} + +impl Display for AccessTransportError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Offline => "access service is offline", + Self::Timeout => "access request timed out", + Self::Tls => "access service TLS validation failed", + Self::Protocol => "access service response is invalid", + Self::BodyTooLarge => "access service response exceeds its byte limit", + }) + } +} + +impl Error for AccessTransportError {} + +pub trait AccessTransport { + fn resolve( + &mut self, + request: &AccessResolveRequest, + ) -> Result; +} + +#[derive(Clone)] +pub struct UreqAccessTransport { + agent: Agent, +} + +impl Default for UreqAccessTransport { + fn default() -> Self { + Self::new() + } +} + +impl UreqAccessTransport { + #[must_use] + pub fn new() -> Self { + let config = Agent::config_builder() + .https_only(true) + .http_status_as_error(false) + .max_redirects(0) + .max_response_header_size(MAXIMUM_RESPONSE_HEADER_BYTES) + .timeout_global(Some(REQUEST_TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + .user_agent(concat!("atrinik-client/", env!("CARGO_PKG_VERSION"))) + .build(); + Self { + agent: config.new_agent(), + } + } +} + +impl AccessTransport for UreqAccessTransport { + fn resolve( + &mut self, + request: &AccessResolveRequest, + ) -> Result { + if request.body().len() != ACCESS_RESOLVE_REQUEST_BYTES { + return Err(AccessTransportError::Protocol); + } + let mut response = self + .agent + .post(ACCESS_RESOLVE_URL) + .header("Accept", ACCESS_MEDIA_TYPE) + .header("Content-Type", ACCESS_MEDIA_TYPE) + .header("Cache-Control", "no-store") + .send(request.body()) + .map_err(|error| classify_ureq_error(&error))?; + let status = response.status().as_u16(); + let headers = selected_headers(response.headers())?; + let body = read_bounded_body(&mut response)?; + Ok(AccessResolveResponse { + status, + headers, + body, + }) + } +} + +fn selected_headers( + headers: &ureq::http::HeaderMap, +) -> Result, AccessTransportError> { + const SELECTED: &[&str] = &["cache-control", "content-length", "content-type"]; + let mut output = Vec::new(); + for (name, value) in headers { + if matches!(name.as_str(), "location" | "set-cookie") { + return Err(AccessTransportError::Protocol); + } + if SELECTED.contains(&name.as_str()) { + output.push(( + name.as_str().to_owned(), + value + .to_str() + .map_err(|_| AccessTransportError::Protocol)? + .to_owned(), + )); + } + } + Ok(output) +} + +fn read_bounded_body( + response: &mut ureq::http::Response, +) -> Result, AccessTransportError> { + let mut reader = response + .body_mut() + .with_config() + .limit((ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT + 1) as u64) + .reader(); + let mut output = Vec::new(); + let mut buffer = [0u8; 8 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| classify_body_error(&error))?; + if read == 0 { + return Ok(output); + } + let next = output + .len() + .checked_add(read) + .ok_or(AccessTransportError::BodyTooLarge)?; + if next > ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT { + output.zeroize(); + return Err(AccessTransportError::BodyTooLarge); + } + output.extend_from_slice(&buffer[..read]); + } +} + +fn classify_body_error(error: &std::io::Error) -> AccessTransportError { + if error.kind() == std::io::ErrorKind::TimedOut { + return AccessTransportError::Timeout; + } + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .map_or(AccessTransportError::Offline, classify_ureq_error) +} + +fn classify_ureq_error(error: &ureq::Error) -> AccessTransportError { + match error { + ureq::Error::Timeout(_) => AccessTransportError::Timeout, + ureq::Error::HostNotFound | ureq::Error::ConnectionFailed | ureq::Error::Io(_) => { + AccessTransportError::Offline + } + ureq::Error::Tls(_) | ureq::Error::Rustls(_) => AccessTransportError::Tls, + ureq::Error::BodyExceedsLimit(_) | ureq::Error::LargeResponseHeader(_, _) => { + AccessTransportError::BodyTooLarge + } + _ => AccessTransportError::Protocol, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ureq::Body; + use ureq::http::{HeaderMap, HeaderValue, Response}; + + const CANONICAL_RESOLVED: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/access-resolve-v1/canonical.json" + )); + + #[test] + fn user_input_normalizes_only_outer_ascii_space_and_case() { + let code = AccessCode::parse_user_input(" \t01abcdefghjkmnpq\r\n").expect("valid code"); + assert_eq!( + code.with_canonical_bytes(|value| *value), + *b"01ABCDEFGHJKMNPQ" + ); + for invalid in [ + "01ABCDEFGHJKMNP", + "01ABCDEFGHJKMNPQR", + "01ABC-DEFGHJKMNP", + "01AB CDEFGHJKMNP", + "01ABCDEFGHJKLMNO", + "01ABCDEFGHJKLMNI", + "01ABCDEFGHJKLMN", + "\u{2003}01ABCDEFGHJKMNPQ", + ] { + assert!( + AccessCode::parse_user_input(invalid).is_err(), + "{invalid:?}" + ); + } + } + + #[test] + fn route_hash_and_request_match_the_language_neutral_formula() { + let code = AccessCode::parse_user_input("0123456789ABCDEF").expect("valid code"); + let route = code.route_capability(); + let nonce = [0x5a; 32]; + let request = AccessResolveRequest::new(&route, nonce); + assert_eq!(request.body().len(), ACCESS_RESOLVE_REQUEST_BYTES); + assert_eq!( + std::str::from_utf8(request.body()).expect("JSON is ASCII"), + "{\"schema\":\"atrinik-access-resolve-v1\",\"routeCapability\":\"3cc820e9a4e884e9515c8f8b211d1fdfaeb75afa1319fd1a1728a050ebe1c60e\",\"clientNonce\":\"5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a\"}" + ); + } + + #[test] + fn response_metadata_rejects_redirect_and_cookie_channels() { + let mut headers = HeaderMap::new(); + headers.insert("cache-control", HeaderValue::from_static("no-store")); + headers.insert("content-type", HeaderValue::from_static(ACCESS_MEDIA_TYPE)); + assert_eq!( + selected_headers(&headers).expect("selected"), + vec![ + ("cache-control".to_owned(), "no-store".to_owned()), + ("content-type".to_owned(), ACCESS_MEDIA_TYPE.to_owned()), + ] + ); + for name in ["location", "set-cookie"] { + let mut invalid = headers.clone(); + invalid.insert(name, HeaderValue::from_static("forbidden")); + assert_eq!( + selected_headers(&invalid), + Err(AccessTransportError::Protocol) + ); + } + } + + #[test] + fn response_body_limit_is_enforced_before_parsing_private_data() { + let exact = vec![b'a'; ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT]; + let mut response = Response::builder() + .status(200) + .body(Body::builder().data(exact.clone())) + .expect("response"); + assert_eq!(read_bounded_body(&mut response), Ok(exact)); + + let oversized = vec![b'a'; ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT + 1]; + let mut response = Response::builder() + .status(200) + .body(Body::builder().data(oversized)) + .expect("response"); + assert_eq!( + read_bounded_body(&mut response), + Err(AccessTransportError::BodyTooLarge) + ); + } + + #[test] + fn unavailable_response_has_one_exact_uncached_shape() { + let code = AccessCode::parse_user_input("0123456789ABCDEF").expect("code"); + let request = AccessResolveRequest::new(&code.route_capability(), [0x5a; 32]); + let response = AccessResolveResponse { + status: 404, + headers: vec![ + ("cache-control".to_owned(), "no-store".to_owned()), + ("content-type".to_owned(), ACCESS_MEDIA_TYPE.to_owned()), + ], + body: br#"{"error":{"code":"access_unavailable"}}"#.to_vec(), + }; + assert!(matches!( + validate_access_response(&response, &request, 1_000, None), + Ok(AccessResolution::Unavailable) + )); + + for invalid in [ + AccessResolveResponse { + status: 404, + headers: response.headers.clone(), + body: br#"{"error":{"code":"access_expired"}}"#.to_vec(), + }, + AccessResolveResponse { + status: 404, + headers: vec![("content-type".to_owned(), ACCESS_MEDIA_TYPE.to_owned())], + body: response.body.clone(), + }, + ] { + assert!(matches!( + validate_access_response(&invalid, &request, 1_000, None), + Err(AccessResponseError::Protocol) + )); + } + } + + #[test] + fn resolved_response_binds_nonce_leaf_identity_and_gp1_spki_pin() { + let code = AccessCode::parse_user_input("0123456789ABCDEF").expect("code"); + let request = AccessResolveRequest::new(&code.route_capability(), [0x22; 32]); + let response = AccessResolveResponse { + status: 200, + headers: vec![ + ("cache-control".to_owned(), "no-store".to_owned()), + ("content-type".to_owned(), ACCESS_MEDIA_TYPE.to_owned()), + ( + "content-length".to_owned(), + CANONICAL_RESOLVED.len().to_string(), + ), + ], + body: CANONICAL_RESOLVED.to_vec(), + }; + let expected_server_id = [ + 0x0d, 0x61, 0xda, 0xe9, 0x42, 0x26, 0xa6, 0x8c, 0x24, 0x52, 0x59, 0x88, 0x98, 0xd3, + 0x3e, 0xf8, 0xeb, 0x97, 0xa7, 0x3a, 0x04, 0x02, 0x94, 0x82, 0x5c, 0x2e, 0xed, 0xb0, + 0x1d, 0x6a, 0xee, 0x40, + ]; + let AccessResolution::Resolved(resolved) = + validate_access_response(&response, &request, 1_000, Some(&expected_server_id)) + .expect("resolved") + else { + panic!("unexpected unavailable response"); + }; + assert_eq!(resolved.server_id, expected_server_id); + assert_eq!(resolved.generation, [0x11; 32]); + assert_eq!(resolved.with_grant(|grant| *grant), [0x33; 32]); + assert_eq!( + resolved.certificate_identities.gp1_spki_pin, + [ + 0x5c, 0xd2, 0x52, 0xfb, 0x0c, 0xe8, 0x93, 0x24, 0x36, 0xfa, 0xf8, 0xcc, 0xd1, 0x04, + 0x09, 0x81, 0xb8, 0x9e, 0xe4, 0xad, 0x6b, 0x9f, 0xe9, 0xe2, 0xa2, 0xb7, 0xe7, 0x1a, + 0xac, 0xb2, 0x7c, 0xd3, + ] + ); + assert!(matches!( + validate_access_response(&response, &request, 1_000, Some(&[0; 32])), + Err(AccessResponseError::Identity) + )); + } +} diff --git a/crates/atrinik-directory/src/cache.rs b/crates/atrinik-directory/src/cache.rs index 3548b97..294d4be 100644 --- a/crates/atrinik-directory/src/cache.rs +++ b/crates/atrinik-directory/src/cache.rs @@ -882,7 +882,7 @@ mod tests { let root = test_root("privacy"); let body = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/canonical.json" + "/../../fixtures/metaserver-directory-v2/canonical.json" )); let mut cache = FileDirectoryCache::new(&root); cache.store(&record(42, body)).expect("store"); diff --git a/crates/atrinik-directory/src/lib.rs b/crates/atrinik-directory/src/lib.rs index 33fdb50..d5a90a6 100644 --- a/crates/atrinik-directory/src/lib.rs +++ b/crates/atrinik-directory/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] //! Fixed-origin, bounded, transactional Game Protocol 1 server discovery. +pub mod access; pub mod cache; pub mod transport; @@ -145,7 +146,7 @@ impl DirectoryView { Ok(DiscoveredConnectionPlan { server_id: server.server_id, certificate_sha256: server.certificate_sha256, - password_required: server.password_required, + access_required: server.access_required, direct_endpoint: server.endpoint.clone(), rendezvous_url, }) @@ -162,7 +163,7 @@ pub enum RendezvousSupport { pub struct DiscoveredConnectionPlan { pub server_id: [u8; 32], pub certificate_sha256: [u8; 32], - pub password_required: bool, + pub access_required: bool, pub direct_endpoint: Option, pub rendezvous_url: Option, } @@ -607,11 +608,11 @@ mod tests { const PUBLISHED_AT: u64 = GENERATED_AT; const CANONICAL: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/canonical.json" + "/../../fixtures/metaserver-directory-v2/canonical.json" )); - const EMPTY: &[u8] = b"{\"schema\":\"atrinik-directory-v1\",\"generation\":\"1\",\"generatedAt\":\"1786219200\",\"expiresAt\":\"1786233600\",\"servers\":[]}\n"; + const EMPTY: &[u8] = b"{\"schema\":\"atrinik-game-directory-v2\",\"generation\":\"1\",\"generatedAt\":\"1786219200\",\"expiresAt\":\"1786233600\",\"servers\":[]}\n"; const EXPECTED_BODY_SHA256: &str = - "059f559d0fe439576cae10bd623eb79ab6dfd6d0a78420563730c07cf9727d78"; + "4fa5013b204c97668b8a3ff719b5b0aaa33dbe8b5cf90d2e90bb436a91d406fa"; const EXPECTED_ETAG: &str = "\"0123456789abcdef0123456789abcdef\""; #[derive(Default)] diff --git a/crates/atrinik-protocol-adapter/Cargo.toml b/crates/atrinik-protocol-adapter/Cargo.toml index 1fc54ef..14177ef 100644 --- a/crates/atrinik-protocol-adapter/Cargo.toml +++ b/crates/atrinik-protocol-adapter/Cargo.toml @@ -10,6 +10,9 @@ repository.workspace = true atrinik-actions.workspace = true atrinik-protocol.workspace = true atrinik-session.workspace = true +sha2.workspace = true +x509-cert.workspace = true +zeroize.workspace = true [lints] workspace = true diff --git a/crates/atrinik-protocol-adapter/src/access.rs b/crates/atrinik-protocol-adapter/src/access.rs new file mode 100644 index 0000000..9c91599 --- /dev/null +++ b/crates/atrinik-protocol-adapter/src/access.rs @@ -0,0 +1,237 @@ +//! Bounded GP1 access negotiation at the generated-contract boundary. + +use atrinik_protocol::game::v1::{ + AccessAuth, AccessPolicy, AccessResult, AccessStatus, Capability, ServerHello, SessionId, +}; +use atrinik_session::Event; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use zeroize::Zeroize; + +const SESSION_ID_BYTES: usize = 16; +const ACCESS_CODE_BYTES: usize = 16; +const ACCESS_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccessNegotiation { + pub session_id: [u8; SESSION_ID_BYTES], + pub event: Event, +} + +/// A session-bound GP1 credential message whose code bytes are never printable. +pub struct AccessAuthentication { + message: AccessAuth, +} + +impl AccessAuthentication { + /// Borrows the generated message only for immediate encrypted serialization. + pub fn with_message(&self, use_message: impl FnOnce(&AccessAuth) -> T) -> T { + use_message(&self.message) + } +} + +impl Drop for AccessAuthentication { + fn drop(&mut self) { + let bytes = std::mem::take(&mut self.message.code); + if let Ok(mut bytes) = bytes.try_into_mut() { + bytes.as_mut().zeroize(); + } + if let Some(session_id) = &mut self.message.session_id { + let bytes = std::mem::take(&mut session_id.value); + if let Ok(mut bytes) = bytes.try_into_mut() { + bytes.as_mut().zeroize(); + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccessAdapterError { + UnsupportedVersion, + MissingCapability, + InvalidPolicy, + InvalidSession, + InvalidIdentity, + InvalidCode, + InvalidResult, +} + +impl Display for AccessAdapterError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::UnsupportedVersion => "server does not support GP1 access negotiation", + Self::MissingCapability => "server omitted the access-token capability", + Self::InvalidPolicy => "server access policy is invalid", + Self::InvalidSession => "server session identity is invalid", + Self::InvalidIdentity => "server transport identity changed", + Self::InvalidCode => "access code is invalid", + Self::InvalidResult => "server access result is invalid", + }) + } +} + +impl Error for AccessAdapterError {} + +pub fn negotiate_server_access( + hello: &ServerHello, + expected_gp1_spki_pin: &[u8; 32], +) -> Result { + let version = hello + .version + .as_ref() + .ok_or(AccessAdapterError::UnsupportedVersion)?; + if version.major != 1 || version.minor < 1 { + return Err(AccessAdapterError::UnsupportedVersion); + } + if !hello + .capabilities + .contains(&(Capability::AccessTokensV1 as i32)) + { + return Err(AccessAdapterError::MissingCapability); + } + let session_id: [u8; SESSION_ID_BYTES] = hello + .session_id + .as_ref() + .ok_or(AccessAdapterError::InvalidSession)? + .value + .as_ref() + .try_into() + .map_err(|_| AccessAdapterError::InvalidSession)?; + let server_identity: [u8; 32] = hello + .server_identity + .as_ref() + .ok_or(AccessAdapterError::InvalidIdentity)? + .value + .as_ref() + .try_into() + .map_err(|_| AccessAdapterError::InvalidIdentity)?; + if &server_identity != expected_gp1_spki_pin { + return Err(AccessAdapterError::InvalidIdentity); + } + let event = match AccessPolicy::try_from(hello.access_policy) { + Ok(AccessPolicy::Open) => Event::Connected, + Ok(AccessPolicy::Protected) => Event::AccessRequired, + Ok(AccessPolicy::Unspecified) | Err(_) => return Err(AccessAdapterError::InvalidPolicy), + }; + Ok(AccessNegotiation { session_id, event }) +} + +pub fn access_auth( + canonical_code: &[u8; ACCESS_CODE_BYTES], + session_id: [u8; SESSION_ID_BYTES], +) -> Result { + if !canonical_code + .iter() + .all(|value| ACCESS_ALPHABET.contains(value)) + { + return Err(AccessAdapterError::InvalidCode); + } + Ok(AccessAuthentication { + message: AccessAuth { + code: canonical_code.to_vec().into(), + session_id: Some(SessionId { + value: session_id.to_vec().into(), + }), + }, + }) +} + +pub fn access_result_event(result: &AccessResult) -> Result { + match AccessStatus::try_from(result.status) { + Ok(AccessStatus::Accepted) => Ok(Event::Connected), + Ok(AccessStatus::Unavailable) => Ok(Event::AccessUnavailable), + Ok(AccessStatus::Unspecified) | Err(_) => Err(AccessAdapterError::InvalidResult), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atrinik_protocol::game::v1::{Digest256, ProtocolVersion}; + + fn hello(policy: AccessPolicy) -> ServerHello { + ServerHello { + version: Some(ProtocolVersion { major: 1, minor: 1 }), + capabilities: vec![Capability::AccessTokensV1 as i32], + session_id: Some(SessionId { + value: vec![7; SESSION_ID_BYTES].into(), + }), + server_identity: Some(Digest256 { + value: vec![9; 32].into(), + }), + maximum_gameplay_frame_bytes: 1, + maximum_resource_frame_bytes: 1, + idle_timeout: None, + access_policy: policy as i32, + } + } + + #[test] + fn mandatory_policy_maps_to_session_gate() { + assert_eq!( + negotiate_server_access(&hello(AccessPolicy::Open), &[9; 32]), + Ok(AccessNegotiation { + session_id: [7; SESSION_ID_BYTES], + event: Event::Connected, + }) + ); + assert_eq!( + negotiate_server_access(&hello(AccessPolicy::Protected), &[9; 32]), + Ok(AccessNegotiation { + session_id: [7; SESSION_ID_BYTES], + event: Event::AccessRequired, + }) + ); + } + + #[test] + fn missing_capability_unknown_policy_and_changed_pin_fail_closed() { + let mut invalid = hello(AccessPolicy::Open); + invalid.version = Some(ProtocolVersion { major: 1, minor: 0 }); + assert_eq!( + negotiate_server_access(&invalid, &[9; 32]), + Err(AccessAdapterError::UnsupportedVersion) + ); + invalid = hello(AccessPolicy::Open); + invalid.capabilities.clear(); + assert_eq!( + negotiate_server_access(&invalid, &[9; 32]), + Err(AccessAdapterError::MissingCapability) + ); + invalid = hello(AccessPolicy::Open); + invalid.access_policy = 99; + assert_eq!( + negotiate_server_access(&invalid, &[9; 32]), + Err(AccessAdapterError::InvalidPolicy) + ); + assert_eq!( + negotiate_server_access(&hello(AccessPolicy::Open), &[8; 32]), + Err(AccessAdapterError::InvalidIdentity) + ); + } + + #[test] + fn access_auth_is_session_bound_and_results_are_indistinguishable() { + let auth = access_auth(b"0123456789ABCDEF", [7; SESSION_ID_BYTES]).expect("auth"); + auth.with_message(|message| { + assert_eq!(message.code.as_ref(), b"0123456789ABCDEF"); + assert_eq!( + message.session_id.as_ref().expect("session").value.as_ref(), + &[7; SESSION_ID_BYTES] + ); + }); + assert!(access_auth(b"0123456789ABCDEO", [7; SESSION_ID_BYTES]).is_err()); + assert_eq!( + access_result_event(&AccessResult { + status: AccessStatus::Accepted as i32, + }), + Ok(Event::Connected) + ); + assert_eq!( + access_result_event(&AccessResult { + status: AccessStatus::Unavailable as i32, + }), + Ok(Event::AccessUnavailable) + ); + } +} diff --git a/crates/atrinik-protocol-adapter/src/directory.rs b/crates/atrinik-protocol-adapter/src/directory.rs index 7987de6..2a01f41 100644 --- a/crates/atrinik-protocol-adapter/src/directory.rs +++ b/crates/atrinik-protocol-adapter/src/directory.rs @@ -1,11 +1,11 @@ //! Bounded adapter from the released static-directory wire model to client data. -use atrinik_protocol::metaserver::directory::{ +use atrinik_protocol::metaserver::directory_v2::{ DirectoryError as ProtocolDirectoryError, MAXIMUM_DIRECTORY_BODY_BYTES, MAXIMUM_DIRECTORY_FUTURE_SKEW, MAXIMUM_DIRECTORY_SERVERS, directory_server_compatible, parse_directory_json, }; -use atrinik_protocol::metaserver::v1::{ +use atrinik_protocol::metaserver::v2::{ DirectEndpoint as ProtocolEndpoint, DirectoryServer as ProtocolServer, DirectoryServerStatus as ProtocolStatus, }; @@ -197,7 +197,7 @@ pub struct DirectoryServer { pub players_online: u32, pub players_capacity: u32, pub status: DirectoryServerStatus, - pub password_required: bool, + pub access_required: bool, pub endpoint: Option, } @@ -273,7 +273,7 @@ fn convert_server(server: ProtocolServer) -> Result u8 { #[cfg(test)] mod tests { use super::*; - use atrinik_protocol::metaserver::directory::{marshal_directory_json, parse_directory_json}; + use atrinik_protocol::metaserver::directory_v2::{ + marshal_directory_json, parse_directory_json, + }; const CANONICAL: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/canonical.json" + "/../../fixtures/metaserver-directory-v2/canonical.json" )); const CONTENT_REVISION: [u8; 32] = [0xaa; 32]; @@ -358,7 +360,7 @@ mod tests { assert_eq!(server.players_online, 3); assert_eq!(server.players_capacity, 64); assert_eq!(server.status, DirectoryServerStatus::Online); - assert!(!server.password_required); + assert!(!server.access_required); assert_eq!( server.endpoint, Some(DirectEndpoint { @@ -451,84 +453,84 @@ mod tests { ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-unsupported-schema.json" + "/../../fixtures/metaserver-directory-v2/negative-unsupported-schema.json" )), DirectoryValidationError::UnsupportedSchema, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-zero-generation.json" + "/../../fixtures/metaserver-directory-v2/negative-zero-generation.json" )), DirectoryValidationError::InvalidGeneration, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-expired-at-generation.json" + "/../../fixtures/metaserver-directory-v2/negative-expired-at-generation.json" )), DirectoryValidationError::InvalidFreshness, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-identity-mismatch.json" + "/../../fixtures/metaserver-directory-v2/negative-identity-mismatch.json" )), DirectoryValidationError::InvalidIdentity, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-numeric-endpoint.json" + "/../../fixtures/metaserver-directory-v2/negative-numeric-endpoint.json" )), DirectoryValidationError::InvalidEndpoint, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-invalid-alabel.json" + "/../../fixtures/metaserver-directory-v2/negative-invalid-alabel.json" )), DirectoryValidationError::InvalidEndpoint, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-status-count.json" + "/../../fixtures/metaserver-directory-v2/negative-status-count.json" )), DirectoryValidationError::InvalidStatus, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-unordered-servers.json" + "/../../fixtures/metaserver-directory-v2/negative-unordered-servers.json" )), DirectoryValidationError::UnorderedServers, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-duplicate-server.json" + "/../../fixtures/metaserver-directory-v2/negative-duplicate-server.json" )), DirectoryValidationError::UnorderedServers, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-noncanonical-whitespace.json" + "/../../fixtures/metaserver-directory-v2/negative-noncanonical-whitespace.json" )), DirectoryValidationError::NonCanonicalJson, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-private-field.json" + "/../../fixtures/metaserver-directory-v2/negative-private-field.json" )), DirectoryValidationError::NonCanonicalJson, ), ( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/metaserver-directory-v1/negative-xml-noncharacter.json" + "/../../fixtures/metaserver-directory-v2/negative-xml-noncharacter.json" )), DirectoryValidationError::InvalidText, ), diff --git a/crates/atrinik-protocol-adapter/src/lib.rs b/crates/atrinik-protocol-adapter/src/lib.rs index c9794c3..86808b1 100644 --- a/crates/atrinik-protocol-adapter/src/lib.rs +++ b/crates/atrinik-protocol-adapter/src/lib.rs @@ -1,7 +1,9 @@ #![forbid(unsafe_code)] //! Boundary where future released Game Protocol 1 messages are validated. +pub mod access; pub mod directory; +pub mod trust; use atrinik_actions::ObjectHandle; use atrinik_session::{Entity, Event, RevisionedEvent, SessionError}; diff --git a/crates/atrinik-protocol-adapter/src/trust.rs b/crates/atrinik-protocol-adapter/src/trust.rs new file mode 100644 index 0000000..e482359 --- /dev/null +++ b/crates/atrinik-protocol-adapter/src/trust.rs @@ -0,0 +1,136 @@ +//! Certificate identity derivation for directory and GP1 trust boundaries. + +use sha2::{Digest, Sha256}; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use x509_cert::Certificate; +use x509_cert::der::asn1::ObjectIdentifier; +use x509_cert::der::{Decode, Encode}; + +pub const CERTIFICATE_DER_BYTES_LIMIT: usize = 2_048; +const EC_PUBLIC_KEY_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); +const P256_CURVE_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CertificateIdentities { + /// Metaserver identity: SHA-256 of the complete canonical DER certificate. + pub server_id: [u8; 32], + /// GP1 transport pin: SHA-256 of `SubjectPublicKeyInfo` DER from that certificate. + pub gp1_spki_pin: [u8; 32], +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CertificateIdentityError { + InvalidLength, + InvalidDer, + InvalidAlgorithm, + IdentityMismatch, +} + +impl Display for CertificateIdentityError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidLength => "certificate DER length is invalid", + Self::InvalidDer => "certificate DER is invalid", + Self::InvalidAlgorithm => "certificate key algorithm is invalid", + Self::IdentityMismatch => "certificate identity does not match the selected server", + }) + } +} + +impl Error for CertificateIdentityError {} + +pub fn derive_certificate_identities( + certificate_der: &[u8], +) -> Result { + if certificate_der.is_empty() || certificate_der.len() > CERTIFICATE_DER_BYTES_LIMIT { + return Err(CertificateIdentityError::InvalidLength); + } + let certificate = + Certificate::from_der(certificate_der).map_err(|_| CertificateIdentityError::InvalidDer)?; + let canonical_certificate = certificate + .to_der() + .map_err(|_| CertificateIdentityError::InvalidDer)?; + if canonical_certificate != certificate_der { + return Err(CertificateIdentityError::InvalidDer); + } + let spki = certificate.tbs_certificate().subject_public_key_info(); + let curve = spki + .algorithm + .parameters + .as_ref() + .and_then(|parameters| parameters.decode_as::().ok()); + if spki.algorithm.oid != EC_PUBLIC_KEY_OID || curve != Some(P256_CURVE_OID) { + return Err(CertificateIdentityError::InvalidAlgorithm); + } + let spki_der = spki + .to_der() + .map_err(|_| CertificateIdentityError::InvalidDer)?; + Ok(CertificateIdentities { + server_id: Sha256::digest(certificate_der).into(), + gp1_spki_pin: Sha256::digest(spki_der).into(), + }) +} + +pub fn verify_certificate_identity( + certificate_der: &[u8], + expected_server_id: &[u8; 32], +) -> Result { + let identities = derive_certificate_identities(certificate_der)?; + if &identities.server_id != expected_server_id { + return Err(CertificateIdentityError::IdentityMismatch); + } + Ok(identities) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SYNTHETIC_P256_CERTIFICATE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/access-resolve-v1/synthetic-p256.der" + )); + + #[test] + fn leaf_identity_and_gp1_pin_are_distinct_hashes_of_the_same_certificate() { + let identities = + derive_certificate_identities(SYNTHETIC_P256_CERTIFICATE).expect("fixture"); + assert_eq!( + identities.server_id, + [ + 0x0d, 0x61, 0xda, 0xe9, 0x42, 0x26, 0xa6, 0x8c, 0x24, 0x52, 0x59, 0x88, 0x98, 0xd3, + 0x3e, 0xf8, 0xeb, 0x97, 0xa7, 0x3a, 0x04, 0x02, 0x94, 0x82, 0x5c, 0x2e, 0xed, 0xb0, + 0x1d, 0x6a, 0xee, 0x40, + ] + ); + assert_eq!( + identities.gp1_spki_pin, + [ + 0x5c, 0xd2, 0x52, 0xfb, 0x0c, 0xe8, 0x93, 0x24, 0x36, 0xfa, 0xf8, 0xcc, 0xd1, 0x04, + 0x09, 0x81, 0xb8, 0x9e, 0xe4, 0xad, 0x6b, 0x9f, 0xe9, 0xe2, 0xa2, 0xb7, 0xe7, 0x1a, + 0xac, 0xb2, 0x7c, 0xd3, + ] + ); + assert_ne!(identities.server_id, identities.gp1_spki_pin); + assert_eq!( + verify_certificate_identity(SYNTHETIC_P256_CERTIFICATE, &identities.server_id), + Ok(identities) + ); + assert_eq!( + verify_certificate_identity(SYNTHETIC_P256_CERTIFICATE, &[0; 32]), + Err(CertificateIdentityError::IdentityMismatch) + ); + } + + #[test] + fn malformed_trailing_empty_and_oversized_certificates_fail_closed() { + for invalid in [b"".as_slice(), b"not DER".as_slice()] { + assert!(derive_certificate_identities(invalid).is_err()); + } + assert_eq!( + derive_certificate_identities(&vec![0u8; CERTIFICATE_DER_BYTES_LIMIT + 1]), + Err(CertificateIdentityError::InvalidLength) + ); + } +} diff --git a/crates/atrinik-session/src/lib.rs b/crates/atrinik-session/src/lib.rs index 1c7d694..76a0d50 100644 --- a/crates/atrinik-session/src/lib.rs +++ b/crates/atrinik-session/src/lib.rs @@ -17,6 +17,7 @@ const MAX_COORDINATE: i32 = 1_000_000; pub enum Phase { #[default] Disconnected, + AccessRequired, Connected, Playing, } @@ -46,6 +47,8 @@ pub struct Player { #[derive(Clone, Debug, Eq, PartialEq)] pub enum Event { + AccessRequired, + AccessUnavailable, Connected, EnteredWorld, Disconnected, @@ -170,8 +173,9 @@ impl Session { { return Err(SessionError::RevisionGap); } - let connecting = matches!(incoming.event, Event::Connected); - if connecting { + let starting = matches!(incoming.event, Event::Connected | Event::AccessRequired); + let begins_generation = starting && self.phase == Phase::Disconnected; + if begins_generation { if self.phase != Phase::Disconnected || incoming.session_generation <= self.generation { return Err(SessionError::InvalidTransition); } @@ -181,7 +185,7 @@ impl Session { self.preflight(&incoming.event)?; self.commit(incoming.event); self.revision = incoming.revision; - if connecting { + if begins_generation { self.generation = incoming.session_generation; } Ok(()) @@ -189,7 +193,15 @@ impl Session { fn preflight(&self, event: &Event) -> Result<(), SessionError> { match event { - Event::Connected if self.phase != Phase::Disconnected => { + Event::AccessRequired if self.phase != Phase::Disconnected => { + Err(SessionError::InvalidTransition) + } + Event::AccessUnavailable if self.phase != Phase::AccessRequired => { + Err(SessionError::InvalidTransition) + } + Event::Connected + if !matches!(self.phase, Phase::Disconnected | Phase::AccessRequired) => + { Err(SessionError::InvalidTransition) } Event::EnteredWorld if self.phase != Phase::Connected => { @@ -276,12 +288,16 @@ impl Session { fn commit(&mut self, event: Event) { match event { + Event::AccessRequired => { + self.reset_transient(); + self.phase = Phase::AccessRequired; + } Event::Connected => { self.reset_transient(); self.phase = Phase::Connected; } Event::EnteredWorld => self.phase = Phase::Playing, - Event::Disconnected => { + Event::AccessUnavailable | Event::Disconnected => { self.reset_transient(); self.phase = Phase::Disconnected; } diff --git a/crates/atrinik-session/tests/session.rs b/crates/atrinik-session/tests/session.rs index 4e88378..ebde769 100644 --- a/crates/atrinik-session/tests/session.rs +++ b/crates/atrinik-session/tests/session.rs @@ -327,3 +327,71 @@ fn character_selection_is_valid_only_before_entering_world() { Err(SessionError::InvalidTransition) ); } + +#[test] +fn protected_connection_requires_access_acceptance_before_account_flow() { + let mut session = Session::default(); + reduce(&mut session, 1, 1, Event::AccessRequired); + assert_eq!(session.snapshot().phase, Phase::AccessRequired); + + let mut sink = Sink::default(); + assert_eq!( + session.dispatch( + ActionRequest { + id: 1, + action: Action::SelectCharacter { character_id: 8 }, + }, + &mut sink, + ), + Err(SessionError::InvalidTransition) + ); + + reduce(&mut session, 2, 1, Event::Connected); + assert_eq!(session.snapshot().phase, Phase::Connected); + session + .dispatch( + ActionRequest { + id: 1, + action: Action::SelectCharacter { character_id: 8 }, + }, + &mut sink, + ) + .expect("account and character flow follows access acceptance"); +} + +#[test] +fn unavailable_access_closes_attempt_without_reusing_its_generation() { + let mut session = Session::default(); + reduce(&mut session, 1, 1, Event::AccessRequired); + reduce(&mut session, 2, 1, Event::AccessUnavailable); + assert_eq!(session.snapshot().phase, Phase::Disconnected); + + assert_eq!( + session.reduce(RevisionedEvent { + revision: 3, + session_generation: 1, + event: Event::Connected, + }), + Err(SessionError::InvalidTransition) + ); + reduce(&mut session, 3, 2, Event::Connected); + assert_eq!(session.snapshot().phase, Phase::Connected); +} + +#[test] +fn access_events_are_rejected_out_of_order_atomically() { + let mut session = Session::default(); + reduce(&mut session, 1, 1, Event::Connected); + let before = session.snapshot(); + for event in [Event::AccessRequired, Event::AccessUnavailable] { + assert_eq!( + session.reduce(RevisionedEvent { + revision: 2, + session_generation: 1, + event, + }), + Err(SessionError::InvalidTransition) + ); + assert_eq!(session.snapshot(), before); + } +} diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 403a863..1bcd7a4 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -7,13 +7,16 @@ origin. Static reads therefore do not invoke the metaserver Worker or D1. ## Trust and compatibility -`atrinik-protocol` 0.1.0 owns the canonical `atrinik-directory-v1` parser and -all wire bounds. Generated protocol records terminate in +`atrinik-protocol` 0.2.0 owns the canonical `atrinik-game-directory-v2` and +`atrinik-access-resolved-v1` parsers and all wire bounds. Generated protocol records terminate in `atrinik-protocol-adapter`; UI and connection code receive only client-owned -types. Every accepted server has a 32-byte server ID equal to its certificate -SHA-256. An optional DNS hostname is only an opt-in routing hint. A discovered -connection remains pinned to that certificate across cache reuse, hostname -reuse, and rendezvous. +types. Directory v2 exposes configured `accessRequired` policy and does not +accept the historical password field. Every resolved private server has a +32-byte server ID equal to SHA-256 of its exact canonical DER leaf certificate. +The GP1 pin is separately SHA-256 of SubjectPublicKeyInfo DER from that same +P-256 certificate. An optional DNS hostname is only an opt-in routing hint. A +discovered connection remains pinned to those identities across cache reuse, +hostname reuse, and rendezvous. The adapter filters before display against protocol major 1 plus the exact installed protocol minor, content ID, and content revision SHA-256. The current @@ -26,7 +29,25 @@ Addressless compatible servers are selectable when the fixed `wss://rendezvous.meta.atrinik.org` v1 capability is enabled. Each connection attempt must create fresh signaling and socket state. Directory records never contain or persist candidates, tickets, authorization transcripts, invite -capabilities, join passwords, or rendezvous tokens. +capabilities, access codes, route capabilities, grants, or rendezvous tokens. + +Private discovery accepts exactly one 16-character ASCII Crockford base32 +access code for a connection attempt, normalizes ASCII case and outer ASCII +whitespace only, and derives the route capability as +`SHA256("atrinik-access-route-v1\\0" || C)`. It sends one bounded strict `POST` +to `https://meta.atrinik.org/v1/access/resolve`; redirects, cookies, caching, +oversized bodies, changed nonces, stale grants, noncanonical JSON, classic +profiles, and certificate identity mismatches fail closed. The access code, +route capability, client nonce, response grant, and private response body have +no logging traits and use best-effort buffer clearing. They are never written +to the directory cache or placed in a URL. + +GP1 peers must negotiate protocol 1.1 and `ACCESS_TOKENS_V1`. The mandatory +server access policy gates account and character actions until a protected +connection receives an accepted, session-bound `AccessResult`. Unknown policy, +missing capability, changed SPKI identity, malformed session identity, and +unsolicited or unavailable access results stop the attempt. Account +authentication and saved-player behavior remain unchanged after acceptance. ## Fetch and resource bounds @@ -80,10 +101,12 @@ inherits hostname or identity data from a stale directory. ## Conformance and validation -`fixtures/metaserver-directory-v1.json` and its corpus are byte-identical test -data from `atrinik/protocol` revision -`8942912d55bc571213836bf1ad4ae7663d60b2a4` (v1.5.3). Tests consume the -positive vector, every declared negative error, the 512-server maximum, truncations, -deterministic mutations, metadata/cache failure matrices, 200/304/offline/stale -transitions, addressless rendezvous planning, and certificate pinning. Run the -complete repository gate with `tools/validate.sh`. +`fixtures/metaserver-directory-v2.json`, its corpus, and +`fixtures/access-resolve-v1/canonical.json` are byte-identical protocol-producer +test data for the 0.2.0 contract. Tests consume the positive vectors, every +declared directory negative error, the 512-server maximum, truncations, +deterministic mutations, access request/response bounds, DER-leaf versus SPKI +identity derivation, GP1 state transitions, metadata/cache failure matrices, +200/304/offline/stale transitions, addressless rendezvous planning, and +certificate pinning. Run the complete repository gate with +`tools/validate.sh` after the immutable 0.2.0 dependency is available. diff --git a/fixtures/README.md b/fixtures/README.md index 2123f4f..69a5f4a 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -1,11 +1,22 @@ # Protocol fixture provenance -`metaserver-directory-v1.json` and `metaserver-directory-v1/` are byte-for-byte -test inputs from `atrinik/protocol` revision -`8942912d55bc571213836bf1ad4ae7663d60b2a4`, released in protocol v1.5.3. -They are MIT language-neutral conformance data, not a copied implementation. -The pinned `atrinik-protocol` 0.1.0 crate still owns the schema parser; the -v1.5.3 language-neutral fixture owns the independent body digest and opaque -HTTP-validator vectors. Client checks pin the manifest digest and every -negative error code so fixture drift requires an explicit protocol dependency -review. +`metaserver-directory-v2.json`, `metaserver-directory-v2/`, and +`access-resolve-v1/canonical.json` are byte-for-byte inputs from +`atrinik/protocol` access-token candidate revision +`1584053ee5f5bb1d96b59ff85f4035579bc17617` governed by frozen normative specification SHA-256 +`0d469bd9fe9c4a1e814594c7248e2acf2f44711ccbc64fc953fa6ada5f3d4f43`. +An immutable `atrinik-protocol` 0.2.0 registry release remains required before +pull-request readiness; a sibling path override is used only for task-local +validation. + +`access-resolve-v1/synthetic-p256.der` is the decoded certificate from the same +protocol producer's synthetic game publisher and resolve fixtures. Its leaf DER +SHA-256 is +`0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40`; +its separately encoded SubjectPublicKeyInfo SHA-256 is +`5cd252fb0ce8932436faf8ccd1040981b89ee4ad6b9fe9e2a2b7e71aacb27cd3`. +The values are public test identities and contain no private key. + +These files are MIT language-neutral conformance data, not copied +implementations. Client checks pin their digests so fixture drift requires an +explicit protocol dependency and trust-boundary review. diff --git a/fixtures/access-resolve-v1/canonical.json b/fixtures/access-resolve-v1/canonical.json new file mode 100644 index 0000000..accf7c1 --- /dev/null +++ b/fixtures/access-resolve-v1/canonical.json @@ -0,0 +1 @@ +{"schema":"atrinik-access-resolved-v1","profile":"game","serverId":"0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40","certificate":"MIIBOjCB4KADAgECAgID6TAKBggqhkjOPQQDAjAmMSQwIgYDVQQDDBtBdHJpbmlrIGFjY2VzcyBmaXh0dXJlIG9ubHkwHhcNMjYwMTAxMDAwMDAwWhcNMzYwMTAxMDAwMDAwWjAmMSQwIgYDVQQDDBtBdHJpbmlrIGFjY2VzcyBmaXh0dXJlIG9ubHkwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1MAoGCCqGSM49BAMCA0kAMEYCIQDuJYjSE1s0zA8WTnf+zwhLUj7HiAN3I4u9Se0dmU2jvAIhALgq0zfa5cvIFi8xBKYqCN8gNsxnhvb2qPHKe/pUq8DT","name":"Synthetic Access Fixture","accessRequired":true,"generation":"1111111111111111111111111111111111111111111111111111111111111111","clientNonce":"2222222222222222222222222222222222222222222222222222222222222222","grant":"3333333333333333333333333333333333333333333333333333333333333333","expiresAt":"1005","endpoint":{"hostname":"play.example.org","port":13327}} \ No newline at end of file diff --git a/fixtures/access-resolve-v1/synthetic-p256.der b/fixtures/access-resolve-v1/synthetic-p256.der new file mode 100644 index 0000000000000000000000000000000000000000..384a7f0c61dfd85072302d3d31c1f062e173f027 GIT binary patch literal 318 zcmXqLVze@7e6WC-iIItkiTR}g7aNCGo5wj@7G@>`HA58xB{t?z7G@r4$C9GVyv%Hc z#N_1E;$nrg%!-oIqEv - - + + Atrinik "Alpha" Cooperative Ω eu-west @@ -9,7 +9,7 @@ - + Beta diff --git a/policy/dependencies.json b/policy/dependencies.json index 257ea38..8f33c15 100644 --- a/policy/dependencies.json +++ b/policy/dependencies.json @@ -6,14 +6,16 @@ "transitive_license_allowlist": ["Apache-2.0", "BSD-3-Clause", "CDLA-Permissive-2.0", "ISC", "MIT", "Unicode-3.0", "Zlib"], "eol_response": "upgrade, replace, or remove before an unsupported release ships", "direct_dependencies": [ - {"name":"atrinik-protocol","native":null,"license":"MIT","source":"https://crates.io/crates/atrinik-protocol","purpose":"released Game Protocol 1 static-directory parser and bounds","validation":"Cargo.lock, protocol fixtures, cargo-deny, unit tests, SBOM"}, + {"name":"atrinik-protocol","native":null,"license":"MIT","source":"https://crates.io/crates/atrinik-protocol","purpose":"released Game Protocol 1 access negotiation, static-directory, and resolve parser contracts","validation":"Cargo.lock, protocol fixtures, cargo-deny, unit tests, SBOM"}, {"name":"httpdate","native":null,"license":"MIT OR Apache-2.0","source":"https://crates.io/crates/httpdate","purpose":"strict static-directory Last-Modified and Retry-After handling","validation":"Cargo.lock, cargo-deny, metadata boundary tests, SBOM"}, {"name":"sdl3","native":"SDL 3.4.18","license":"MIT AND Zlib","source":"https://crates.io/crates/sdl3","purpose":"isolated platform/window/input/audio boundary","validation":"Cargo.lock, cargo-deny, source-static build, Linux/Windows checks"}, {"name":"sha2","native":null,"license":"MIT OR Apache-2.0","source":"https://crates.io/crates/sha2","purpose":"authenticated resource and static-directory body/cache identity digest verification","validation":"Cargo.lock, cargo-deny, unit tests, SBOM"}, - {"name":"ureq","native":null,"license":"MIT OR Apache-2.0","source":"https://crates.io/crates/ureq","purpose":"bounded blocking HTTPS retrieval of the fixed static directory origin","validation":"Cargo.lock, cargo-deny, fixed-request transport tests, Linux/Windows builds, SBOM"} + {"name":"ureq","native":null,"license":"MIT OR Apache-2.0","source":"https://crates.io/crates/ureq","purpose":"bounded blocking HTTPS retrieval of the fixed static directory and access-resolve origins","validation":"Cargo.lock, cargo-deny, fixed-request transport tests, Linux/Windows builds, SBOM"}, + {"name":"x509-cert","native":null,"license":"Apache-2.0 OR MIT","source":"https://crates.io/crates/x509-cert","purpose":"bounded DER parsing and SubjectPublicKeyInfo encoding for certificate and GP1 identity derivation","validation":"Cargo.lock, cargo-deny, protocol certificate fixture tests, SBOM"}, + {"name":"zeroize","native":null,"license":"Apache-2.0 OR MIT","source":"https://crates.io/crates/zeroize","purpose":"best-effort clearing of access codes, route capabilities, nonces, grants, and private response buffers","validation":"Cargo.lock, cargo-deny, access boundary tests, SBOM"} ], "compatibility": [ - {"name":"atrinik/protocol","contract":"game-protocol-1 and atrinik-directory-v1","dependency_status":"released crates.io package pinned exactly; path and Git dependencies forbidden"}, + {"name":"atrinik/protocol","contract":"game-protocol-1.1, atrinik-game-directory-v2, and atrinik-access-resolved-v1","dependency_status":"0.2.0 release required on crates.io and pinned exactly; path and Git dependencies forbidden"}, {"name":"atrinik/renderer","contract":"scene-snapshot-1","dependency_status":"awaiting released registry crate; path and Git dependencies forbidden"} ], "host_build_dependencies": [ diff --git a/tools/check-architecture.sh b/tools/check-architecture.sh index 735f7c9..7316e6e 100755 --- a/tools/check-architecture.sh +++ b/tools/check-architecture.sh @@ -14,10 +14,10 @@ jq -e ' (deps("atrinik-session") | sort == ["atrinik-actions"]) and (deps("atrinik-ui-model") | sort == ["atrinik-actions", "atrinik-session"]) and (deps("atrinik-scene-adapter") == ["atrinik-session"]) and - (deps("atrinik-protocol-adapter") | sort == ["atrinik-actions", "atrinik-protocol", "atrinik-session"]) and - (deps("atrinik-directory") | sort == ["atrinik-protocol-adapter", "httpdate", "sha2", "ureq"]) and + (deps("atrinik-protocol-adapter") | sort == ["atrinik-actions", "atrinik-protocol", "atrinik-session", "sha2", "x509-cert", "zeroize"]) and + (deps("atrinik-directory") | sort == ["atrinik-protocol", "atrinik-protocol-adapter", "httpdate", "sha2", "ureq", "zeroize"]) and (deps("atrinik-platform") | sort == ["atrinik-actions", "sdl3"]) and - ([.packages[] | select(.source == null and (.name | startswith("atrinik-"))) | .dependencies[] | select(.source != null) | .name] | all(. == "atrinik-protocol" or . == "httpdate" or . == "sdl3" or . == "sha2" or . == "ureq")) + ([.workspace_members[] as $member | .packages[] | select(.id == $member) | .dependencies[] | select(.source != null) | .name] | all(. == "atrinik-protocol" or . == "httpdate" or . == "sdl3" or . == "sha2" or . == "ureq" or . == "x509-cert" or . == "zeroize")) ' "${metadata}" >/dev/null duplicates=$(jq -r '[.packages[] | select(.links != null) | .links] | group_by(.)[] | select(length > 1) | .[0]' "${metadata}") diff --git a/tools/check-foundations.sh b/tools/check-foundations.sh index 84b33a2..8f3f1a5 100755 --- a/tools/check-foundations.sh +++ b/tools/check-foundations.sh @@ -22,10 +22,13 @@ if find crates -type f \( -name '*.pb.rs' -o -name '*_generated.rs' \) -print -q exit 1 fi -for required in CONTRIBUTING.md PROVENANCE.md SECURITY.md docs/PLATFORM.md docs/DIRECTORY.md decisions/0001-client-architecture.md fixtures/README.md fixtures/metaserver-directory-v1.json; do test -s "${required}"; done -test "$(sha256sum fixtures/metaserver-directory-v1/canonical.json | awk '{print $1}')" = 059f559d0fe439576cae10bd623eb79ab6dfd6d0a78420563730c07cf9727d78 -test "$(sha256sum fixtures/metaserver-directory-v1.json | awk '{print $1}')" = 0aa322621a3057dbeb0e738c7d54e7239c87be20933a2938e626c816e25c51ae -test "$(git check-attr eol -- fixtures/metaserver-directory-v1/canonical.json)" = "fixtures/metaserver-directory-v1/canonical.json: eol: lf" +for required in CONTRIBUTING.md PROVENANCE.md SECURITY.md docs/PLATFORM.md docs/DIRECTORY.md decisions/0001-client-architecture.md fixtures/README.md fixtures/metaserver-directory-v2.json fixtures/access-resolve-v1/canonical.json fixtures/access-resolve-v1/synthetic-p256.der; do test -s "${required}"; done +test "$(sha256sum fixtures/metaserver-directory-v2/canonical.json | awk '{print $1}')" = 4fa5013b204c97668b8a3ff719b5b0aaa33dbe8b5cf90d2e90bb436a91d406fa +test "$(sha256sum fixtures/metaserver-directory-v2.json | awk '{print $1}')" = 19ef15b7c3a97db28bb42eb87dd7a253e848a1a003d1f320dbdd31f289f5cf89 +test "$(sha256sum fixtures/access-resolve-v1/canonical.json | awk '{print $1}')" = 857bab164da8d2ed638eacf9a568d0cfb4466c2a039816a17f750abb85443b30 +test "$(sha256sum fixtures/access-resolve-v1/synthetic-p256.der | awk '{print $1}')" = 0d61dae94226a68c2452598898d33ef8eb97a73a040294825c2eedb01d6aee40 +test "$(git check-attr eol -- fixtures/metaserver-directory-v2/canonical.json)" = "fixtures/metaserver-directory-v2/canonical.json: eol: lf" +test "$(git check-attr eol -- fixtures/access-resolve-v1/canonical.json)" = "fixtures/access-resolve-v1/canonical.json: eol: lf" if grep -RInE '(index\.wsgi|/v2/|index\.xml)' crates --include='*.rs'; then echo "replacement client source contains a classic metaserver route" >&2 exit 1 From 41f37334b554834c8a04616d941d287e07b561c9 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 10:25:51 -0500 Subject: [PATCH 2/3] fix(access): enforce protected admission state --- PROVENANCE.md | 11 ++ crates/atrinik-directory/src/access.rs | 19 ++- crates/atrinik-protocol-adapter/src/access.rs | 28 +++-- crates/atrinik-session/src/lib.rs | 31 ++++- crates/atrinik-session/tests/session.rs | 113 +++++++++++++++++- docs/DIRECTORY.md | 6 +- fixtures/README.md | 6 +- 7 files changed, 187 insertions(+), 27 deletions(-) diff --git a/PROVENANCE.md b/PROVENANCE.md index 634851e..485b411 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -41,6 +41,17 @@ interoperability facts. No protocol implementation, classic source, or historical client code was copied. The language-neutral fixture manifest is retained byte-for-byte and independently digest-pinned. +The directory-v2, access-resolution, GP1 access-policy, and certificate trust +consumers are newly authored from the public MIT `atrinik/protocol` access-token +candidate fixture revision `1584053ee5f5bb1d96b59ff85f4035579bc17617` and +the service-origin clarification at +`b7c8d22ed9acd53bf31818bca5eda10a6f51d3e2`. The relevant normative +specification has SHA-256 +`1f09123f8432b6bbfb0bfed35ebe7e00df9dbe822e5126cd363f8c681553d872`. +Generated Rust bindings remain an external dependency and require an immutable +published `atrinik-protocol` 0.2.0 release before pull-request readiness; no +protocol implementation or historical client source was copied. + `provenance/identity-reference.synthetic.json` demonstrates the canonical privacy-preserving identity reference workflow for issue #386. It is newly reviewer-signed synthetic evidence only: it grants no permission for real diff --git a/crates/atrinik-directory/src/access.rs b/crates/atrinik-directory/src/access.rs index e8970fa..9b610bb 100644 --- a/crates/atrinik-directory/src/access.rs +++ b/crates/atrinik-directory/src/access.rs @@ -16,7 +16,7 @@ use zeroize::Zeroize; pub const ACCESS_CODE_BYTES: usize = 16; pub const ACCESS_RESOLVE_REQUEST_BYTES: usize = 204; pub const ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT: usize = 8 * 1024; -pub const ACCESS_RESOLVE_URL: &str = "https://meta.atrinik.org/v1/access/resolve"; +pub const ACCESS_RESOLVE_URL: &str = "https://rendezvous.meta.atrinik.org/v1/access/resolve"; const ACCESS_MEDIA_TYPE: &str = "application/json; charset=utf-8"; const MAXIMUM_RESPONSE_HEADER_BYTES: usize = 8 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); @@ -385,10 +385,16 @@ fn read_bounded_body( let mut output = Vec::new(); let mut buffer = [0u8; 8 * 1024]; loop { - let read = reader - .read(&mut buffer) - .map_err(|error| classify_body_error(&error))?; + let read = match reader.read(&mut buffer) { + Ok(read) => read, + Err(error) => { + output.zeroize(); + buffer.zeroize(); + return Err(classify_body_error(&error)); + } + }; if read == 0 { + buffer.zeroize(); return Ok(output); } let next = output @@ -397,6 +403,7 @@ fn read_bounded_body( .ok_or(AccessTransportError::BodyTooLarge)?; if next > ACCESS_RESOLVE_RESPONSE_BYTES_LIMIT { output.zeroize(); + buffer.zeroize(); return Err(AccessTransportError::BodyTooLarge); } output.extend_from_slice(&buffer[..read]); @@ -464,6 +471,10 @@ mod tests { #[test] fn route_hash_and_request_match_the_language_neutral_formula() { + assert_eq!( + ACCESS_RESOLVE_URL, + "https://rendezvous.meta.atrinik.org/v1/access/resolve" + ); let code = AccessCode::parse_user_input("0123456789ABCDEF").expect("valid code"); let route = code.route_capability(); let nonce = [0x5a; 32]; diff --git a/crates/atrinik-protocol-adapter/src/access.rs b/crates/atrinik-protocol-adapter/src/access.rs index 9c91599..f27ab68 100644 --- a/crates/atrinik-protocol-adapter/src/access.rs +++ b/crates/atrinik-protocol-adapter/src/access.rs @@ -10,6 +10,7 @@ use zeroize::Zeroize; const SESSION_ID_BYTES: usize = 16; const ACCESS_CODE_BYTES: usize = 16; +const SUPPORTED_PROTOCOL_MINOR: u32 = 1; const ACCESS_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; #[derive(Clone, Debug, Eq, PartialEq)] @@ -28,6 +29,12 @@ impl AccessAuthentication { pub fn with_message(&self, use_message: impl FnOnce(&AccessAuth) -> T) -> T { use_message(&self.message) } + + /// Returns the state transition to apply only after the encrypted write succeeds. + #[must_use] + pub const fn sent_event(&self) -> Event { + Event::AccessAuthenticationSent + } } impl Drop for AccessAuthentication { @@ -80,7 +87,7 @@ pub fn negotiate_server_access( .version .as_ref() .ok_or(AccessAdapterError::UnsupportedVersion)?; - if version.major != 1 || version.minor < 1 { + if version.major != 1 || version.minor != SUPPORTED_PROTOCOL_MINOR { return Err(AccessAdapterError::UnsupportedVersion); } if !hello @@ -138,7 +145,7 @@ pub fn access_auth( pub fn access_result_event(result: &AccessResult) -> Result { match AccessStatus::try_from(result.status) { - Ok(AccessStatus::Accepted) => Ok(Event::Connected), + Ok(AccessStatus::Accepted) => Ok(Event::AccessAccepted), Ok(AccessStatus::Unavailable) => Ok(Event::AccessUnavailable), Ok(AccessStatus::Unspecified) | Err(_) => Err(AccessAdapterError::InvalidResult), } @@ -186,13 +193,15 @@ mod tests { #[test] fn missing_capability_unknown_policy_and_changed_pin_fail_closed() { + for minor in [0, 2, u32::MAX] { + let mut invalid = hello(AccessPolicy::Open); + invalid.version = Some(ProtocolVersion { major: 1, minor }); + assert_eq!( + negotiate_server_access(&invalid, &[9; 32]), + Err(AccessAdapterError::UnsupportedVersion) + ); + } let mut invalid = hello(AccessPolicy::Open); - invalid.version = Some(ProtocolVersion { major: 1, minor: 0 }); - assert_eq!( - negotiate_server_access(&invalid, &[9; 32]), - Err(AccessAdapterError::UnsupportedVersion) - ); - invalid = hello(AccessPolicy::Open); invalid.capabilities.clear(); assert_eq!( negotiate_server_access(&invalid, &[9; 32]), @@ -221,11 +230,12 @@ mod tests { ); }); assert!(access_auth(b"0123456789ABCDEO", [7; SESSION_ID_BYTES]).is_err()); + assert_eq!(auth.sent_event(), Event::AccessAuthenticationSent); assert_eq!( access_result_event(&AccessResult { status: AccessStatus::Accepted as i32, }), - Ok(Event::Connected) + Ok(Event::AccessAccepted) ); assert_eq!( access_result_event(&AccessResult { diff --git a/crates/atrinik-session/src/lib.rs b/crates/atrinik-session/src/lib.rs index 76a0d50..4969d27 100644 --- a/crates/atrinik-session/src/lib.rs +++ b/crates/atrinik-session/src/lib.rs @@ -18,6 +18,7 @@ pub enum Phase { #[default] Disconnected, AccessRequired, + AccessPending, Connected, Playing, } @@ -48,6 +49,8 @@ pub struct Player { #[derive(Clone, Debug, Eq, PartialEq)] pub enum Event { AccessRequired, + AccessAuthenticationSent, + AccessAccepted, AccessUnavailable, Connected, EnteredWorld, @@ -192,15 +195,18 @@ impl Session { } fn preflight(&self, event: &Event) -> Result<(), SessionError> { + if is_authorized_state(event) && !matches!(self.phase, Phase::Connected | Phase::Playing) { + return Err(SessionError::InvalidTransition); + } match event { - Event::AccessRequired if self.phase != Phase::Disconnected => { + Event::AccessRequired | Event::Connected if self.phase != Phase::Disconnected => { Err(SessionError::InvalidTransition) } - Event::AccessUnavailable if self.phase != Phase::AccessRequired => { + Event::AccessAuthenticationSent if self.phase != Phase::AccessRequired => { Err(SessionError::InvalidTransition) } - Event::Connected - if !matches!(self.phase, Phase::Disconnected | Phase::AccessRequired) => + Event::AccessAccepted | Event::AccessUnavailable + if self.phase != Phase::AccessPending => { Err(SessionError::InvalidTransition) } @@ -292,6 +298,8 @@ impl Session { self.reset_transient(); self.phase = Phase::AccessRequired; } + Event::AccessAuthenticationSent => self.phase = Phase::AccessPending, + Event::AccessAccepted => self.phase = Phase::Connected, Event::Connected => { self.reset_transient(); self.phase = Phase::Connected; @@ -471,6 +479,21 @@ impl Session { } } +const fn is_authorized_state(event: &Event) -> bool { + matches!( + event, + Event::PlayerStats { .. } + | Event::MapReset { .. } + | Event::EntityUpsert(_) + | Event::EntityRemoved(_) + | Event::InventoryReplay(_) + | Event::DialogReplaced(_) + | Event::QuestReplaced(_) + | Event::Message(_) + | Event::ActionResolved { .. } + ) +} + fn validate_text(text: &str) -> Result<(), SessionError> { if text.len() > MAX_TEXT_BYTES || text.contains('\0') { Err(SessionError::TextLimit) diff --git a/crates/atrinik-session/tests/session.rs b/crates/atrinik-session/tests/session.rs index ebde769..9242c46 100644 --- a/crates/atrinik-session/tests/session.rs +++ b/crates/atrinik-session/tests/session.rs @@ -346,7 +346,9 @@ fn protected_connection_requires_access_acceptance_before_account_flow() { Err(SessionError::InvalidTransition) ); - reduce(&mut session, 2, 1, Event::Connected); + reduce(&mut session, 2, 1, Event::AccessAuthenticationSent); + assert_eq!(session.snapshot().phase, Phase::AccessPending); + reduce(&mut session, 3, 1, Event::AccessAccepted); assert_eq!(session.snapshot().phase, Phase::Connected); session .dispatch( @@ -363,27 +365,72 @@ fn protected_connection_requires_access_acceptance_before_account_flow() { fn unavailable_access_closes_attempt_without_reusing_its_generation() { let mut session = Session::default(); reduce(&mut session, 1, 1, Event::AccessRequired); - reduce(&mut session, 2, 1, Event::AccessUnavailable); + reduce(&mut session, 2, 1, Event::AccessAuthenticationSent); + reduce(&mut session, 3, 1, Event::AccessUnavailable); assert_eq!(session.snapshot().phase, Phase::Disconnected); assert_eq!( session.reduce(RevisionedEvent { - revision: 3, + revision: 4, session_generation: 1, - event: Event::Connected, + event: Event::AccessAccepted, }), Err(SessionError::InvalidTransition) ); - reduce(&mut session, 3, 2, Event::Connected); + reduce(&mut session, 4, 2, Event::Connected); assert_eq!(session.snapshot().phase, Phase::Connected); } #[test] fn access_events_are_rejected_out_of_order_atomically() { + let mut before_hello = Session::default(); + let initial = before_hello.snapshot(); + assert_eq!( + before_hello.reduce(RevisionedEvent { + revision: 1, + session_generation: 0, + event: Event::AccessAccepted, + }), + Err(SessionError::InvalidTransition) + ); + assert_eq!(before_hello.snapshot(), initial); + + let mut protected = Session::default(); + reduce(&mut protected, 1, 1, Event::AccessRequired); + let required = protected.snapshot(); + for event in [Event::AccessAccepted, Event::AccessUnavailable] { + assert_eq!( + protected.reduce(RevisionedEvent { + revision: 2, + session_generation: 1, + event, + }), + Err(SessionError::InvalidTransition) + ); + assert_eq!(protected.snapshot(), required); + } + reduce(&mut protected, 2, 1, Event::AccessAuthenticationSent); + reduce(&mut protected, 3, 1, Event::AccessAccepted); + let accepted = protected.snapshot(); + assert_eq!( + protected.reduce(RevisionedEvent { + revision: 4, + session_generation: 1, + event: Event::AccessAccepted, + }), + Err(SessionError::InvalidTransition) + ); + assert_eq!(protected.snapshot(), accepted); + let mut session = Session::default(); reduce(&mut session, 1, 1, Event::Connected); let before = session.snapshot(); - for event in [Event::AccessRequired, Event::AccessUnavailable] { + for event in [ + Event::AccessRequired, + Event::AccessAuthenticationSent, + Event::AccessAccepted, + Event::AccessUnavailable, + ] { assert_eq!( session.reduce(RevisionedEvent { revision: 2, @@ -395,3 +442,57 @@ fn access_events_are_rejected_out_of_order_atomically() { assert_eq!(session.snapshot(), before); } } + +#[test] +fn protected_admission_fences_all_authorized_state_atomically() { + let mut session = Session::default(); + reduce(&mut session, 1, 1, Event::AccessRequired); + + let events = vec![ + Event::PlayerStats { + health: 1, + health_max: 1, + }, + Event::MapReset { map_generation: 1 }, + Event::EntityUpsert(Entity { + handle: ObjectHandle { + session_generation: 1, + map_generation: 0, + object_id: 1, + object_generation: 1, + }, + x: 0, + y: 0, + name: "entity".into(), + }), + Event::EntityRemoved(ObjectHandle { + session_generation: 1, + map_generation: 0, + object_id: 1, + object_generation: 1, + }), + Event::InventoryReplay(vec![]), + Event::DialogReplaced("dialog".into()), + Event::QuestReplaced("quest".into()), + Event::Message("message".into()), + Event::ActionResolved { request_id: 1 }, + ]; + + for phase_transition in [None, Some(Event::AccessAuthenticationSent)] { + if let Some(event) = phase_transition { + reduce(&mut session, 2, 1, event); + } + let before = session.snapshot(); + for event in events.clone() { + assert_eq!( + session.reduce(RevisionedEvent { + revision: before.revision + 1, + session_generation: 1, + event, + }), + Err(SessionError::InvalidTransition) + ); + assert_eq!(session.snapshot(), before); + } + } +} diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 1bcd7a4..53962c2 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -35,7 +35,7 @@ Private discovery accepts exactly one 16-character ASCII Crockford base32 access code for a connection attempt, normalizes ASCII case and outer ASCII whitespace only, and derives the route capability as `SHA256("atrinik-access-route-v1\\0" || C)`. It sends one bounded strict `POST` -to `https://meta.atrinik.org/v1/access/resolve`; redirects, cookies, caching, +to `https://rendezvous.meta.atrinik.org/v1/access/resolve`; redirects, cookies, caching, oversized bodies, changed nonces, stale grants, noncanonical JSON, classic profiles, and certificate identity mismatches fail closed. The access code, route capability, client nonce, response grant, and private response body have @@ -44,7 +44,9 @@ to the directory cache or placed in a URL. GP1 peers must negotiate protocol 1.1 and `ACCESS_TOKENS_V1`. The mandatory server access policy gates account and character actions until a protected -connection receives an accepted, session-bound `AccessResult`. Unknown policy, +connection has sent its encrypted, session-bound `AccessAuth` and then receives +an accepted `AccessResult`. World and account state are rejected throughout +that admission phase. Unknown policy, missing capability, changed SPKI identity, malformed session identity, and unsolicited or unavailable access results stop the attempt. Account authentication and saved-player behavior remain unchanged after acceptance. diff --git a/fixtures/README.md b/fixtures/README.md index 69a5f4a..03d12bb 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -3,8 +3,10 @@ `metaserver-directory-v2.json`, `metaserver-directory-v2/`, and `access-resolve-v1/canonical.json` are byte-for-byte inputs from `atrinik/protocol` access-token candidate revision -`1584053ee5f5bb1d96b59ff85f4035579bc17617` governed by frozen normative specification SHA-256 -`0d469bd9fe9c4a1e814594c7248e2acf2f44711ccbc64fc953fa6ada5f3d4f43`. +`1584053ee5f5bb1d96b59ff85f4035579bc17617`, governed by the service-origin +clarification at `b7c8d22ed9acd53bf31818bca5eda10a6f51d3e2` and its normative +specification SHA-256 +`1f09123f8432b6bbfb0bfed35ebe7e00df9dbe822e5126cd363f8c681553d872`. An immutable `atrinik-protocol` 0.2.0 registry release remains required before pull-request readiness; a sibling path override is used only for task-local validation. From 4cd8709db5e0a676e74942b8e1afc67964fc16ee Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 11:04:50 -0500 Subject: [PATCH 3/3] fix(access): validate the client access offer --- PROVENANCE.md | 2 + crates/atrinik-protocol-adapter/src/access.rs | 79 ++++++++++++++++++- docs/DIRECTORY.md | 10 ++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/PROVENANCE.md b/PROVENANCE.md index 485b411..8be133d 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -48,6 +48,8 @@ the service-origin clarification at `b7c8d22ed9acd53bf31818bca5eda10a6f51d3e2`. The relevant normative specification has SHA-256 `1f09123f8432b6bbfb0bfed35ebe7e00df9dbe822e5126cd363f8c681553d872`. +The shared client access-offer validator is consumed from reviewed protocol +candidate revision `e48902dc3cb7051e589c48fdb448af3433c12ac6`. Generated Rust bindings remain an external dependency and require an immutable published `atrinik-protocol` 0.2.0 release before pull-request readiness; no protocol implementation or historical client source was copied. diff --git a/crates/atrinik-protocol-adapter/src/access.rs b/crates/atrinik-protocol-adapter/src/access.rs index f27ab68..b3447fc 100644 --- a/crates/atrinik-protocol-adapter/src/access.rs +++ b/crates/atrinik-protocol-adapter/src/access.rs @@ -1,8 +1,10 @@ //! Bounded GP1 access negotiation at the generated-contract boundary. use atrinik_protocol::game::v1::{ - AccessAuth, AccessPolicy, AccessResult, AccessStatus, Capability, ServerHello, SessionId, + AccessAuth, AccessPolicy, AccessResult, AccessStatus, Capability, ClientHello, Platform, + ProtocolVersion, ServerHello, SessionId, }; +use atrinik_protocol::validation; use atrinik_session::Event; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -19,6 +21,27 @@ pub struct AccessNegotiation { pub event: Event, } +/// A validated GP1 1.1 access offer for immediate encrypted serialization. +pub struct AccessClientHello { + message: ClientHello, +} + +impl AccessClientHello { + /// Borrows the shared-contract-validated offer only for its immediate send. + pub fn with_message(&self, send: impl FnOnce(&ClientHello) -> T) -> T { + send(&self.message) + } +} + +impl Drop for AccessClientHello { + fn drop(&mut self) { + let bytes = std::mem::take(&mut self.message.client_nonce); + if let Ok(mut bytes) = bytes.try_into_mut() { + bytes.as_mut().zeroize(); + } + } +} + /// A session-bound GP1 credential message whose code bytes are never printable. pub struct AccessAuthentication { message: AccessAuth, @@ -54,6 +77,7 @@ impl Drop for AccessAuthentication { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AccessAdapterError { + InvalidClientHello, UnsupportedVersion, MissingCapability, InvalidPolicy, @@ -66,6 +90,7 @@ pub enum AccessAdapterError { impl Display for AccessAdapterError { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { + Self::InvalidClientHello => "client access offer is invalid", Self::UnsupportedVersion => "server does not support GP1 access negotiation", Self::MissingCapability => "server omitted the access-token capability", Self::InvalidPolicy => "server access policy is invalid", @@ -79,6 +104,34 @@ impl Display for AccessAdapterError { impl Error for AccessAdapterError {} +/// Constructs the current client's exact access offer and validates it through +/// the shared protocol contract before the caller can serialize or send it. +pub fn access_client_hello( + client_nonce: [u8; 32], +) -> Result { + let platform = if cfg!(target_os = "linux") { + Platform::Linux + } else if cfg!(target_os = "windows") { + Platform::Windows + } else { + return Err(AccessAdapterError::InvalidClientHello); + }; + let message = ClientHello { + version: Some(ProtocolVersion { + major: 1, + minor: SUPPORTED_PROTOCOL_MINOR, + }), + capabilities: vec![Capability::AccessTokensV1 as i32], + locale: "en".to_owned(), + platform: platform as i32, + build_id: env!("CARGO_PKG_VERSION").to_owned(), + client_nonce: client_nonce.to_vec().into(), + }; + validation::access_client_hello(&message) + .map_err(|_| AccessAdapterError::InvalidClientHello)?; + Ok(AccessClientHello { message }) +} + pub fn negotiate_server_access( hello: &ServerHello, expected_gp1_spki_pin: &[u8; 32], @@ -154,7 +207,29 @@ pub fn access_result_event(result: &AccessResult) -> Result ServerHello { ServerHello { diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 53962c2..feec24a 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -43,10 +43,12 @@ no logging traits and use best-effort buffer clearing. They are never written to the directory cache or placed in a URL. GP1 peers must negotiate protocol 1.1 and `ACCESS_TOKENS_V1`. The mandatory -server access policy gates account and character actions until a protected -connection has sent its encrypted, session-bound `AccessAuth` and then receives -an accepted `AccessResult`. World and account state are rejected throughout -that admission phase. Unknown policy, +client hello is constructed as the exact current 1.1 access offer and validated +through the shared protocol contract immediately before encrypted send. The +mandatory server access policy gates account and character actions until a +protected connection has sent its encrypted, session-bound `AccessAuth` and +then receives an accepted `AccessResult`. World and account state are rejected +throughout that admission phase. Unknown policy, missing capability, changed SPKI identity, malformed session identity, and unsolicited or unavailable access results stop the attempt. Account authentication and saved-player behavior remain unchanged after acceptance.