diff --git a/AGENTS.md b/AGENTS.md index 4c0c039..30634b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,11 @@ until the code that needs it exists. | Purpose | Crate | Version | | --- | --- | --- | -| JWKS fetch | `reqwest` | 0.12 (rustls) | +| HTTP client (Play Integrity + tests) | `reqwest` | 0.12 (rustls) | +| GCP auth (Play Integrity ADC) | `gcp_auth` | 0.12 (aws-lc-rs) | +| CBOR (App Attest) | `ciborium` | 0.2 | +| X.509 (App Attest chain) | `x509-parser` | 0.18 (verify) | +| P-256 assertions | `p256` | 0.13 (ecdsa) | Rust resource servers (e.g. beacon-relay) depend on `trust-auth` for TOKEN-SPEC §7 verification. Revocation/quota remain in the adopting service. diff --git a/Cargo.lock b/Cargo.lock index 09f61f6..194d121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -267,6 +276,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -323,6 +346,22 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -608,6 +647,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -717,6 +762,33 @@ dependencies = [ "slab", ] +[[package]] +name = "gcp_auth" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d27dbcc645b60b8e7f6e2868a9d7102ece97d1bb49c1288b5321fcc67f7260" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "chrono", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-futures", + "url", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -780,6 +852,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -888,6 +979,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -909,6 +1001,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -938,6 +1031,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1316,6 +1433,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "p256" version = "0.13.2" @@ -1399,6 +1522,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1776,6 +1919,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1784,6 +1928,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1800,6 +1956,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted 0.9.0", @@ -1817,6 +1974,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1837,6 +2003,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2361,6 +2550,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -2436,6 +2635,7 @@ dependencies = [ "dashmap", "ed25519-dalek", "figment", + "gcp_auth", "hex", "http-body-util", "jsonwebtoken", @@ -2700,12 +2900,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 0011d40..853c631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,11 +67,14 @@ serde_bytes = "0.11.19" x509-parser = { version = "0.18.1", features = ["verify"] } p256 = { version = "0.13", features = ["ecdsa"] } +# Platform attestation — Google Play Integrity decode (HTTP + ADC) +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +gcp_auth = { version = "0.12", default-features = false, features = ["aws-lc-rs"] } + [dev-dependencies] signinwithethereum = "0.8" http-body-util = "0.1" base64 = "0.22" ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } trust-auth = { path = "crates/trust-auth" } diff --git a/config/default.toml b/config/default.toml index bb3900f..ce5c4fc 100644 --- a/config/default.toml +++ b/config/default.toml @@ -48,6 +48,8 @@ token_freshness_secs = 300 min_device_verdict = "device" require_play_licensed = false min_sdk_version = 0 +circuit_failure_threshold = 5 +circuit_open_secs = 60 [quota] enabled = true diff --git a/src/config.rs b/src/config.rs index d65ab37..41952e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -245,6 +245,12 @@ pub struct AndroidAttestationConfig { pub require_play_licensed: bool, #[serde(default)] pub min_sdk_version: u32, + /// Consecutive transient Google failures before the circuit opens. + #[serde(default = "default_android_circuit_failure_threshold")] + pub circuit_failure_threshold: u32, + /// How long the circuit stays open after tripping. + #[serde(default = "default_android_circuit_open_secs")] + pub circuit_open_secs: u32, #[serde(default)] pub apps: Vec, } @@ -259,6 +265,8 @@ impl Default for AndroidAttestationConfig { min_device_verdict: default_min_device_verdict(), require_play_licensed: false, min_sdk_version: 0, + circuit_failure_threshold: default_android_circuit_failure_threshold(), + circuit_open_secs: default_android_circuit_open_secs(), apps: Vec::new(), } } @@ -299,6 +307,14 @@ fn default_min_device_verdict() -> AttestationLevel { AttestationLevel::Device } +fn default_android_circuit_failure_threshold() -> u32 { + 5 +} + +fn default_android_circuit_open_secs() -> u32 { + 60 +} + impl Config { /// Load config: `config/default.toml` -> `config/{APP_ENV}.toml` -> /// `APP_`-prefixed env vars (nested via `__`). diff --git a/src/routes/attest.rs b/src/routes/attest.rs index 29f411a..c3fbc40 100644 --- a/src/routes/attest.rs +++ b/src/routes/attest.rs @@ -34,13 +34,27 @@ pub struct IosAttestationRequest { pub challenge: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidAttestationRequest { + pub package_name: String, + pub integrity_token: String, + pub challenge: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AttestationRefreshRequest { pub platform: Platform, - pub key_id: String, - pub assertion: String, pub challenge: String, + #[serde(default)] + pub key_id: Option, + #[serde(default)] + pub assertion: Option, + #[serde(default)] + pub package_name: Option, + #[serde(default)] + pub integrity_token: Option, } #[derive(Debug, Serialize)] @@ -104,26 +118,68 @@ pub async fn post_ios_attestation( attestation_response(&state, &claims, record) } -/// Re-prove iOS key possession using a counter-protected App Attest assertion. -pub async fn post_attestation_refresh( +/// Verify a Play Integrity token and upgrade the caller's token. +pub async fn post_android_attestation( State(state): State, headers: HeaderMap, - Json(body): Json, + Json(body): Json, ) -> Result, AppError> { let claims = verified_claims(&state, &headers)?; state.revocation.ensure_wallet_active(&claims.sub).await?; - if body.platform != Platform::Ios { - return Err(AppError::InvalidRequest( - "platform refresh is not implemented".into(), - )); - } let record = state .attestation - .verify_ios_assertion(&claims.sub, &body.challenge, &body.key_id, &body.assertion) + .verify_android_attestation( + &claims.sub, + &body.challenge, + &body.package_name, + &body.integrity_token, + ) .await?; attestation_response(&state, &claims, record) } +/// Re-prove attestation: iOS `generateAssertion` or a fresh Android integrity token. +pub async fn post_attestation_refresh( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, AppError> { + let claims = verified_claims(&state, &headers)?; + state.revocation.ensure_wallet_active(&claims.sub).await?; + let record = match body.platform { + Platform::Ios => { + let key_id = body.key_id.as_deref().ok_or_else(|| { + AppError::InvalidRequest("keyId is required for iOS refresh".into()) + })?; + let assertion = body.assertion.as_deref().ok_or_else(|| { + AppError::InvalidRequest("assertion is required for iOS refresh".into()) + })?; + state + .attestation + .verify_ios_assertion(&claims.sub, &body.challenge, key_id, assertion) + .await? + } + Platform::Android => { + let package_name = body.package_name.as_deref().ok_or_else(|| { + AppError::InvalidRequest("packageName is required for Android refresh".into()) + })?; + let integrity_token = body.integrity_token.as_deref().ok_or_else(|| { + AppError::InvalidRequest("integrityToken is required for Android refresh".into()) + })?; + state + .attestation + .verify_android_attestation( + &claims.sub, + &body.challenge, + package_name, + integrity_token, + ) + .await? + } + }; + attestation_response(&state, &claims, record) +} + fn attestation_response( state: &AppState, claims: &crate::models::AccessTokenClaims, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 67d0721..9fd9186 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -31,6 +31,7 @@ fn wallet_auth_routes(state: AppState) -> Router { .route("/v1/auth/quota", get(auth::get_quota)) .route("/v1/attest/challenge", post(attest::post_challenge)) .route("/v1/attest/ios", post(attest::post_ios_attestation)) + .route("/v1/attest/android", post(attest::post_android_attestation)) .route("/v1/attest/refresh", post(attest::post_attestation_refresh)) .with_state(state) .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES)) diff --git a/src/services/attestation.rs b/src/services/attestation.rs index 1707178..8fddf57 100644 --- a/src/services/attestation.rs +++ b/src/services/attestation.rs @@ -1,6 +1,8 @@ //! Platform-attestation challenge issuance and wallet-state lookup. +pub mod android; pub mod ios; +pub mod play_integrity; use std::sync::Arc; @@ -16,12 +18,20 @@ use crate::{ store::attestation::{AttestationChallenge, AttestationRecord, AttestationStore}, }; -use self::ios::IosAttestationVerifier; +use self::{ + android::{evaluate_verdict, precheck_integrity_token}, + ios::IosAttestationVerifier, + play_integrity::{ + CircuitBreakingPlayIntegrityClient, HttpPlayIntegrityClient, PlayIntegrityClient, + PlayIntegrityError, + }, +}; pub struct AttestationService { store: Arc, config: AttestationConfig, ios_verifier: IosAttestationVerifier, + play_integrity: Arc, } #[derive(Debug, Clone)] @@ -34,6 +44,20 @@ impl AttestationService { pub fn new( store: Arc, config: AttestationConfig, + ) -> Result { + let http = Arc::new(HttpPlayIntegrityClient::new()); + let play_integrity = Arc::new(CircuitBreakingPlayIntegrityClient::new( + http, + config.android.circuit_failure_threshold, + config.android.circuit_open_secs, + )); + Self::with_play_integrity(store, config, play_integrity) + } + + pub fn with_play_integrity( + store: Arc, + config: AttestationConfig, + play_integrity: Arc, ) -> Result { let ios_verifier = IosAttestationVerifier::new(&config.ios.apple_root_ca_pem) .map_err(|error| AppError::Internal(error.to_string()))?; @@ -41,6 +65,7 @@ impl AttestationService { store, config, ios_verifier, + play_integrity, }) } @@ -66,7 +91,7 @@ impl AttestationService { }; self.store .put_challenge( - &challenge_hash(&challenge), + &challenge_hash(challenge.as_str()), &record, u64::from(self.config.challenge_ttl_secs), ) @@ -174,6 +199,7 @@ impl AttestationService { ios_counter: Some(0), evidence_sha256, challenge_sha256: Some(request_challenge_sha256), + grace_extended: false, }; self.put_verified_record(&wallet, &record).await?; Ok(record) @@ -238,6 +264,7 @@ impl AttestationService { record.ios_counter = Some(counter); record.evidence_sha256 = hex::encode(Sha256::digest(&assertion_bytes)); record.challenge_sha256 = Some(challenge_hash(challenge)); + record.grace_extended = false; let ttl_secs = record.grace_expires_at.saturating_sub(now).max(1) as u64; let updated = self .store @@ -251,6 +278,118 @@ impl AttestationService { Ok(record) } + pub async fn verify_android_attestation( + &self, + wallet: &str, + challenge: &str, + package_name: &str, + integrity_token: &str, + ) -> Result { + let wallet = normalize_wallet(wallet)?; + let package_name = package_name.trim(); + precheck_integrity_token(integrity_token) + .map_err(|error| AppError::InvalidRequest(error.to_string()))?; + let evidence_sha256 = hex::encode(Sha256::digest(integrity_token.as_bytes())); + let request_challenge_sha256 = challenge_hash(challenge); + let now_ts = OffsetDateTime::now_utc().unix_timestamp(); + + if let Some(existing) = self.store.get_wallet_record(&wallet).await? { + if existing.platform == Platform::Android + && existing.app == package_name + && existing.evidence_sha256 == evidence_sha256 + && existing.challenge_sha256.as_deref() == Some(request_challenge_sha256.as_str()) + && now_ts.saturating_sub(existing.attested_at) + <= i64::from(self.config.challenge_ttl_secs) + && existing.grace_expires_at > now_ts + { + return Ok(existing); + } + } + + let challenge_record = self + .consume_challenge(challenge, &wallet, Platform::Android) + .await?; + if challenge_record.app != package_name { + return Err(AppError::InvalidRequest( + "packageName does not match the attestation challenge".into(), + )); + } + + let verdict = match self + .play_integrity + .decode_integrity_token(package_name, integrity_token) + .await + { + Ok(verdict) => verdict, + Err(error) => return Err(self.handle_android_decode_failure(&wallet, error).await), + }; + + let verified = evaluate_verdict( + &verdict, + &self.config.android, + package_name, + challenge, + &wallet, + OffsetDateTime::now_utc(), + ) + .map_err(|error| AppError::InvalidRequest(error.to_string()))?; + + let expires_at = now_ts + i64::from(self.config.android.attestation_ttl_secs); + let record = AttestationRecord { + platform: Platform::Android, + app: verified.package_name, + level: verified.level, + attested_at: now_ts, + expires_at, + grace_expires_at: expires_at + i64::from(self.config.android.grace_secs), + ios_key_id: None, + ios_public_key: None, + ios_counter: None, + evidence_sha256, + challenge_sha256: Some(request_challenge_sha256), + grace_extended: false, + }; + self.put_verified_record(&wallet, &record).await?; + Ok(record) + } + + async fn handle_android_decode_failure( + &self, + wallet: &str, + error: PlayIntegrityError, + ) -> AppError { + if !error.is_transient() { + return AppError::InvalidRequest(error.to_string()); + } + + if let Ok(Some(mut record)) = self.store.get_wallet_record(wallet).await { + if record.platform == Platform::Android && !record.grace_extended { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let extension = i64::from(self.config.android.grace_secs); + record.grace_expires_at = record.grace_expires_at.max(now) + extension; + record.grace_extended = true; + let ttl_secs = record.grace_expires_at.saturating_sub(now).max(1) as u64; + if let Err(store_error) = self + .store + .put_wallet_record(wallet, &record, ttl_secs) + .await + { + return store_error.into(); + } + tracing::warn!( + wallet, + grace_expires_at = record.grace_expires_at, + "extended Android attestation grace after Play Integrity failure" + ); + } + } + + match error { + PlayIntegrityError::QuotaExhausted => AppError::RateLimited(error.to_string()), + _ => AppError::Internal(error.to_string()), + } + } + /// Return claim metadata while the record is within its configured grace window. pub async fn live_claim(&self, wallet: &str) -> Result, AppError> { if !self.config.enabled { diff --git a/src/services/attestation/android.rs b/src/services/attestation/android.rs new file mode 100644 index 0000000..8d5a632 --- /dev/null +++ b/src/services/attestation/android.rs @@ -0,0 +1,312 @@ +//! Play Integrity verdict policy for Android attestation. + +use base64::Engine; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use time::OffsetDateTime; + +use crate::{ + config::{AndroidAppConfig, AndroidAttestationConfig}, + models::AttestationLevel, +}; + +/// Decoded Play Integrity token payload (`tokenPayloadExternal`). +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PlayIntegrityVerdict { + pub request_details: RequestDetails, + pub app_integrity: AppIntegrity, + pub device_integrity: DeviceIntegrity, + #[serde(default)] + pub account_details: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RequestDetails { + pub request_package_name: String, + pub request_hash: String, + /// Google returns this as a decimal string. + pub timestamp_millis: String, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppIntegrity { + pub app_recognition_verdict: String, + pub package_name: String, + #[serde(default)] + pub certificate_sha256_digest: Vec, + #[serde(default)] + pub version_code: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeviceIntegrity { + #[serde(default)] + pub device_recognition_verdict: Vec, + #[serde(default)] + pub device_attributes: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeviceAttributes { + #[serde(default)] + pub sdk_version: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AccountDetails { + #[serde(default)] + pub app_licensing_verdict: Option, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum AndroidPolicyError { + #[error("integrity token is not a compact JWE")] + MalformedIntegrityToken, + #[error("app recognition verdict is not PLAY_RECOGNIZED")] + AppNotRecognized, + #[error("device integrity is below the configured floor")] + DeviceIntegrityTooLow, + #[error("request hash does not match challenge and wallet")] + RequestHashMismatch, + #[error("package name does not match the challenge binding")] + PackageMismatch, + #[error("signing certificate digest is not trusted for this package")] + CertificateMismatch, + #[error("integrity token is outside the freshness window")] + TokenStale, + #[error("device SDK version is below the configured floor")] + SdkTooLow, + #[error("Play licensing verdict is not LICENSED")] + NotLicensed, + #[error("package is not configured for Android attestation")] + UnconfiguredApp, + #[error("invalid token timestamp")] + InvalidTimestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedAndroidAttestation { + pub package_name: String, + pub level: AttestationLevel, + pub timestamp_millis: i64, +} + +/// `requestHash = base64url(SHA256(challenge ‖ wallet))` with no padding. +pub fn request_hash(challenge: &str, wallet: &str) -> String { + let digest = Sha256::digest([challenge.as_bytes(), wallet.as_bytes()].concat()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) +} + +/// Reject obviously malformed tokens before spending Google decode quota. +pub fn precheck_integrity_token(token: &str) -> Result<(), AndroidPolicyError> { + let parts: Vec<&str> = token.trim().split('.').collect(); + if parts.len() != 5 || parts.iter().any(|part| part.is_empty()) { + return Err(AndroidPolicyError::MalformedIntegrityToken); + } + Ok(()) +} + +pub fn evaluate_verdict( + verdict: &PlayIntegrityVerdict, + config: &AndroidAttestationConfig, + package_name: &str, + challenge: &str, + wallet: &str, + now: OffsetDateTime, +) -> Result { + let app = config + .apps + .iter() + .find(|app| app.package_name == package_name) + .ok_or(AndroidPolicyError::UnconfiguredApp)?; + + if verdict.app_integrity.app_recognition_verdict != "PLAY_RECOGNIZED" { + return Err(AndroidPolicyError::AppNotRecognized); + } + if verdict.app_integrity.package_name != package_name + || verdict.request_details.request_package_name != package_name + { + return Err(AndroidPolicyError::PackageMismatch); + } + if !certificate_trusted(app, &verdict.app_integrity.certificate_sha256_digest) { + return Err(AndroidPolicyError::CertificateMismatch); + } + + let expected_hash = request_hash(challenge, wallet); + if verdict.request_details.request_hash != expected_hash { + return Err(AndroidPolicyError::RequestHashMismatch); + } + + let timestamp_millis: i64 = verdict + .request_details + .timestamp_millis + .parse() + .map_err(|_| AndroidPolicyError::InvalidTimestamp)?; + let age_ms = now.unix_timestamp() * 1_000 - timestamp_millis; + if age_ms < 0 || age_ms > i64::from(config.token_freshness_secs) * 1_000 { + return Err(AndroidPolicyError::TokenStale); + } + + let level = device_level(&verdict.device_integrity.device_recognition_verdict) + .ok_or(AndroidPolicyError::DeviceIntegrityTooLow)?; + if level_rank(level) < level_rank(config.min_device_verdict) { + return Err(AndroidPolicyError::DeviceIntegrityTooLow); + } + + if config.min_sdk_version > 0 { + let sdk = verdict + .device_integrity + .device_attributes + .as_ref() + .and_then(|attrs| attrs.sdk_version) + .unwrap_or(0); + if sdk < config.min_sdk_version { + return Err(AndroidPolicyError::SdkTooLow); + } + } + + if config.require_play_licensed { + let licensed = verdict + .account_details + .as_ref() + .and_then(|account| account.app_licensing_verdict.as_deref()) + == Some("LICENSED"); + if !licensed { + return Err(AndroidPolicyError::NotLicensed); + } + } else if let Some(verdict) = verdict + .account_details + .as_ref() + .and_then(|account| account.app_licensing_verdict.as_deref()) + { + if verdict != "LICENSED" { + tracing::info!( + package = package_name, + licensing = verdict, + "Play licensing verdict is not LICENSED (log-only in iteration 1)" + ); + } + } + + Ok(VerifiedAndroidAttestation { + package_name: package_name.to_string(), + level, + timestamp_millis, + }) +} + +fn certificate_trusted(app: &AndroidAppConfig, digests: &[String]) -> bool { + app.certificate_sha256_digests + .iter() + .any(|trusted| digests.iter().any(|digest| digest == trusted)) +} + +fn device_level(verdicts: &[String]) -> Option { + if verdicts.iter().any(|v| v == "MEETS_STRONG_INTEGRITY") { + Some(AttestationLevel::Strong) + } else if verdicts.iter().any(|v| v == "MEETS_DEVICE_INTEGRITY") { + Some(AttestationLevel::Device) + } else if verdicts.iter().any(|v| v == "MEETS_BASIC_INTEGRITY") { + Some(AttestationLevel::Basic) + } else { + None + } +} + +fn level_rank(level: AttestationLevel) -> u8 { + match level { + AttestationLevel::Basic => 1, + AttestationLevel::Device => 2, + AttestationLevel::Strong => 3, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AndroidAppConfig; + + fn fixture_verdict() -> PlayIntegrityVerdict { + serde_json::from_str(include_str!( + "../../../tests/fixtures/android_play_integrity/decoded_verdict.json" + )) + .unwrap() + } + + fn config() -> AndroidAttestationConfig { + AndroidAttestationConfig { + apps: vec![AndroidAppConfig { + name: "beacon".into(), + package_name: "com.nodle.beacon_relay".into(), + certificate_sha256_digests: vec![ + "VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k".into() + ], + earns_rewards: true, + attestation_optional: false, + }], + token_freshness_secs: 300, + min_device_verdict: AttestationLevel::Device, + require_play_licensed: false, + min_sdk_version: 0, + ..AndroidAttestationConfig::default() + } + } + + #[test] + fn fixture_passes_iteration1_policy() { + let verdict = fixture_verdict(); + let meta: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/android_play_integrity/metadata.json" + )) + .unwrap(); + let challenge = meta["request"]["challenge_base64url"].as_str().unwrap(); + let wallet = meta["wallet_address"].as_str().unwrap(); + let now = OffsetDateTime::from_unix_timestamp(1_713_441_600 + 30).unwrap(); + let verified = evaluate_verdict( + &verdict, + &config(), + "com.nodle.beacon_relay", + challenge, + wallet, + now, + ) + .unwrap(); + assert_eq!(verified.level, AttestationLevel::Strong); + assert_eq!(verified.package_name, "com.nodle.beacon_relay"); + } + + #[test] + fn rejects_stale_token() { + let verdict = fixture_verdict(); + let meta: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/android_play_integrity/metadata.json" + )) + .unwrap(); + let challenge = meta["request"]["challenge_base64url"].as_str().unwrap(); + let wallet = meta["wallet_address"].as_str().unwrap(); + let now = OffsetDateTime::from_unix_timestamp(1_713_441_600 + 301).unwrap(); + let err = evaluate_verdict( + &verdict, + &config(), + "com.nodle.beacon_relay", + challenge, + wallet, + now, + ) + .unwrap_err(); + assert_eq!(err, AndroidPolicyError::TokenStale); + } + + #[test] + fn precheck_requires_five_jwe_parts() { + assert!(precheck_integrity_token("a.b.c.d.e").is_ok()); + assert!(precheck_integrity_token("a.b.c.d").is_err()); + assert!(precheck_integrity_token("a..c.d.e").is_err()); + } +} diff --git a/src/services/attestation/play_integrity.rs b/src/services/attestation/play_integrity.rs new file mode 100644 index 0000000..f13ea42 --- /dev/null +++ b/src/services/attestation/play_integrity.rs @@ -0,0 +1,252 @@ +//! Google Play Integrity `decodeIntegrityToken` client. + +use std::sync::atomic::{AtomicI64, AtomicU32, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +use super::android::PlayIntegrityVerdict; + +const PLAY_INTEGRITY_SCOPE: &str = "https://www.googleapis.com/auth/playintegrity"; + +#[derive(Debug, Error, Clone)] +pub enum PlayIntegrityError { + #[error("Play Integrity decode failed: {0}")] + Upstream(String), + #[error("Play Integrity quota exhausted")] + QuotaExhausted, + #[error("Play Integrity temporarily unavailable")] + Unavailable, + #[error("Play Integrity authentication failed: {0}")] + Auth(String), + #[error("Play Integrity response was malformed")] + MalformedResponse, +} + +impl PlayIntegrityError { + pub fn is_transient(&self) -> bool { + matches!( + self, + Self::Upstream(_) | Self::QuotaExhausted | Self::Unavailable | Self::Auth(_) + ) + } +} + +#[async_trait] +pub trait PlayIntegrityClient: Send + Sync { + async fn decode_integrity_token( + &self, + package_name: &str, + integrity_token: &str, + ) -> Result; +} + +/// HTTP client that calls Google's decode endpoint with ADC / SA credentials. +pub struct HttpPlayIntegrityClient { + http: reqwest::Client, + provider: tokio::sync::Mutex>>, +} + +impl HttpPlayIntegrityClient { + pub fn new() -> Self { + Self { + http: reqwest::Client::new(), + provider: tokio::sync::Mutex::new(None), + } + } + + async fn access_token(&self) -> Result { + let mut guard = self.provider.lock().await; + if guard.is_none() { + let provider = gcp_auth::provider() + .await + .map_err(|error| PlayIntegrityError::Auth(error.to_string()))?; + *guard = Some(provider); + } + let provider = guard.as_ref().expect("provider initialized"); + let token = provider + .token(&[PLAY_INTEGRITY_SCOPE]) + .await + .map_err(|error| PlayIntegrityError::Auth(error.to_string()))?; + Ok(token.as_str().to_string()) + } +} + +impl Default for HttpPlayIntegrityClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PlayIntegrityClient for HttpPlayIntegrityClient { + async fn decode_integrity_token( + &self, + package_name: &str, + integrity_token: &str, + ) -> Result { + // Android package names are reverse-DNS (`[A-Za-z0-9._]+`) and safe in the path. + if !package_name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_') + { + return Err(PlayIntegrityError::Upstream( + "package name contains invalid characters".into(), + )); + } + + let access_token = self.access_token().await?; + let url = + format!("https://playintegrity.googleapis.com/v1/{package_name}:decodeIntegrityToken"); + let response = self + .http + .post(url) + .bearer_auth(access_token) + .json(&DecodeRequest { + integrity_token: integrity_token.to_string(), + }) + .send() + .await + .map_err(|error| { + tracing::warn!(error = %error, "Play Integrity HTTP request failed"); + PlayIntegrityError::Unavailable + })?; + + let status = response.status(); + if status.as_u16() == 429 { + return Err(PlayIntegrityError::QuotaExhausted); + } + if status.is_server_error() { + return Err(PlayIntegrityError::Unavailable); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(PlayIntegrityError::Upstream(format!( + "status {status}: {body}" + ))); + } + + let decoded: DecodeResponse = response + .json() + .await + .map_err(|_| PlayIntegrityError::MalformedResponse)?; + decoded + .token_payload_external + .ok_or(PlayIntegrityError::MalformedResponse) + } +} + +#[derive(Serialize)] +struct DecodeRequest { + #[serde(rename = "integrity_token")] + integrity_token: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecodeResponse { + token_payload_external: Option, +} + +/// Wraps a decode client with a simple consecutive-failure circuit breaker. +pub struct CircuitBreakingPlayIntegrityClient { + inner: Arc, + failure_threshold: u32, + open_secs: u32, + consecutive_failures: AtomicU32, + open_until: AtomicI64, +} + +impl CircuitBreakingPlayIntegrityClient { + pub fn new( + inner: Arc, + failure_threshold: u32, + open_secs: u32, + ) -> Self { + Self { + inner, + failure_threshold: failure_threshold.max(1), + open_secs, + consecutive_failures: AtomicU32::new(0), + open_until: AtomicI64::new(0), + } + } + + fn is_open(&self) -> bool { + let until = self.open_until.load(Ordering::Relaxed); + until > OffsetDateTime::now_utc().unix_timestamp() + } +} + +#[async_trait] +impl PlayIntegrityClient for CircuitBreakingPlayIntegrityClient { + async fn decode_integrity_token( + &self, + package_name: &str, + integrity_token: &str, + ) -> Result { + if self.is_open() { + return Err(PlayIntegrityError::Unavailable); + } + + match self + .inner + .decode_integrity_token(package_name, integrity_token) + .await + { + Ok(verdict) => { + self.consecutive_failures.store(0, Ordering::Relaxed); + Ok(verdict) + } + Err(error) if error.is_transient() => { + let failures = self.consecutive_failures.fetch_add(1, Ordering::Relaxed) + 1; + if failures >= self.failure_threshold { + let open_until = + OffsetDateTime::now_utc().unix_timestamp() + i64::from(self.open_secs); + self.open_until.store(open_until, Ordering::Relaxed); + tracing::warn!( + failures, + open_secs = self.open_secs, + "Play Integrity circuit opened after consecutive failures" + ); + } + Err(error) + } + Err(error) => Err(error), + } + } +} + +/// Test double that returns a fixed verdict (or error). +#[derive(Clone)] +pub struct MockPlayIntegrityClient { + pub result: Arc>>, +} + +impl MockPlayIntegrityClient { + pub fn with_verdict(verdict: PlayIntegrityVerdict) -> Self { + Self { + result: Arc::new(tokio::sync::Mutex::new(Ok(verdict))), + } + } + + pub fn with_error(error: PlayIntegrityError) -> Self { + Self { + result: Arc::new(tokio::sync::Mutex::new(Err(error))), + } + } +} + +#[async_trait] +impl PlayIntegrityClient for MockPlayIntegrityClient { + async fn decode_integrity_token( + &self, + _package_name: &str, + _integrity_token: &str, + ) -> Result { + self.result.lock().await.clone() + } +} diff --git a/src/store/attestation.rs b/src/store/attestation.rs index de7278e..e403415 100644 --- a/src/store/attestation.rs +++ b/src/store/attestation.rs @@ -41,6 +41,9 @@ pub struct AttestationRecord { pub evidence_sha256: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub challenge_sha256: Option, + /// Set after a one-shot grace extension when Google Play Integrity is down. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub grace_extended: bool, } impl AttestationRecord { diff --git a/src/store/attestation/memory.rs b/src/store/attestation/memory.rs index 2e4bc14..d55ef0d 100644 --- a/src/store/attestation/memory.rs +++ b/src/store/attestation/memory.rs @@ -148,6 +148,7 @@ mod tests { ios_counter: Some(0), evidence_sha256: "evidence".into(), challenge_sha256: None, + grace_extended: false, }; store .put_wallet_record("wallet", &record, 60) diff --git a/tests/attestation.rs b/tests/attestation.rs index f6bc7ca..5e7152c 100644 --- a/tests/attestation.rs +++ b/tests/attestation.rs @@ -144,6 +144,7 @@ async fn session_and_refresh_mints_include_live_attestation_record() { ios_counter: None, evidence_sha256: "evidence".into(), challenge_sha256: None, + grace_extended: false, }, ) .await @@ -243,6 +244,7 @@ async fn ios_assertion_refresh_updates_record_and_mints_token() { ios_counter: Some(0), evidence_sha256: "initial".into(), challenge_sha256: None, + grace_extended: false, }, ) .await @@ -320,3 +322,216 @@ async fn ios_assertion_refresh_updates_record_and_mints_token() { assert_eq!(claims.tier, Some(TrustTier::Attested)); assert_eq!(claims.att.unwrap().platform, Platform::Ios); } + +#[tokio::test] +async fn android_attestation_upgrades_token_via_mocked_decode() { + use std::sync::Arc; + use trust_relay::services::attestation::{ + android::{request_hash, PlayIntegrityVerdict}, + play_integrity::MockPlayIntegrityClient, + AttestationService, + }; + use trust_relay::store::attestation::InMemoryAttestationStore; + + const PACKAGE: &str = "com.nodle.beacon_relay"; + let mut config = attestation_config(); + config.attestation.android.apps = vec![AndroidAppConfig { + name: "beacon".into(), + package_name: PACKAGE.into(), + certificate_sha256_digests: vec!["VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k".into()], + earns_rewards: true, + attestation_optional: false, + }]; + let signing = config.signing.clone(); + let store = Arc::new(InMemoryAttestationStore::default()); + let mock = Arc::new(MockPlayIntegrityClient::with_verdict( + serde_json::from_str(include_str!( + "fixtures/android_play_integrity/decoded_verdict.json" + )) + .unwrap(), + )); + // Rebuild state with injectable Play Integrity client. + let mut state = AppState::build(config).await.unwrap(); + let attestation = AttestationService::with_play_integrity( + store, + state.config.attestation.clone(), + mock.clone(), + ) + .unwrap(); + state.attestation = Arc::new(attestation); + + let token = state + .token + .mint_wallet_token(TEST_WALLET, &["scan:submit"], 324) + .unwrap(); + let app = create_router(state); + + let challenge_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/challenge") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({"platform": "android", "app": PACKAGE}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(challenge_response.status(), StatusCode::OK); + let challenge: ChallengeBody = + serde_json::from_value(response_json(challenge_response).await).unwrap(); + + let mut verdict: PlayIntegrityVerdict = serde_json::from_str(include_str!( + "fixtures/android_play_integrity/decoded_verdict.json" + )) + .unwrap(); + verdict.request_details.request_hash = request_hash(&challenge.challenge, TEST_WALLET); + verdict.request_details.timestamp_millis = + (OffsetDateTime::now_utc().unix_timestamp() * 1000).to_string(); + *mock.result.lock().await = Ok(verdict); + + let integrity_token = "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIn0.aaaa.bbbb.cccc.dddd"; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/android") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "packageName": PACKAGE, + "integrityToken": integrity_token, + "challenge": challenge.challenge, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["attestation"]["platform"], "android"); + assert_eq!(body["attestation"]["app"], PACKAGE); + assert_eq!(body["attestation"]["level"], "strong"); + let claims = TokenService::new(signing) + .unwrap() + .verify(body["accessToken"].as_str().unwrap()) + .unwrap(); + assert_eq!(claims.tier, Some(TrustTier::Attested)); + assert_eq!(claims.att.unwrap().platform, Platform::Android); +} + +#[tokio::test] +async fn android_google_outage_extends_grace_once() { + use std::sync::Arc; + use trust_relay::services::attestation::{ + play_integrity::{MockPlayIntegrityClient, PlayIntegrityError}, + AttestationService, + }; + use trust_relay::store::attestation::{AttestationStore, InMemoryAttestationStore}; + + const PACKAGE: &str = "com.nodle.beacon_relay"; + let mut config = attestation_config(); + config.attestation.android.apps = vec![AndroidAppConfig { + name: "beacon".into(), + package_name: PACKAGE.into(), + certificate_sha256_digests: vec!["VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k".into()], + earns_rewards: true, + attestation_optional: false, + }]; + let store = Arc::new(InMemoryAttestationStore::default()); + let mock = Arc::new(MockPlayIntegrityClient::with_error( + PlayIntegrityError::Unavailable, + )); + let mut state = AppState::build(config).await.unwrap(); + let attestation = AttestationService::with_play_integrity( + store.clone(), + state.config.attestation.clone(), + mock, + ) + .unwrap(); + state.attestation = Arc::new(attestation); + + let now = OffsetDateTime::now_utc().unix_timestamp(); + state + .attestation + .put_verified_record( + TEST_WALLET, + &AttestationRecord { + platform: Platform::Android, + app: PACKAGE.into(), + level: AttestationLevel::Device, + attested_at: now - 100, + expires_at: now + 60, + grace_expires_at: now + 120, + ios_key_id: None, + ios_public_key: None, + ios_counter: None, + evidence_sha256: "prior".into(), + challenge_sha256: None, + grace_extended: false, + }, + ) + .await + .unwrap(); + + let token = state + .token + .mint_wallet_token(TEST_WALLET, &["scan:submit"], 324) + .unwrap(); + let app = create_router(state.clone()); + let challenge_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/challenge") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({"platform": "android", "app": PACKAGE}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let challenge: ChallengeBody = + serde_json::from_value(response_json(challenge_response).await).unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/android") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "packageName": PACKAGE, + "integrityToken": "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIn0.a.b.c.d", + "challenge": challenge.challenge, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let record = state + .attestation + .live_claim(TEST_WALLET) + .await + .unwrap() + .expect("grace-extended record should still be live"); + assert_eq!(record.app, PACKAGE); + let stored = store.get_wallet_record(TEST_WALLET).await.unwrap().unwrap(); + assert!(stored.grace_extended); + assert!(stored.grace_expires_at > now + 120); +} diff --git a/tests/attestation_redis.rs b/tests/attestation_redis.rs index e066ef9..ab9197a 100644 --- a/tests/attestation_redis.rs +++ b/tests/attestation_redis.rs @@ -53,6 +53,7 @@ async fn redis_attestation_store_roundtrip_and_single_use_challenge() { ios_counter: None, evidence_sha256: id.clone(), challenge_sha256: None, + grace_extended: false, }; store.put_wallet_record(&wallet, &record, 60).await.unwrap(); assert_eq!( @@ -78,6 +79,7 @@ async fn redis_attestation_store_roundtrip_and_single_use_challenge() { ios_counter: Some(0), evidence_sha256: "initial".into(), challenge_sha256: None, + grace_extended: false, }; store .put_wallet_record(&wallet, &ios_record, 60) diff --git a/tests/fixtures/android_play_integrity/README.md b/tests/fixtures/android_play_integrity/README.md new file mode 100644 index 0000000..090285e --- /dev/null +++ b/tests/fixtures/android_play_integrity/README.md @@ -0,0 +1,27 @@ +# Android Play Integrity fixture + +Synthetic Play Integrity verdict matching Google's +[`decodeIntegrityToken`](https://developer.android.com/google/play/integrity/verdicts) +schema. The encrypted JWE in `metadata.json` is not decryptable locally — tests +feed `decoded_verdict.json` through a mocked decode client. + +## Iteration-1 binding + +trust-relay binds tokens with: + +```text +requestHash = base64url(SHA256(challenge_utf8 ‖ wallet_address_utf8_lowercase)) +``` + +This differs from the older beacon-relay fixture formula +(`challenge ‖ pubkey ‖ publisher`). Values in `metadata.json` / `decoded_verdict.json` +were regenerated for that shape. + +## App parameters + +| Field | Value | +| --- | --- | +| `packageName` | `com.nodle.beacon_relay` | +| `certificateSha256Digest` | `VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k` | +| Wallet | `0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266` | +| Token timestamp | `1713441600000` (verify with injected `now`) | diff --git a/tests/fixtures/android_play_integrity/decoded_verdict.json b/tests/fixtures/android_play_integrity/decoded_verdict.json new file mode 100644 index 0000000..480a42d --- /dev/null +++ b/tests/fixtures/android_play_integrity/decoded_verdict.json @@ -0,0 +1,29 @@ +{ + "_comment": "Decoded Play Integrity verdict payload. This is the JSON returned by Google's decodeIntegrityToken API (server-to-server). Source format: https://developer.android.com/google/play/integrity/verdicts. This fixture is synthetic but structurally identical to real Google responses.", + "requestDetails": { + "requestPackageName": "com.nodle.beacon_relay", + "requestHash": "TyLqxE6CPATfL6QYnRU_Un2jEXArKE_N9n7VSkBLlV0", + "timestampMillis": "1713441600000" + }, + "appIntegrity": { + "appRecognitionVerdict": "PLAY_RECOGNIZED", + "packageName": "com.nodle.beacon_relay", + "certificateSha256Digest": [ + "VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k" + ], + "versionCode": "15" + }, + "deviceIntegrity": { + "deviceRecognitionVerdict": [ + "MEETS_STRONG_INTEGRITY", + "MEETS_DEVICE_INTEGRITY", + "MEETS_BASIC_INTEGRITY" + ], + "deviceAttributes": { + "sdkVersion": 34 + } + }, + "accountDetails": { + "appLicensingVerdict": "LICENSED" + } +} diff --git a/tests/fixtures/android_play_integrity/metadata.json b/tests/fixtures/android_play_integrity/metadata.json new file mode 100644 index 0000000..2bb2d52 --- /dev/null +++ b/tests/fixtures/android_play_integrity/metadata.json @@ -0,0 +1,35 @@ +{ + "_comment": "Android Play Integrity fixture for trust-relay iteration 1. requestHash = SHA256(challenge || wallet_address). Synthetic verdict matching Google decodeIntegrityToken schema.", + "app": { + "package_name": "com.nodle.beacon_relay", + "version_code": "15", + "certificate_sha256_digest_base64url": "VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k" + }, + "request": { + "challenge_base64url": "obLD1OX2p7jJ0OHyo7TF1uf4qbDB0uP0pbbH2OnwobI", + "wallet_address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "request_hash_base64url": "TyLqxE6CPATfL6QYnRU_Un2jEXArKE_N9n7VSkBLlV0", + "request_hash_hex": "4f22eac44e823c04df2fa4189d153f527da311702b284fcdf67ed54a404b955d", + "request_hash_formula": "SHA256(challenge_utf8 || wallet_address_utf8_lowercase)" + }, + "integrity_token": { + "_comment": "Synthetic JWE compact serialization (5 dot-separated parts: header.encryptedKey.iv.ciphertext.tag). In production, the device SDK obtains this from the Play Integrity API. The gateway sends it to Google's decodeIntegrityToken endpoint. This fixture token is NOT decryptable \u2014 use decoded_verdict.json for testing.", + "token": "eyJhbGciOiAiQTI1NktXIiwgImVuYyI6ICJBMjU2R0NNIn0.VACwYVz3ZFrbZ6eNpaudh4PD22HdKqpyubhArlYRGhc.qnOMfi8LaIrPibR8.RlgTY7jP2N9gaIh1V6nT8eqOO1HaIx4X1IndRblcgRkaRcAFLiV7A6EEQmKKVqcA-jFfp01Rz9XQ_YJzXRar-4AOg0kgWWGfuEJbhSdIp63XtMNHhGoANfLRKAFo57zzwfL0jCJa12Ms5WBbptT1qGOqABTeZEcOE1Y_SDG54Aj9ETDYpwXyadJFODuwmVggGct4GtzkJnf_tbJQ3BnqZqRJM1esKw-BdK2uXDeD9pJEq-ViEDD7hJ3aoqYfIeZY7u9JUvtvW0pL_W0-nv1CWYbbcNcCgAWhuMWrnLBOdwD4UXEAnEYlsPUiyYOyFsLsBUnowuqluZhrGhYrLrujRPR4mIOsOsQms71VgKWBv-NGBn3tzKA_8i4bprt0HbpEuKUl1YDH61U9ta5rcfaeDa684Te2lEipvEQe8FZcS_PCsjYAmx2X_KYEcOnVnJe4BSpnEiYVw3c82u_ru3oJirDp2z9NJPV7kUWE5sdqaRUUEkaxxJ7opGbpjSDrEZC2wp5KwUFAi6IePz_XKbsHgCt8dwaB7pGZZv57dknlpDhUQsXDGwLR_57xh2KfFThKbZmmagrku6hih38mh4XtSrFIgj97jOBzOW7Q6XDIpdYYLy8khyq6zoKfz-nI-L1uEhxEQBBoftoq67kXyTR-UEA9HSdLTkZdU3-UadXq-lo.TZ_ed8HLkWhatDPY35M1og" + }, + "expected_validation_values": { + "step_1_decode_token": "Call Google decodeIntegrityToken API server-to-server; response is decoded_verdict.json", + "step_2_app_recognition": "PLAY_RECOGNIZED", + "step_3_device_integrity_minimum": "MEETS_DEVICE_INTEGRITY", + "step_3_device_integrity_preferred": "MEETS_STRONG_INTEGRITY", + "step_4_request_hash_matches": true, + "step_4_request_hash_formula": "SHA256(challenge || wallet_address)", + "step_5_package_name": "com.nodle.beacon_relay", + "step_6_certificate_digest_base64url": "VJ2TINNcc3RbYpuFnT8jAoO6Bd3KCtFkoG10358Ob4k", + "step_7_freshness_max_seconds": 300, + "step_7_timestamp_millis": "1713441600000", + "step_8_sdk_version_minimum": 33, + "step_8_sdk_version_actual": 34, + "step_9_address_derivation": "keccak256(pubkey_x || pubkey_y) \u2192 last 20 bytes \u2192 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf" + }, + "wallet_address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" +}