From baa2ea28bc0f36039b70c9cf397f2f4310e4f272 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 17 Jul 2026 13:28:09 +1200 Subject: [PATCH 1/4] Add Apple App Attest verification dependencies and root CA. Bundle Apple's public App Attestation root and pull in the CBOR/x509 stack so the local iOS verifier can land next without secret material. Co-authored-by: Cursor --- .gitignore | 3 + Cargo.lock | 201 +++++++++++++++++++++++ Cargo.toml | 6 + config/apple_app_attestation_root_ca.pem | 14 ++ config/default.toml | 2 + src/config.rs | 4 + 6 files changed, 230 insertions(+) create mode 100644 config/apple_app_attestation_root_ca.pem diff --git a/.gitignore b/.gitignore index 0c1c2f6..12ff7c0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ secrets/ config/local.toml config/*.local.toml +# Public Apple App Attest root CA bundled for local verification +!config/apple_app_attestation_root_ca.pem + # OS / editor .DS_Store *.swp diff --git a/Cargo.lock b/Cargo.lock index 8a2f83c..09f61f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,45 @@ dependencies = [ "rustversion", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -228,6 +267,33 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cmake" version = "0.1.58" @@ -281,6 +347,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -344,6 +416,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -351,9 +429,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -449,6 +542,7 @@ dependencies = [ "ff", "generic-array", "group", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -686,6 +780,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1126,6 +1231,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.1" @@ -1137,6 +1248,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1180,12 +1301,33 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1242,6 +1384,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1298,6 +1449,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1601,6 +1761,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1684,6 +1853,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2253,6 +2432,7 @@ dependencies = [ "async-trait", "axum", "base64", + "ciborium", "dashmap", "ed25519-dalek", "figment", @@ -2260,10 +2440,12 @@ dependencies = [ "http-body-util", "jsonwebtoken", "k256", + "p256", "rand 0.8.6", "redis", "reqwest", "serde", + "serde_bytes", "serde_json", "sha2", "sha3", @@ -2278,6 +2460,7 @@ dependencies = [ "trust-auth", "url", "uuid", + "x509-parser", ] [[package]] @@ -2714,6 +2897,24 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 7470113..0011d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,12 @@ sha2 = "0.10" hex = "0.4" url = "2" +# Platform attestation — Apple App Attest CBOR, X.509 chain, and P-256 assertions +ciborium = "0.2.2" +serde_bytes = "0.11.19" +x509-parser = { version = "0.18.1", features = ["verify"] } +p256 = { version = "0.13", features = ["ecdsa"] } + [dev-dependencies] signinwithethereum = "0.8" http-body-util = "0.1" diff --git a/config/apple_app_attestation_root_ca.pem b/config/apple_app_attestation_root_ca.pem new file mode 100644 index 0000000..4cff227 --- /dev/null +++ b/config/apple_app_attestation_root_ca.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/config/default.toml b/config/default.toml index 5351f5c..bb3900f 100644 --- a/config/default.toml +++ b/config/default.toml @@ -37,6 +37,8 @@ challenge_ttl_secs = 600 attestation_ttl_secs = 604800 reproof_interval_secs = 86400 grace_secs = 86400 +# Empty uses the bundled Apple App Attestation Root CA. +apple_root_ca_pem = "" [attestation.android] attestation_ttl_secs = 604800 diff --git a/src/config.rs b/src/config.rs index 1b730f7..d65ab37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -189,6 +189,9 @@ pub struct IosAttestationConfig { pub reproof_interval_secs: u32, #[serde(default = "default_attestation_grace_secs")] pub grace_secs: u32, + /// Optional PEM override for the bundled Apple App Attestation Root CA. + #[serde(default)] + pub apple_root_ca_pem: String, #[serde(default)] pub apps: Vec, } @@ -199,6 +202,7 @@ impl Default for IosAttestationConfig { attestation_ttl_secs: default_attestation_ttl_secs(), reproof_interval_secs: default_ios_reproof_interval_secs(), grace_secs: default_attestation_grace_secs(), + apple_root_ca_pem: String::new(), apps: Vec::new(), } } From 9b029b0815ad7d286d318e2a0b9cf71c177e891a Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 17 Jul 2026 13:28:13 +1200 Subject: [PATCH 2/4] Add Apple App Attest verifier and assertion state updates. Verify attestation objects against Apple's chain and fixture values, then persist iOS key material with an atomic counter CAS for re-proof. Co-authored-by: Cursor --- src/bootstrap.rs | 7 +- src/services/attestation.rs | 220 +++++++- src/services/attestation/ios.rs | 489 ++++++++++++++++++ src/state.rs | 2 +- src/store/attestation.rs | 12 + src/store/attestation/memory.rs | 56 +- src/store/attestation/redis.rs | 37 ++ tests/fixtures/apple_app_attest/README.md | 16 + .../apple_app_attest/attestation.cbor | Bin 0 -> 5906 bytes 9 files changed, 811 insertions(+), 28 deletions(-) create mode 100644 src/services/attestation/ios.rs create mode 100644 tests/fixtures/apple_app_attest/README.md create mode 100644 tests/fixtures/apple_app_attest/attestation.cbor diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 4a75e53..6fa1c2c 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -56,8 +56,11 @@ pub async fn build_attestation_store( pub fn build_attestation_service( config: &Config, store: Arc, -) -> Arc { - Arc::new(AttestationService::new(store, config.attestation.clone())) +) -> Result, AppError> { + Ok(Arc::new(AttestationService::new( + store, + config.attestation.clone(), + )?)) } pub fn build_token_service(config: &Config) -> Result, AppError> { diff --git a/src/services/attestation.rs b/src/services/attestation.rs index 327810c..1707178 100644 --- a/src/services/attestation.rs +++ b/src/services/attestation.rs @@ -1,5 +1,7 @@ //! Platform-attestation challenge issuance and wallet-state lookup. +pub mod ios; + use std::sync::Arc; use base64::Engine; @@ -8,15 +10,18 @@ use sha2::{Digest, Sha256}; use time::OffsetDateTime; use crate::{ - config::AttestationConfig, + config::{AttestationConfig, IosAppConfig}, error::AppError, - models::{AttestationClaim, Platform}, + models::{AttestationClaim, AttestationLevel, Platform}, store::attestation::{AttestationChallenge, AttestationRecord, AttestationStore}, }; +use self::ios::IosAttestationVerifier; + pub struct AttestationService { store: Arc, config: AttestationConfig, + ios_verifier: IosAttestationVerifier, } #[derive(Debug, Clone)] @@ -26,8 +31,17 @@ pub struct IssuedAttestationChallenge { } impl AttestationService { - pub fn new(store: Arc, config: AttestationConfig) -> Self { - Self { store, config } + pub fn new( + store: Arc, + config: AttestationConfig, + ) -> Result { + let ios_verifier = IosAttestationVerifier::new(&config.ios.apple_root_ca_pem) + .map_err(|error| AppError::Internal(error.to_string()))?; + Ok(Self { + store, + config, + ios_verifier, + }) } pub async fn issue_challenge( @@ -68,9 +82,7 @@ impl AttestationService { challenge: &str, wallet: &str, platform: Platform, - app: &str, ) -> Result { - self.ensure_trusted_app(platform, app)?; let hash = challenge_hash(challenge); let record = self.store.take_challenge(&hash).await?.ok_or_else(|| { AppError::InvalidRequest("attestation challenge invalid or used".into()) @@ -80,14 +92,162 @@ impl AttestationService { "attestation challenge expired".into(), )); } - if record.sub != normalize_wallet(wallet)? - || record.platform != platform - || record.app != app - { + if record.sub != normalize_wallet(wallet)? || record.platform != platform { return Err(AppError::InvalidRequest( "attestation challenge binding mismatch".into(), )); } + self.ensure_trusted_app(record.platform, &record.app)?; + Ok(record) + } + + pub async fn verify_ios_attestation( + &self, + wallet: &str, + challenge: &str, + key_id: &str, + attestation: &str, + ) -> Result { + let wallet = normalize_wallet(wallet)?; + let key_id_bytes = decode_base64(key_id, "keyId")?; + if key_id_bytes.len() != 32 { + return Err(AppError::InvalidRequest( + "keyId must decode to 32 bytes".into(), + )); + } + let canonical_key_id = base64::engine::general_purpose::STANDARD.encode(&key_id_bytes); + let attestation_bytes = decode_base64(attestation, "attestation")?; + let evidence_sha256 = hex::encode(Sha256::digest(&attestation_bytes)); + let request_challenge_sha256 = challenge_hash(challenge); + let now = OffsetDateTime::now_utc().unix_timestamp(); + + if let Some(existing) = self.store.get_wallet_record(&wallet).await? { + let same_key = existing.ios_key_id.as_deref() == Some(canonical_key_id.as_str()); + if existing.platform == Platform::Ios + && same_key + && existing.evidence_sha256 == evidence_sha256 + && existing.challenge_sha256.as_deref() == Some(request_challenge_sha256.as_str()) + && now.saturating_sub(existing.attested_at) + <= i64::from(self.config.challenge_ttl_secs) + && existing.grace_expires_at > now + { + return Ok(existing); + } + } + + let challenge_record = self + .consume_challenge(challenge, &wallet, Platform::Ios) + .await?; + let app = self.ios_app(&challenge_record.app)?; + if let Some(owner) = self.store.get_ios_key_owner(&canonical_key_id).await? { + if owner != wallet { + return Err(AppError::InvalidRequest( + "App Attest key is already associated with another wallet".into(), + )); + } + } + + let client_data_hash = Sha256::digest(challenge.as_bytes()); + let verified = self + .ios_verifier + .verify_attestation( + &attestation_bytes, + &key_id_bytes, + &client_data_hash, + &challenge_record.app, + app.environment, + now, + ) + .map_err(|error| AppError::InvalidRequest(error.to_string()))?; + let expires_at = now + i64::from(self.config.ios.attestation_ttl_secs); + let record = AttestationRecord { + platform: Platform::Ios, + app: challenge_record.app, + level: AttestationLevel::Strong, + attested_at: now, + expires_at, + grace_expires_at: expires_at + i64::from(self.config.ios.grace_secs), + ios_key_id: Some(canonical_key_id), + ios_public_key: Some( + base64::engine::general_purpose::STANDARD.encode(verified.public_key_sec1), + ), + ios_counter: Some(0), + evidence_sha256, + challenge_sha256: Some(request_challenge_sha256), + }; + self.put_verified_record(&wallet, &record).await?; + Ok(record) + } + + pub async fn verify_ios_assertion( + &self, + wallet: &str, + challenge: &str, + key_id: &str, + assertion: &str, + ) -> Result { + let wallet = normalize_wallet(wallet)?; + let key_id_bytes = decode_base64(key_id, "keyId")?; + if key_id_bytes.len() != 32 { + return Err(AppError::InvalidRequest( + "keyId must decode to 32 bytes".into(), + )); + } + let canonical_key_id = base64::engine::general_purpose::STANDARD.encode(key_id_bytes); + let assertion_bytes = decode_base64(assertion, "assertion")?; + let challenge_record = self + .consume_challenge(challenge, &wallet, Platform::Ios) + .await?; + let mut record = self + .store + .get_wallet_record(&wallet) + .await? + .ok_or_else(|| AppError::InvalidRequest("no iOS attestation record".into()))?; + if record.platform != Platform::Ios + || record.app != challenge_record.app + || record.ios_key_id.as_deref() != Some(canonical_key_id.as_str()) + { + return Err(AppError::InvalidRequest( + "iOS assertion does not match the attestation record".into(), + )); + } + let public_key = record + .ios_public_key + .as_deref() + .ok_or_else(|| AppError::Internal("iOS attestation public key is missing".into())) + .and_then(|value| decode_base64(value, "stored iOS public key"))?; + let previous_counter = record + .ios_counter + .ok_or_else(|| AppError::Internal("iOS attestation counter is missing".into()))?; + let counter = self + .ios_verifier + .verify_assertion( + &assertion_bytes, + &public_key, + &Sha256::digest(challenge.as_bytes()), + &record.app, + previous_counter, + ) + .map_err(|error| AppError::InvalidRequest(error.to_string()))?; + + let now = OffsetDateTime::now_utc().unix_timestamp(); + let expires_at = now + i64::from(self.config.ios.attestation_ttl_secs); + record.attested_at = now; + record.expires_at = expires_at; + record.grace_expires_at = expires_at + i64::from(self.config.ios.grace_secs); + record.ios_counter = Some(counter); + record.evidence_sha256 = hex::encode(Sha256::digest(&assertion_bytes)); + record.challenge_sha256 = Some(challenge_hash(challenge)); + let ttl_secs = record.grace_expires_at.saturating_sub(now).max(1) as u64; + let updated = self + .store + .put_ios_assertion_record(&wallet, previous_counter, &record, ttl_secs) + .await?; + if !updated { + return Err(AppError::InvalidRequest( + "assertion counter did not increase".into(), + )); + } Ok(record) } @@ -98,8 +258,9 @@ impl AttestationService { } let wallet = normalize_wallet(wallet)?; let record = self.store.get_wallet_record(&wallet).await?; + let now = OffsetDateTime::now_utc().unix_timestamp(); Ok(record - .filter(|record| record.grace_expires_at > OffsetDateTime::now_utc().unix_timestamp()) + .filter(|record| record.grace_expires_at > now) .map(|record| record.claim())) } @@ -149,12 +310,33 @@ impl AttestationService { )) } } + + fn ios_app(&self, app_id: &str) -> Result<&IosAppConfig, AppError> { + self.config + .ios + .apps + .iter() + .find(|app| format!("{}.{}", app.team_id, app.bundle_id) == app_id) + .ok_or_else(|| { + AppError::InvalidRequest("app is not configured for iOS attestation".into()) + }) + } } fn challenge_hash(challenge: &str) -> String { hex::encode(Sha256::digest(challenge.as_bytes())) } +fn decode_base64(value: &str, field: &str) -> Result, AppError> { + [ + &base64::engine::general_purpose::STANDARD, + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + ] + .into_iter() + .find_map(|engine| engine.decode(value.trim()).ok()) + .ok_or_else(|| AppError::InvalidRequest(format!("{field} must be valid base64"))) +} + fn normalize_wallet(wallet: &str) -> Result { let wallet = wallet.trim().to_ascii_lowercase(); if wallet.len() != 42 @@ -191,7 +373,7 @@ mod tests { }, ..AttestationConfig::default() }; - AttestationService::new(Arc::new(InMemoryAttestationStore::default()), config) + AttestationService::new(Arc::new(InMemoryAttestationStore::default()), config).unwrap() } #[tokio::test] @@ -203,21 +385,11 @@ mod tests { .await .unwrap(); service - .consume_challenge( - &issued.challenge, - wallet, - Platform::Android, - "io.nodle.cash", - ) + .consume_challenge(&issued.challenge, wallet, Platform::Android) .await .unwrap(); assert!(service - .consume_challenge( - &issued.challenge, - wallet, - Platform::Android, - "io.nodle.cash", - ) + .consume_challenge(&issued.challenge, wallet, Platform::Android,) .await .is_err()); } diff --git a/src/services/attestation/ios.rs b/src/services/attestation/ios.rs new file mode 100644 index 0000000..cb14fb0 --- /dev/null +++ b/src/services/attestation/ios.rs @@ -0,0 +1,489 @@ +//! Apple App Attest object and assertion verification. + +use ciborium::de::from_reader; +use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use serde::Deserialize; +use serde_bytes::ByteBuf; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use x509_parser::{ + parse_x509_certificate, + pem::parse_x509_pem, + prelude::{ASN1Time, X509Certificate}, +}; + +use crate::config::IosEnvironment; + +const APPLE_NONCE_OID: &str = "1.2.840.113635.100.8.2"; +const PRODUCTION_AAGUID: &[u8; 16] = b"appattest\0\0\0\0\0\0\0"; +const DEVELOPMENT_AAGUID: &[u8; 16] = b"appattestdevelop"; +const BUNDLED_APPLE_ROOT_CA_PEM: &str = + include_str!("../../../config/apple_app_attestation_root_ca.pem"); + +#[derive(Debug, Error)] +pub enum IosVerificationError { + #[error("invalid attestation object")] + InvalidAttestationObject, + #[error("unexpected attestation format")] + UnexpectedFormat, + #[error("invalid Apple certificate chain")] + InvalidCertificateChain, + #[error("Apple certificate is not valid at the verification time")] + InvalidCertificateTime, + #[error("invalid App Attest credential certificate")] + InvalidCredentialCertificate, + #[error("attestation nonce does not match the challenge")] + NonceMismatch, + #[error("attestation key ID does not match the credential")] + KeyIdMismatch, + #[error("attestation app identity does not match")] + AppMismatch, + #[error("invalid initial attestation counter")] + InvalidInitialCounter, + #[error("attestation environment does not match")] + EnvironmentMismatch, + #[error("invalid assertion object")] + InvalidAssertionObject, + #[error("assertion counter did not increase")] + CounterDidNotIncrease, + #[error("invalid assertion signature")] + InvalidAssertionSignature, + #[error("invalid Apple root certificate")] + InvalidRootCertificate, +} + +#[derive(Debug)] +pub struct VerifiedIosAttestation { + pub public_key_sec1: Vec, + pub receipt: Vec, +} + +pub struct IosAttestationVerifier { + root_der: Vec, +} + +impl IosAttestationVerifier { + pub fn new(root_ca_pem: &str) -> Result { + let pem = if root_ca_pem.trim().is_empty() { + BUNDLED_APPLE_ROOT_CA_PEM.as_bytes() + } else { + root_ca_pem.as_bytes() + }; + let (remaining, root) = + parse_x509_pem(pem).map_err(|_| IosVerificationError::InvalidRootCertificate)?; + if !remaining.iter().all(u8::is_ascii_whitespace) { + return Err(IosVerificationError::InvalidRootCertificate); + } + let (_, certificate) = parse_x509_certificate(&root.contents) + .map_err(|_| IosVerificationError::InvalidRootCertificate)?; + validate_root(&certificate)?; + Ok(Self { + root_der: root.contents, + }) + } + + pub fn verify_attestation( + &self, + attestation_object: &[u8], + key_id: &[u8], + client_data_hash: &[u8], + app_id: &str, + environment: IosEnvironment, + now_unix: i64, + ) -> Result { + let object: AttestationObject = from_reader(attestation_object) + .map_err(|_| IosVerificationError::InvalidAttestationObject)?; + if object.format != "apple-appattest" || object.statement.certificates.len() != 2 { + return Err(IosVerificationError::UnexpectedFormat); + } + + let leaf_der = object.statement.certificates[0].as_ref(); + let intermediate_der = object.statement.certificates[1].as_ref(); + let (remaining, leaf) = parse_x509_certificate(leaf_der) + .map_err(|_| IosVerificationError::InvalidCredentialCertificate)?; + if !remaining.is_empty() { + return Err(IosVerificationError::InvalidCredentialCertificate); + } + let (remaining, intermediate) = parse_x509_certificate(intermediate_der) + .map_err(|_| IosVerificationError::InvalidCertificateChain)?; + if !remaining.is_empty() { + return Err(IosVerificationError::InvalidCertificateChain); + } + let (_, root) = parse_x509_certificate(&self.root_der) + .map_err(|_| IosVerificationError::InvalidRootCertificate)?; + + verify_certificate_chain(&leaf, &intermediate, &root, now_unix)?; + + let public_key = leaf.public_key().subject_public_key.data.as_ref(); + if public_key.len() != 65 + || public_key.first() != Some(&0x04) + || VerifyingKey::from_sec1_bytes(public_key).is_err() + { + return Err(IosVerificationError::InvalidCredentialCertificate); + } + + let auth_data = object.authenticator_data.as_ref(); + let parsed = parse_attestation_auth_data(auth_data)?; + let expected_key_id = Sha256::digest(public_key); + if key_id != expected_key_id.as_slice() + || parsed.credential_id != key_id + || parsed.credential_id != expected_key_id.as_slice() + { + return Err(IosVerificationError::KeyIdMismatch); + } + + let expected_rp_id = Sha256::digest(app_id.as_bytes()); + if parsed.rp_id_hash != expected_rp_id.as_slice() { + return Err(IosVerificationError::AppMismatch); + } + if parsed.counter != 0 { + return Err(IosVerificationError::InvalidInitialCounter); + } + let expected_aaguid = match environment { + IosEnvironment::Production => PRODUCTION_AAGUID, + IosEnvironment::Development => DEVELOPMENT_AAGUID, + }; + if parsed.aaguid != expected_aaguid { + return Err(IosVerificationError::EnvironmentMismatch); + } + + let mut nonce_input = Vec::with_capacity(auth_data.len() + client_data_hash.len()); + nonce_input.extend_from_slice(auth_data); + nonce_input.extend_from_slice(client_data_hash); + let expected_nonce = Sha256::digest(&nonce_input); + let certificate_nonce = certificate_nonce(&leaf)?; + if certificate_nonce != expected_nonce.as_slice() { + return Err(IosVerificationError::NonceMismatch); + } + + Ok(VerifiedIosAttestation { + public_key_sec1: public_key.to_vec(), + receipt: object.statement.receipt.into_vec(), + }) + } + + pub fn verify_assertion( + &self, + assertion_object: &[u8], + public_key_sec1: &[u8], + client_data_hash: &[u8], + app_id: &str, + previous_counter: u32, + ) -> Result { + let assertion: AssertionObject = from_reader(assertion_object) + .map_err(|_| IosVerificationError::InvalidAssertionObject)?; + let auth_data = assertion.authenticator_data.as_ref(); + if auth_data.len() != 37 { + return Err(IosVerificationError::InvalidAssertionObject); + } + + let expected_rp_id = Sha256::digest(app_id.as_bytes()); + if &auth_data[..32] != expected_rp_id.as_slice() { + return Err(IosVerificationError::AppMismatch); + } + let counter = u32::from_be_bytes( + auth_data[33..37] + .try_into() + .map_err(|_| IosVerificationError::InvalidAssertionObject)?, + ); + if counter <= previous_counter { + return Err(IosVerificationError::CounterDidNotIncrease); + } + + let verifying_key = VerifyingKey::from_sec1_bytes(public_key_sec1) + .map_err(|_| IosVerificationError::InvalidCredentialCertificate)?; + let signature = Signature::from_der(assertion.signature.as_ref()) + .map_err(|_| IosVerificationError::InvalidAssertionObject)?; + let mut signed_data = Vec::with_capacity(auth_data.len() + client_data_hash.len()); + signed_data.extend_from_slice(auth_data); + signed_data.extend_from_slice(client_data_hash); + verifying_key + .verify(&signed_data, &signature) + .map_err(|_| IosVerificationError::InvalidAssertionSignature)?; + Ok(counter) + } +} + +#[derive(Deserialize)] +struct AttestationObject { + #[serde(rename = "fmt")] + format: String, + #[serde(rename = "attStmt")] + statement: AttestationStatement, + #[serde(rename = "authData")] + authenticator_data: ByteBuf, +} + +#[derive(Deserialize)] +struct AttestationStatement { + #[serde(rename = "x5c")] + certificates: Vec, + receipt: ByteBuf, +} + +#[derive(Deserialize)] +struct AssertionObject { + signature: ByteBuf, + #[serde(rename = "authenticatorData")] + authenticator_data: ByteBuf, +} + +struct ParsedAttestationAuthData<'a> { + rp_id_hash: &'a [u8], + counter: u32, + aaguid: &'a [u8], + credential_id: &'a [u8], +} + +fn parse_attestation_auth_data( + auth_data: &[u8], +) -> Result, IosVerificationError> { + if auth_data.len() < 55 { + return Err(IosVerificationError::InvalidAttestationObject); + } + let counter = u32::from_be_bytes( + auth_data[33..37] + .try_into() + .map_err(|_| IosVerificationError::InvalidAttestationObject)?, + ); + let credential_length = usize::from(u16::from_be_bytes( + auth_data[53..55] + .try_into() + .map_err(|_| IosVerificationError::InvalidAttestationObject)?, + )); + let credential_end = 55usize + .checked_add(credential_length) + .filter(|end| *end <= auth_data.len()) + .ok_or(IosVerificationError::InvalidAttestationObject)?; + Ok(ParsedAttestationAuthData { + rp_id_hash: &auth_data[..32], + counter, + aaguid: &auth_data[37..53], + credential_id: &auth_data[55..credential_end], + }) +} + +fn verify_certificate_chain( + leaf: &X509Certificate<'_>, + intermediate: &X509Certificate<'_>, + root: &X509Certificate<'_>, + now_unix: i64, +) -> Result<(), IosVerificationError> { + if leaf.issuer() != intermediate.subject() + || intermediate.issuer() != root.subject() + || leaf.is_ca() + || !intermediate.is_ca() + || !root.is_ca() + { + return Err(IosVerificationError::InvalidCertificateChain); + } + let leaf_usage = leaf + .key_usage() + .map_err(|_| IosVerificationError::InvalidCertificateChain)? + .ok_or(IosVerificationError::InvalidCertificateChain)?; + let intermediate_usage = intermediate + .key_usage() + .map_err(|_| IosVerificationError::InvalidCertificateChain)? + .ok_or(IosVerificationError::InvalidCertificateChain)?; + if !leaf_usage.value.digital_signature() || !intermediate_usage.value.key_cert_sign() { + return Err(IosVerificationError::InvalidCertificateChain); + } + + leaf.verify_signature(Some(intermediate.public_key())) + .map_err(|_| IosVerificationError::InvalidCertificateChain)?; + intermediate + .verify_signature(Some(root.public_key())) + .map_err(|_| IosVerificationError::InvalidCertificateChain)?; + + let now = ASN1Time::from_timestamp(now_unix) + .map_err(|_| IosVerificationError::InvalidCertificateTime)?; + if !leaf.validity().is_valid_at(now) + || !intermediate.validity().is_valid_at(now) + || !root.validity().is_valid_at(now) + { + return Err(IosVerificationError::InvalidCertificateTime); + } + Ok(()) +} + +fn validate_root(root: &X509Certificate<'_>) -> Result<(), IosVerificationError> { + if root.subject() != root.issuer() || !root.is_ca() { + return Err(IosVerificationError::InvalidRootCertificate); + } + let usage = root + .key_usage() + .map_err(|_| IosVerificationError::InvalidRootCertificate)? + .ok_or(IosVerificationError::InvalidRootCertificate)?; + if !usage.value.key_cert_sign() { + return Err(IosVerificationError::InvalidRootCertificate); + } + root.verify_signature(None) + .map_err(|_| IosVerificationError::InvalidRootCertificate) +} + +fn certificate_nonce<'a>( + certificate: &'a X509Certificate<'_>, +) -> Result<&'a [u8], IosVerificationError> { + let extension = certificate + .extensions() + .iter() + .find(|extension| extension.oid.to_id_string() == APPLE_NONCE_OID) + .ok_or(IosVerificationError::InvalidCredentialCertificate)?; + const PREFIX: &[u8] = &[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]; + if extension.value.len() != PREFIX.len() + 32 || !extension.value.starts_with(PREFIX) { + return Err(IosVerificationError::InvalidCredentialCertificate); + } + Ok(&extension.value[PREFIX.len()..]) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use p256::ecdsa::{signature::Signer, SigningKey}; + use serde::Serialize; + use time::OffsetDateTime; + + const APPLE_FIXTURE: &[u8] = + include_bytes!("../../../tests/fixtures/apple_app_attest/attestation.cbor"); + const APP_ID: &str = "1234567890.com.example.myapp"; + const CHALLENGE: &[u8] = b"example_server_challenge"; + const KEY_ID: &str = "zgSY9YSD+7TaDXssY6WlOPVS1K3Lmk+pFhlcSWE+ZV0="; + const FIXTURE_TIME: i64 = 1_776_795_192; + + #[test] + fn verifies_apple_attestation_fixture() { + let verifier = IosAttestationVerifier::new("").unwrap(); + let key_id = base64::engine::general_purpose::STANDARD + .decode(KEY_ID) + .unwrap(); + let verified = verifier + .verify_attestation( + APPLE_FIXTURE, + &key_id, + CHALLENGE, + APP_ID, + IosEnvironment::Production, + FIXTURE_TIME, + ) + .unwrap(); + assert_eq!(verified.public_key_sec1.len(), 65); + assert!(!verified.receipt.is_empty()); + assert_eq!( + Sha256::digest(&verified.public_key_sec1).as_slice(), + key_id.as_slice() + ); + } + + #[test] + fn fixture_rejects_wrong_challenge_and_environment() { + let verifier = IosAttestationVerifier::new("").unwrap(); + let key_id = base64::engine::general_purpose::STANDARD + .decode(KEY_ID) + .unwrap(); + let challenge_error = verifier + .verify_attestation( + APPLE_FIXTURE, + &key_id, + b"wrong", + APP_ID, + IosEnvironment::Production, + FIXTURE_TIME, + ) + .unwrap_err(); + assert!(matches!( + challenge_error, + IosVerificationError::NonceMismatch + )); + + let environment_error = verifier + .verify_attestation( + APPLE_FIXTURE, + &key_id, + CHALLENGE, + APP_ID, + IosEnvironment::Development, + FIXTURE_TIME, + ) + .unwrap_err(); + assert!(matches!( + environment_error, + IosVerificationError::EnvironmentMismatch + )); + } + + #[test] + fn fixture_uses_injected_certificate_time() { + let verifier = IosAttestationVerifier::new("").unwrap(); + let key_id = base64::engine::general_purpose::STANDARD + .decode(KEY_ID) + .unwrap(); + let error = verifier + .verify_attestation( + APPLE_FIXTURE, + &key_id, + CHALLENGE, + APP_ID, + IosEnvironment::Production, + OffsetDateTime::now_utc().unix_timestamp(), + ) + .unwrap_err(); + assert!(matches!( + error, + IosVerificationError::InvalidCertificateTime + )); + } + + #[derive(Serialize)] + struct TestAssertion { + signature: ByteBuf, + #[serde(rename = "authenticatorData")] + authenticator_data: ByteBuf, + } + + #[test] + fn verifies_assertion_and_rejects_replayed_counter() { + let verifier = IosAttestationVerifier::new("").unwrap(); + let signing_key = SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); + let public_key = signing_key.verifying_key().to_encoded_point(false); + let challenge = b"assertion-challenge"; + + let mut auth_data = Vec::with_capacity(37); + auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes())); + auth_data.push(0); + auth_data.extend_from_slice(&1u32.to_be_bytes()); + let client_data_hash = Sha256::digest(challenge); + let mut signed_data = auth_data.clone(); + signed_data.extend_from_slice(&client_data_hash); + let signature: Signature = signing_key.sign(&signed_data); + let assertion = TestAssertion { + signature: ByteBuf::from(signature.to_der().as_bytes().to_vec()), + authenticator_data: ByteBuf::from(auth_data), + }; + let mut encoded = Vec::new(); + ciborium::ser::into_writer(&assertion, &mut encoded).unwrap(); + + assert_eq!( + verifier + .verify_assertion( + &encoded, + public_key.as_bytes(), + &client_data_hash, + APP_ID, + 0, + ) + .unwrap(), + 1 + ); + let error = verifier + .verify_assertion( + &encoded, + public_key.as_bytes(), + &client_data_hash, + APP_ID, + 1, + ) + .unwrap_err(); + assert!(matches!(error, IosVerificationError::CounterDidNotIncrease)); + } +} diff --git a/src/state.rs b/src/state.rs index d903c9b..2d20f8e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -34,7 +34,7 @@ impl AppState { let nonce = bootstrap::build_nonce_service(&config, store); let nonce_rate_limiter = bootstrap::build_nonce_rate_limiter(&config); let attestation_store = bootstrap::build_attestation_store(&config).await?; - let attestation = bootstrap::build_attestation_service(&config, attestation_store); + let attestation = bootstrap::build_attestation_service(&config, attestation_store)?; let token = bootstrap::build_token_service(&config)?; let revocation_store = bootstrap::build_revocation_store(&config).await?; let revocation = bootstrap::build_revocation_service(revocation_store); diff --git a/src/store/attestation.rs b/src/store/attestation.rs index f3e949c..de7278e 100644 --- a/src/store/attestation.rs +++ b/src/store/attestation.rs @@ -39,6 +39,8 @@ pub struct AttestationRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub ios_counter: Option, pub evidence_sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub challenge_sha256: Option, } impl AttestationRecord { @@ -93,6 +95,16 @@ pub trait AttestationStore: Send + Sync { wallet: &str, ) -> Result, AttestationStoreError>; + /// Replace an iOS record only when its stored counter still matches the + /// value that was verified, preventing concurrent assertion regressions. + async fn put_ios_assertion_record( + &self, + wallet: &str, + expected_counter: u32, + record: &AttestationRecord, + ttl_secs: u64, + ) -> Result; + async fn put_ios_key_owner( &self, key_id: &str, diff --git a/src/store/attestation/memory.rs b/src/store/attestation/memory.rs index 473b864..2e4bc14 100644 --- a/src/store/attestation/memory.rs +++ b/src/store/attestation/memory.rs @@ -64,6 +64,29 @@ impl AttestationStore for InMemoryAttestationStore { .cloned()) } + async fn put_ios_assertion_record( + &self, + wallet: &str, + expected_counter: u32, + record: &AttestationRecord, + _ttl_secs: u64, + ) -> Result { + let mut wallets = self + .wallets + .lock() + .map_err(|_| AttestationStoreError::Other("lock poisoned".into()))?; + let Some(current) = wallets.get(wallet) else { + return Ok(false); + }; + if current.ios_counter != Some(expected_counter) + || !matches!(record.ios_counter, Some(counter) if counter > expected_counter) + { + return Ok(false); + } + wallets.insert(wallet.to_string(), record.clone()); + Ok(true) + } + async fn put_ios_key_owner( &self, key_id: &str, @@ -93,7 +116,7 @@ impl AttestationStore for InMemoryAttestationStore { #[cfg(test)] mod tests { use super::*; - use crate::models::Platform; + use crate::models::{AttestationLevel, Platform}; #[tokio::test] async fn challenge_is_consumed_once() { @@ -109,4 +132,35 @@ mod tests { assert_eq!(store.take_challenge("hash").await.unwrap(), Some(challenge)); assert_eq!(store.take_challenge("hash").await.unwrap(), None); } + + #[tokio::test] + async fn ios_assertion_update_compares_counter_atomically() { + let store = InMemoryAttestationStore::default(); + let mut record = AttestationRecord { + platform: Platform::Ios, + app: "TEAM.app".into(), + level: AttestationLevel::Strong, + attested_at: 1, + expires_at: 2, + grace_expires_at: 3, + ios_key_id: Some("key".into()), + ios_public_key: Some("public".into()), + ios_counter: Some(0), + evidence_sha256: "evidence".into(), + challenge_sha256: None, + }; + store + .put_wallet_record("wallet", &record, 60) + .await + .unwrap(); + record.ios_counter = Some(1); + assert!(store + .put_ios_assertion_record("wallet", 0, &record, 60) + .await + .unwrap()); + assert!(!store + .put_ios_assertion_record("wallet", 0, &record, 60) + .await + .unwrap()); + } } diff --git a/src/store/attestation/redis.rs b/src/store/attestation/redis.rs index 6fd6ea3..d206958 100644 --- a/src/store/attestation/redis.rs +++ b/src/store/attestation/redis.rs @@ -80,6 +80,43 @@ impl AttestationStore for RedisAttestationStore { .transpose() } + async fn put_ios_assertion_record( + &self, + wallet: &str, + expected_counter: u32, + record: &AttestationRecord, + ttl_secs: u64, + ) -> Result { + let key = format!("{WALLET_PREFIX}{wallet}"); + let payload = serde_json::to_string(record) + .map_err(|e| AttestationStoreError::Other(e.to_string()))?; + let script = r#" + local raw = redis.call('GET', KEYS[1]) + if not raw then return 0 end + local current = cjson.decode(raw) + local expected = tonumber(ARGV[1]) + local next_counter = tonumber(ARGV[2]) + if current.ios_counter ~= expected or next_counter <= expected then + return 0 + end + redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[4]) + return 1 + "#; + let next_counter = record + .ios_counter + .ok_or_else(|| AttestationStoreError::Other("iOS counter is missing".into()))?; + let mut conn = self.client.clone(); + let updated: i64 = redis::Script::new(script) + .key(key) + .arg(expected_counter) + .arg(next_counter) + .arg(payload) + .arg(ttl_secs.max(1)) + .invoke_async(&mut conn) + .await?; + Ok(updated == 1) + } + async fn put_ios_key_owner( &self, key_id: &str, diff --git a/tests/fixtures/apple_app_attest/README.md b/tests/fixtures/apple_app_attest/README.md new file mode 100644 index 0000000..ae44391 --- /dev/null +++ b/tests/fixtures/apple_app_attest/README.md @@ -0,0 +1,16 @@ +# Apple App Attest fixture + +`attestation.cbor` is the Base64-decoded example attestation object from Apple's +[Attestation Object Validation Guide](https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide), +fetched on 2026-07-17 from Apple's documentation JSON API. + +The example values used by the conformance tests are: + +- App ID: `1234567890.com.example.myapp` +- Client data hash in Apple's fixture: the bytes of `example_server_challenge` +- Key ID: `zgSY9YSD+7TaDXssY6WlOPVS1K3Lmk+pFhlcSWE+ZV0=` +- Certificate verification time: `2026-04-21T18:13:12Z` + +The fixture certificate is intentionally verified at its issuance time because +App Attest credential certificates are short-lived. Production verification +always supplies the current server time. diff --git a/tests/fixtures/apple_app_attest/attestation.cbor b/tests/fixtures/apple_app_attest/attestation.cbor new file mode 100644 index 0000000000000000000000000000000000000000..e7f3e7d67db7f3e6d6f22406ba43b0a88041f989 GIT binary patch literal 5906 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 literal 0 HcmV?d00001 From c085ed6d8df1ea1251e05b6da4ec2b8bf37a8965 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 17 Jul 2026 13:28:13 +1200 Subject: [PATCH 3/4] Add iOS attestation and assertion refresh endpoints. Expose POST /v1/attest/ios and /v1/attest/refresh so wallet sessions can upgrade to attested tier and keep the record alive via generateAssertion. Co-authored-by: Cursor --- src/routes/attest.rs | 109 ++++++++++++++++++++++++++++- src/routes/mod.rs | 2 + tests/attestation.rs | 139 ++++++++++++++++++++++++++++++++++++- tests/attestation_redis.rs | 28 ++++++++ 4 files changed, 276 insertions(+), 2 deletions(-) diff --git a/src/routes/attest.rs b/src/routes/attest.rs index 0d3d5da..29f411a 100644 --- a/src/routes/attest.rs +++ b/src/routes/attest.rs @@ -4,7 +4,12 @@ use axum::{extract::State, http::HeaderMap, Json}; use serde::{Deserialize, Serialize}; use time::format_description::well_known::Rfc3339; -use crate::{error::AppError, models::Platform, state::AppState}; +use crate::{ + error::AppError, + models::{AttestationLevel, Platform}, + state::AppState, + store::attestation::AttestationRecord, +}; use super::auth::verified_claims; @@ -21,6 +26,42 @@ pub struct ChallengeResponse { pub expires_at: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IosAttestationRequest { + pub key_id: String, + pub attestation: 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, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttestationResponse { + pub access_token: String, + pub token_type: &'static str, + pub expires_in: u32, + pub attestation: AttestationSummary, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttestationSummary { + pub platform: Platform, + pub app: String, + pub level: AttestationLevel, + pub attested_at: i64, + pub expires_at: i64, +} + /// Issue a single-use challenge bound to the bearer wallet, platform, and app. pub async fn post_challenge( State(state): State, @@ -42,3 +83,69 @@ pub async fn post_challenge( expires_at, })) } + +/// Verify an initial Apple App Attest object and upgrade the caller's token. +pub async fn post_ios_attestation( + 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 = state + .attestation + .verify_ios_attestation( + &claims.sub, + &body.challenge, + &body.key_id, + &body.attestation, + ) + .await?; + attestation_response(&state, &claims, record) +} + +/// Re-prove iOS key possession using a counter-protected App Attest assertion. +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?; + 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) + .await?; + attestation_response(&state, &claims, record) +} + +fn attestation_response( + state: &AppState, + claims: &crate::models::AccessTokenClaims, + record: AttestationRecord, +) -> Result, AppError> { + let scopes: Vec<&str> = claims.scope.split_whitespace().collect(); + let access_token = state.token.mint_access_token( + &claims.sub, + &scopes, + claims.chain_id.unwrap_or(state.config.auth.chain_id), + Some(record.claim()), + )?; + Ok(Json(AttestationResponse { + access_token, + token_type: "Bearer", + expires_in: state.config.signing.access_token_ttl_secs, + attestation: AttestationSummary { + platform: record.platform, + app: record.app, + level: record.level, + attested_at: record.attested_at, + expires_at: record.expires_at, + }, + })) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index d886a36..67d0721 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -30,6 +30,8 @@ fn wallet_auth_routes(state: AppState) -> Router { .route("/v1/auth/logout", post(auth::post_logout)) .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/refresh", post(attest::post_attestation_refresh)) .with_state(state) .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES)) } diff --git a/tests/attestation.rs b/tests/attestation.rs index 43b51f8..f6bc7ca 100644 --- a/tests/attestation.rs +++ b/tests/attestation.rs @@ -10,12 +10,19 @@ use base64::Engine; use common::{build_siwe_message, sign_siwe_message, TEST_WALLET}; use ed25519_dalek::SigningKey; use http_body_util::BodyExt; +use p256::ecdsa::{signature::Signer, Signature, SigningKey as P256SigningKey}; use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; use serde_json::json; +use sha2::{Digest, Sha256}; use time::OffsetDateTime; use tower::ServiceExt; use trust_relay::{ - config::{AndroidAppConfig, AndroidAttestationConfig, AttestationConfig, Config}, + config::{ + AndroidAppConfig, AndroidAttestationConfig, AttestationConfig, Config, IosAppConfig, + IosAttestationConfig, IosEnvironment, + }, models::{AttestationLevel, Platform, TrustTier}, routes::create_router, services::token::TokenService, @@ -40,6 +47,17 @@ fn attestation_config() -> Config { }], ..AndroidAttestationConfig::default() }, + ios: IosAttestationConfig { + apps: vec![IosAppConfig { + name: "nodle-cash".into(), + team_id: "TEAMID".into(), + bundle_id: "com.nodle.cash".into(), + environment: IosEnvironment::Production, + earns_rewards: true, + attestation_optional: false, + }], + ..IosAttestationConfig::default() + }, ..AttestationConfig::default() }; config @@ -50,6 +68,12 @@ async fn response_json(response: axum::response::Response) -> serde_json::Value serde_json::from_slice(&body).unwrap() } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChallengeBody { + challenge: String, +} + async fn fetch_nonce(app: &axum::Router) -> String { let response = app .clone() @@ -119,6 +143,7 @@ async fn session_and_refresh_mints_include_live_attestation_record() { ios_public_key: None, ios_counter: None, evidence_sha256: "evidence".into(), + challenge_sha256: None, }, ) .await @@ -183,3 +208,115 @@ async fn session_and_refresh_mints_include_live_attestation_record() { assert_eq!(refresh_claims.tier, Some(TrustTier::Attested)); assert_eq!(refresh_claims.att.unwrap().app, "io.nodle.cash"); } + +#[derive(Serialize)] +struct TestIosAssertion { + signature: ByteBuf, + #[serde(rename = "authenticatorData")] + authenticator_data: ByteBuf, +} + +#[tokio::test] +async fn ios_assertion_refresh_updates_record_and_mints_token() { + const APP_ID: &str = "TEAMID.com.nodle.cash"; + let state = AppState::build(attestation_config()).await.unwrap(); + let signing_key = P256SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); + let public_key = signing_key.verifying_key().to_encoded_point(false); + let key_id = + base64::engine::general_purpose::STANDARD.encode(Sha256::digest(public_key.as_bytes())); + let now = OffsetDateTime::now_utc().unix_timestamp(); + state + .attestation + .put_verified_record( + TEST_WALLET, + &AttestationRecord { + platform: Platform::Ios, + app: APP_ID.into(), + level: AttestationLevel::Strong, + attested_at: now, + expires_at: now + 604_800, + grace_expires_at: now + 691_200, + ios_key_id: Some(key_id.clone()), + ios_public_key: Some( + base64::engine::general_purpose::STANDARD.encode(public_key.as_bytes()), + ), + ios_counter: Some(0), + evidence_sha256: "initial".into(), + challenge_sha256: None, + }, + ) + .await + .unwrap(); + let token = state + .token + .mint_wallet_token(TEST_WALLET, &["scan:submit"], 324) + .unwrap(); + let signing = state.config.signing.clone(); + 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": "ios", "app": APP_ID}).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 auth_data = Vec::with_capacity(37); + auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes())); + auth_data.push(0); + auth_data.extend_from_slice(&1u32.to_be_bytes()); + let client_data_hash = Sha256::digest(challenge.challenge.as_bytes()); + let mut signed_data = auth_data.clone(); + signed_data.extend_from_slice(&client_data_hash); + let signature: Signature = signing_key.sign(&signed_data); + let assertion = TestIosAssertion { + signature: ByteBuf::from(signature.to_der().as_bytes().to_vec()), + authenticator_data: ByteBuf::from(auth_data), + }; + let mut assertion_bytes = Vec::new(); + ciborium::ser::into_writer(&assertion, &mut assertion_bytes).unwrap(); + let assertion_b64 = base64::engine::general_purpose::STANDARD.encode(assertion_bytes); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/refresh") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "platform": "ios", + "keyId": key_id, + "assertion": assertion_b64, + "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"], "ios"); + assert_eq!(body["attestation"]["app"], APP_ID); + 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::Ios); +} diff --git a/tests/attestation_redis.rs b/tests/attestation_redis.rs index ad720c2..e066ef9 100644 --- a/tests/attestation_redis.rs +++ b/tests/attestation_redis.rs @@ -52,6 +52,7 @@ async fn redis_attestation_store_roundtrip_and_single_use_challenge() { ios_public_key: None, ios_counter: None, evidence_sha256: id.clone(), + challenge_sha256: None, }; store.put_wallet_record(&wallet, &record, 60).await.unwrap(); assert_eq!( @@ -64,4 +65,31 @@ async fn redis_attestation_store_roundtrip_and_single_use_challenge() { store.get_ios_key_owner(&id).await.unwrap().as_deref(), Some(wallet.as_str()) ); + + let mut ios_record = AttestationRecord { + platform: Platform::Ios, + app: "TEAM.app".into(), + level: AttestationLevel::Strong, + attested_at: 1, + expires_at: 2, + grace_expires_at: 3, + ios_key_id: Some(id.clone()), + ios_public_key: Some("public".into()), + ios_counter: Some(0), + evidence_sha256: "initial".into(), + challenge_sha256: None, + }; + store + .put_wallet_record(&wallet, &ios_record, 60) + .await + .unwrap(); + ios_record.ios_counter = Some(1); + assert!(store + .put_ios_assertion_record(&wallet, 0, &ios_record, 60) + .await + .unwrap()); + assert!(!store + .put_ios_assertion_record(&wallet, 0, &ios_record, 60) + .await + .unwrap()); } From ac974a701ef49d31f3a20189cda5269e0ab0f8bb Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 17 Jul 2026 13:55:04 +1200 Subject: [PATCH 4/4] Keep coverage PR comments under GitHub's size limit. Extract only TOTAL from llvm-cov (with color disabled) instead of falling back to the full summary, and don't fail the job if posting the sticky comment fails after thresholds already ran. Co-authored-by: Cursor --- .github/workflows/ci.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc3b41d..f74afda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,9 @@ jobs: - name: Measure coverage id: coverage shell: bash + env: + # Keep report text free of ANSI so TOTAL extraction is reliable. + CARGO_TERM_COLOR: never run: | set +e cargo llvm-cov --all-targets --all-features --no-report @@ -99,7 +102,18 @@ jobs: cargo llvm-cov report --lcov --output-path lcov.info cargo llvm-cov report --text --summary-only > coverage-summary.txt - grep -E '^(Filename|TOTAL|-{5,})' coverage-summary.txt > coverage-table.txt || cp coverage-summary.txt coverage-table.txt + + # PR comments are capped at 65 KiB. Keep only the TOTAL line(s) — + # never fall back to the full per-file summary (that is what blew + # up once ANSI-colored headers stopped matching the Filename grep). + { + grep -E '^Filename' coverage-summary.txt | head -n 1 || true + grep -E '^-{5,}' coverage-summary.txt | head -n 1 || true + grep -E '^TOTAL' coverage-summary.txt || true + } > coverage-table.txt + if [[ ! -s coverage-table.txt ]]; then + tail -n 5 coverage-summary.txt > coverage-table.txt + fi set +e cargo llvm-cov report \ @@ -125,18 +139,14 @@ jobs: echo echo "${status}" echo - echo "
" - echo "Summary" - echo echo '```' cat coverage-table.txt echo '```' - echo - echo "
" } > coverage-comment.md - name: Post coverage comment if: github.event_name == 'pull_request' + continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage