diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml index 77710ae0d9..7f6c5e57a2 100644 --- a/.intersect/sadscan.yaml +++ b/.intersect/sadscan.yaml @@ -4,11 +4,15 @@ exclude_rules_for_files: - Cargo.lock # Self-signed test fixture generated solely to exercise reqwest identity parsing. kingfisher.privkey.2: + # Published upstream synthetic CA key, compiled only under cfg(test). + # The gateway pins Apple's root and cannot trust this fixture CA. + - "*appattest-test-ca-key.pem" - "*apns-test-identity.pem" - "*apns-test-key-only.pem" - "*apns-test-encrypted-identity.pem" - "*apns-test-mismatched-identity.pem" np.pem.1: + - "*appattest-test-ca-key.pem" - "*apns-test-identity.pem" - "*apns-test-key-only.pem" - "*apns-test-encrypted-identity.pem" diff --git a/Cargo.lock b/Cargo.lock index 798825deaf..94a5ebd139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,24 +158,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "appattest" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7" -dependencies = [ - "arrayvec", - "aws-lc-rs", - "base64 0.22.1", - "byteorder", - "ciborium", - "minicbor", - "openssl", - "p256", - "rustls-pki-types", - "rustls-webpki", -] - [[package]] name = "apple-native-keyring-store" version = "1.0.2" @@ -1403,21 +1385,25 @@ name = "buzz-push-gateway" version = "0.1.0" dependencies = [ "aes-gcm", - "appattest", "async-trait", + "aws-lc-rs", "axum", "base64 0.22.1", - "byteorder", "chrono", + "ciborium", "getrandom 0.4.3", "hex", "metrics", "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", + "openssl", + "p256", "proptest", "rand 0.10.1", "reqwest 0.13.4", + "rustls-pki-types", + "rustls-webpki", "serde", "serde_json", "sha2 0.11.0", @@ -1430,6 +1416,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "x509-parser", ] [[package]] @@ -5963,6 +5950,21 @@ name = "minicbor" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0" +dependencies = [ + "half", + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "minidom" @@ -6927,6 +6929,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.116" @@ -6935,6 +6946,7 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index 2946d2802a..29dc8d2137 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -17,16 +17,19 @@ path = "src/main.rs" [features] # Personal device development only. Production builds remain production-only. -personal-dev-app-attest = ["appattest/testing"] +personal-dev-app-attest = [] [dependencies] aes-gcm = "0.10" -appattest = { version = "0.1.1", default-features = false } +aws-lc-rs = { version = "1", default-features = false } +minicbor = { version = "0.25", features = ["std", "half"] } +p256 = { version = "0.13", features = ["ecdsa"] } +rustls-pki-types = { version = "1", features = ["std"] } +webpki = { package = "rustls-webpki", version = "0.103.9", default-features = false, features = ["aws-lc-rs", "alloc"] } +x509-parser = { version = "0.18.1", default-features = false } axum = { workspace = true } async-trait = "0.1" base64 = "0.22" -byteorder = "1.5" -minicbor = "0.25" chrono = { workspace = true } hex = { workspace = true } getrandom = "0.4" @@ -49,5 +52,7 @@ url = { workspace = true } uuid = { workspace = true } [dev-dependencies] +openssl = { version = "0.10", features = ["vendored"] } +ciborium = "0.2" proptest = { workspace = true } reqwest = { workspace = true } diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index 979106936f..a6b5076f11 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -2,9 +2,17 @@ //! Apple production AAGUID material by default; personal development requires //! an explicit build feature and environment. No cryptographic checks are skipped. use crate::config::AppAttestEnvironment; -use appattest::{assertion::Assertion, attestation::Attestation}; +mod authenticator; +mod cbor; +mod certificate; +#[cfg(test)] +mod compatibility_tests; +mod error; +mod proof; +mod properties; +#[cfg(test)] +mod testing; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use byteorder::{BigEndian, ByteOrder}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -76,32 +84,28 @@ impl AppAttestVerifier { key_id_b64: &str, client_data: &[u8], ) -> Result { + if attestation_b64.len() > crate::model::MAX_APP_ATTESTATION_BYTES.div_ceil(3) * 4 { + return Err(AppAttestError::Invalid); + } let cbor = STANDARD .decode(attestation_b64) .map_err(|_| AppAttestError::Invalid)?; if cbor.is_empty() || cbor.len() > crate::model::MAX_APP_ATTESTATION_BYTES { return Err(AppAttestError::Invalid); } - verify_attestation_environment(&cbor, self.environment)?; - let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?; - let att = Attestation::from_cbor_bytes(&cbor).map_err(|_| AppAttestError::Invalid)?; - let (public_key, _) = att - .verify( - challenge, - &self.app_id, - key_id_b64, - &self.apple_root_cert_pem, - ) - .map_err(|_| AppAttestError::Invalid)?; - let key_id = STANDARD - .decode(key_id_b64) - .map_err(|_| AppAttestError::Invalid)?; - if key_id.len() != 32 { - return Err(AppAttestError::Invalid); - } + let verified = proof::enroll( + &cbor, + client_data, + &self.app_id, + key_id_b64, + &self.apple_root_cert_pem, + matches_development(self.environment), + ) + .map_err(verification_error)?; + observe_properties("attestation", verified.flags, &verified.properties); Ok(VerifiedAttestation { - key_id, - public_key: public_key.to_vec(), + key_id: verified.key_id, + public_key: verified.public_key.to_vec(), }) } pub fn verify_assertion( @@ -113,136 +117,71 @@ impl AppAttestVerifier { challenge: &str, stored_challenge: &str, ) -> Result { + if assertion_b64.len() > MAX_ASSERTION_BYTES.div_ceil(3) * 4 { + return Err(AppAttestError::Invalid); + } let cbor = STANDARD .decode(assertion_b64) .map_err(|_| AppAttestError::Invalid)?; if cbor.is_empty() || cbor.len() > MAX_ASSERTION_BYTES { return Err(AppAttestError::Invalid); } - let counter = assertion_counter(&cbor)?; - let client_data_hash = Sha256::digest(client_data); - Assertion::from_assertion(&cbor) - .map_err(|_| AppAttestError::Invalid)? - .verify( - client_data_hash, - challenge, - &self.app_id, - public_key, - previous_counter, - stored_challenge, - ) - .map_err(|_| AppAttestError::Invalid)?; - Ok(VerifiedAssertion { counter }) + if challenge != stored_challenge { + return Err(AppAttestError::Invalid); + } + let verified = proof::assert( + &cbor, + client_data, + &self.app_id, + public_key, + previous_counter, + ) + .map_err(verification_error)?; + observe_properties("assertion", verified.flags, &verified.properties); + Ok(VerifiedAssertion { + counter: verified.counter, + }) } } -// The dependency's development feature accepts both Apple environments. Fence -// the exact signed authData here, then let its full verifier validate those same -// bytes (chain, nonce, app ID, counter, public key and credential ID). -fn verify_attestation_environment( - cbor: &[u8], - environment: AppAttestEnvironment, -) -> Result<(), AppAttestError> { - let mut decoder = minicbor::Decoder::new(cbor); - let count = decoder - .map() - .map_err(|_| AppAttestError::Invalid)? - .ok_or(AppAttestError::Invalid)?; - if count != 3 { - return Err(AppAttestError::Invalid); - } - let mut auth_data = None; - let mut format_seen = false; - let mut statement_seen = false; - for _ in 0..count { - match decoder.str().map_err(|_| AppAttestError::Invalid)? { - "authData" if auth_data.is_none() => { - auth_data = Some(decoder.bytes().map_err(|_| AppAttestError::Invalid)?); - } - "fmt" if !format_seen => { - if decoder.str().map_err(|_| AppAttestError::Invalid)? != "apple-appattest" { - return Err(AppAttestError::Invalid); - } - format_seen = true; - } - "attStmt" if !statement_seen => { - // appattest 0.1.1 treats indefinite containers as empty without - // consuming their contents. Accept only the definite schema so - // its cursor cannot reinterpret nested fields as root fields. - if decoder.map().map_err(|_| AppAttestError::Invalid)? != Some(2) { - return Err(AppAttestError::Invalid); - } - let mut certs_seen = false; - let mut receipt_seen = false; - for _ in 0..2 { - match decoder.str().map_err(|_| AppAttestError::Invalid)? { - "x5c" if !certs_seen => { - let count = decoder - .array() - .map_err(|_| AppAttestError::Invalid)? - .filter(|count| (1..=3).contains(count)) - .ok_or(AppAttestError::Invalid)?; - for _ in 0..count { - decoder.bytes().map_err(|_| AppAttestError::Invalid)?; - } - certs_seen = true; - } - "receipt" if !receipt_seen => { - decoder.bytes().map_err(|_| AppAttestError::Invalid)?; - receipt_seen = true; - } - _ => return Err(AppAttestError::Invalid), - } - } - statement_seen = true; - } - _ => return Err(AppAttestError::Invalid), +fn verification_error(error: error::AppAttestError) -> AppAttestError { + use error::AppAttestError as E; + let reason = match error { + E::ResourceLimit => "capacity", + E::InvalidCbor | E::InvalidFormat | E::AuthenticatorDataTooShort => "structure", + E::InvalidCertificate => "certificate", + E::InvalidNonce | E::InvalidSignature => "signature", + E::InvalidCounter => "counter", + E::InvalidAppID | E::InvalidAAGUID | E::InvalidCredentialID | E::InvalidPublicKey => { + "identity" } - } - if decoder.position() != cbor.len() { - return Err(AppAttestError::Invalid); - } - let expected: &[u8] = match environment { - AppAttestEnvironment::Production => b"appattest\0\0\0\0\0\0\0", - #[cfg(feature = "personal-dev-app-attest")] - AppAttestEnvironment::Development => b"appattestdevelop", }; - if auth_data.and_then(|data| data.get(37..53)) != Some(expected) { - return Err(AppAttestError::Invalid); - } - Ok(()) + tracing::debug!(reason, "App Attest verification rejected"); + AppAttestError::Invalid } -/// App Attest assertion CBOR is a closed two-field map. Extracting signCount -/// from authenticatorData is safe only after the library verifies the same -/// bytes' RP ID, signature, and monotonic relation. -fn assertion_counter(cbor: &[u8]) -> Result { - let mut d = minicbor::Decoder::new(cbor); - let count = d - .map() - .map_err(|_| AppAttestError::Invalid)? - .ok_or(AppAttestError::Invalid)?; - let mut auth = None; - for _ in 0..count { - let k = d.str().map_err(|_| AppAttestError::Invalid)?; - match k { - "authenticatorData" => auth = Some(d.bytes().map_err(|_| AppAttestError::Invalid)?), - "signature" => { - d.bytes().map_err(|_| AppAttestError::Invalid)?; - } - _ => return Err(AppAttestError::Invalid), - } +fn observe_properties( + operation: &'static str, + flags: u8, + properties: &properties::ClientProperties, +) { + tracing::debug!(operation, flags, bundle_version = ?properties.bundle_version, + category_bytes = ?properties.validation_category, opaque_property = properties.opaque_known_property, + unknown_extensions = properties.unknown_extensions, "App Attest authenticated properties"); +} + +fn matches_development(environment: AppAttestEnvironment) -> bool { + match environment { + AppAttestEnvironment::Production => false, + #[cfg(feature = "personal-dev-app-attest")] + AppAttestEnvironment::Development => true, } - let auth = auth - .filter(|a| a.len() == 37) - .ok_or(AppAttestError::Invalid)?; - Ok(BigEndian::read_u32(&auth[33..37])) } #[cfg(test)] mod tests { + use super::error::AppAttestError as ProofError; use super::*; - use appattest::error::AppAttestError as DependencyAppAttestError; use serde::Deserialize; const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json"); @@ -259,14 +198,32 @@ mod tests { app_id: String, challenge: String, aaguid: String, + #[serde(skip)] attestation_b64: String, + #[serde(skip)] key_id_b64: String, root_cert_pem: String, } fn fixture(json: &str) -> Fixture { - let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON"); + let mut fixture: Fixture = + serde_json::from_str(json).expect("valid App Attest fixture JSON"); assert!(!fixture.description.is_empty()); + // Sign a fresh leaf at test time instead of checking in a certificate + // whose one-year validity will eventually break positive tests. + let generated = super::testing::build_test_attestation_with_auth_data( + &fixture.challenge, + &fixture.app_id, + |auth| { + if fixture.aaguid == "appattestdevelop" { + auth[37..53].copy_from_slice(b"appattestdevelop"); + } else { + assert_eq!(fixture.aaguid, "appattest"); + } + }, + ); + fixture.attestation_b64 = STANDARD.encode(generated.cbor); + fixture.key_id_b64 = generated.key_id; fixture } @@ -278,28 +235,32 @@ mod tests { } } - fn verify_dependency( + fn verify_proof( fixture: &Fixture, app_id: &str, challenge: &str, key_id_b64: &str, root_cert_pem: &[u8], - ) -> Result<(), DependencyAppAttestError> { + ) -> Result<(), ProofError> { let cbor = STANDARD .decode(&fixture.attestation_b64) .expect("fixture attestation is base64"); - let attestation = Attestation::from_cbor_bytes(&cbor)?; - let result = attestation - .verify(challenge, app_id, key_id_b64, root_cert_pem) - .map(|_| ()); - result + proof::enroll( + &cbor, + challenge.as_bytes(), + app_id, + key_id_b64, + root_cert_pem, + false, + ) + .map(|_| ()) } #[test] fn strict_verifier_accepts_good_fixture() { let fixture = fixture(GOOD_FIXTURE_JSON); assert_eq!(fixture.aaguid, "appattest"); - verify_dependency( + verify_proof( &fixture, &fixture.app_id, &fixture.challenge, @@ -323,19 +284,19 @@ mod tests { fn wrong_root_is_rejected() { let good = fixture(GOOD_FIXTURE_JSON); let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON); - assert!(verify_dependency( - &wrong_root, - &wrong_root.app_id, - &wrong_root.challenge, - &wrong_root.key_id_b64, - good.root_cert_pem.as_bytes(), + assert!(verify_proof( + &good, + &good.app_id, + &good.challenge, + &good.key_id_b64, + wrong_root.root_cert_pem.as_bytes(), ) .is_err()); - assert!(verifier(&wrong_root.app_id, good.root_cert_pem.as_bytes()) + assert!(verifier(&good.app_id, wrong_root.root_cert_pem.as_bytes()) .verify_attestation( - &wrong_root.attestation_b64, - &wrong_root.key_id_b64, - wrong_root.challenge.as_bytes(), + &good.attestation_b64, + &good.key_id_b64, + good.challenge.as_bytes() ) .is_err()); } @@ -345,14 +306,14 @@ mod tests { let fixture = fixture(GOOD_FIXTURE_JSON); let wrong_app_id = "TEAMID.xyz.buzz.wrong"; assert_eq!( - verify_dependency( + verify_proof( &fixture, wrong_app_id, &fixture.challenge, &fixture.key_id_b64, fixture.root_cert_pem.as_bytes(), ), - Err(DependencyAppAttestError::InvalidAppID) + Err(ProofError::InvalidAppID) ); assert!(verifier(wrong_app_id, fixture.root_cert_pem.as_bytes()) .verify_attestation( @@ -368,14 +329,14 @@ mod tests { let fixture = fixture(GOOD_FIXTURE_JSON); let wrong_challenge = "wrong-challenge"; assert_eq!( - verify_dependency( + verify_proof( &fixture, &fixture.app_id, wrong_challenge, &fixture.key_id_b64, fixture.root_cert_pem.as_bytes(), ), - Err(DependencyAppAttestError::InvalidNonce) + Err(ProofError::InvalidNonce) ); assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) .verify_attestation( @@ -392,14 +353,14 @@ mod tests { assert_eq!(fixture.aaguid, "appattestdevelop"); #[cfg(not(feature = "personal-dev-app-attest"))] assert_eq!( - verify_dependency( + verify_proof( &fixture, &fixture.app_id, &fixture.challenge, &fixture.key_id_b64, fixture.root_cert_pem.as_bytes(), ), - Err(DependencyAppAttestError::InvalidAAGUID) + Err(ProofError::InvalidAAGUID) ); assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) .verify_attestation( @@ -416,19 +377,6 @@ mod tests { let dev = fixture(WRONG_AAGUID_FIXTURE_JSON); let mut v = verifier(&dev.app_id, dev.root_cert_pem.as_bytes()); v.environment = AppAttestEnvironment::Development; - let cbor = STANDARD.decode(&dev.attestation_b64).unwrap(); - assert!( - verify_attestation_environment(&cbor, v.environment).is_ok(), - "environment parser" - ); - verify_dependency( - &dev, - &dev.app_id, - &dev.challenge, - &dev.key_id_b64, - dev.root_cert_pem.as_bytes(), - ) - .expect("development dependency"); assert!(v .verify_attestation( &dev.attestation_b64, @@ -543,16 +491,7 @@ mod tests { .unwrap(); let length = encoder.writer().position(); let malicious = &buffer[..length]; - // Prove this envelope reaches valid signed material in the dependency. - Attestation::from_cbor_bytes(malicious) - .unwrap() - .verify( - &fixture.challenge, - &fixture.app_id, - &fixture.key_id_b64, - fixture.root_cert_pem.as_bytes(), - ) - .unwrap(); + // Reject the ambiguous envelope at the public boundary. let mut verifier = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()); verifier.environment = environment; assert!( @@ -571,9 +510,7 @@ mod tests { #[test] fn environment_parser_rejects_ambiguous_or_truncated_auth_data() { for data in [vec![], vec![0xa0], vec![0xa1, 0x68], vec![0xbf, 0xff]] { - assert!( - verify_attestation_environment(&data, AppAttestEnvironment::Production).is_err() - ); + assert!(proof::enroll(&data, b"challenge", "app", "key", b"root", false).is_err()); } let mut data = [0u8; 256]; let mut encoder = @@ -593,8 +530,7 @@ mod tests { .unwrap(); let length = encoder.writer().position(); assert!( - verify_attestation_environment(&data[..length], AppAttestEnvironment::Production) - .is_err() + proof::enroll(&data[..length], b"challenge", "app", "key", b"root", false).is_err() ); } @@ -602,7 +538,7 @@ mod tests { fn short_and_oversize_key_ids_are_rejected() { let fixture = fixture(GOOD_FIXTURE_JSON); for key_id_b64 in [STANDARD.encode([0x11; 31]), STANDARD.encode([0x22; 33])] { - assert!(verify_dependency( + assert!(verify_proof( &fixture, &fixture.app_id, &fixture.challenge, @@ -638,3 +574,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "app_attest_assertion_tests.rs"] +mod assertion_tests; diff --git a/crates/buzz-push-gateway/src/app_attest/LICENSE b/crates/buzz-push-gateway/src/app_attest/LICENSE new file mode 100644 index 0000000000..dd36d644f8 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Peter Farr +Copyright (c) 2024 Ayodeji Akinola + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/buzz-push-gateway/src/app_attest/authenticator.rs b/crates/buzz-push-gateway/src/app_attest/authenticator.rs new file mode 100644 index 0000000000..7bc4ed060d --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/authenticator.rs @@ -0,0 +1,139 @@ +//! Operation-specific framing. No optional credential state exists on assertions. +use super::{ + cbor::{self, Value}, + error::AppAttestError as Error, + properties::ClientProperties, +}; +use aws_lc_rs::digest::{digest, SHA256}; +use p256::elliptic_curve::sec1::ToEncodedPoint; + +pub(super) struct Checked { + pub counter: u32, + pub flags: u8, + pub properties: ClientProperties, +} + +fn header(bytes: &[u8], app_id: &str) -> Result { + let prefix = bytes.get(..37).ok_or(Error::AuthenticatorDataTooShort)?; + if prefix[..32] != *digest(&SHA256, app_id.as_bytes()).as_ref() { + return Err(Error::InvalidAppID); + } + Ok(Checked { + counter: u32::from_be_bytes( + prefix[33..37] + .try_into() + .map_err(|_| Error::AuthenticatorDataTooShort)?, + ), + flags: prefix[32], + properties: ClientProperties::default(), + }) +} + +fn properties(tail: &[u8]) -> Result { + // Frame by operation and parsed CBOR, not AT/ED flags: Apple's published + // enrollment sample includes extensions with ED clear. + if tail.is_empty() { + return Ok(ClientProperties::default()); + } + ClientProperties::from_extensions(&cbor::complete(tail)?) +} + +pub(super) fn assertion(bytes: &[u8], app_id: &str, previous: u32) -> Result { + let mut checked = header(bytes, app_id)?; + if checked.counter <= previous { + return Err(Error::InvalidCounter); + } + checked.properties = properties(&bytes[37..])?; + Ok(checked) +} + +pub(super) fn enrollment( + bytes: &[u8], + app_id: &str, + key_id: &[u8], + public_key: &[u8; 65], + development: bool, +) -> Result { + let mut checked = header(bytes, app_id)?; + if checked.counter != 0 { + return Err(Error::InvalidCounter); + } + let credential_prefix = bytes.get(..55).ok_or(Error::AuthenticatorDataTooShort)?; + let expected = if development { + b"appattestdevelop" + } else { + b"appattest\0\0\0\0\0\0\0" + }; + if &credential_prefix[37..53] != expected { + return Err(Error::InvalidAAGUID); + } + // App Attest credential IDs are SHA-256 hashes, always 32 bytes. Preserve + // appattest 0.1.1's workaround for reported iOS 26.x nonstandard length + // fields. The original length bytes remain authenticated by the nonce; + // the credential, supplied key ID, COSE key and certificate must agree. + const END: usize = 55 + 32; + let end = END; + if bytes.get(55..end).ok_or(Error::AuthenticatorDataTooShort)? != key_id { + return Err(Error::InvalidCredentialID); + } + let tail = bytes.get(end..).ok_or(Error::AuthenticatorDataTooShort)?; + let (key, consumed) = credential_key(tail)?; + if &key != public_key { + return Err(Error::InvalidPublicKey); + } + checked.properties = properties(&tail[consumed..])?; + Ok(checked) +} + +fn credential_key(tail: &[u8]) -> Result<([u8; 65], usize), Error> { + let (cose, consumed) = cbor::prefix(tail)?; + let map = cose.map()?; + if !map + .keys() + .all(|k| matches!(k, Value::Integer(_) | Value::Text(_))) + { + return Err(Error::InvalidPublicKey); + } + for (label, expected) in [(1, 2), (3, -7), (-1, 1)] { + if map.get(&Value::Integer(label)) != Some(&Value::Integer(expected)) { + return Err(Error::InvalidPublicKey); + } + } + if let Some(operations) = map.get(&Value::Integer(4)) { + match operations { + Value::Array(v) + if v.iter().all(|op| matches!(op, Value::Integer(_))) + && v.contains(&Value::Integer(2)) => {} + _ => return Err(Error::InvalidPublicKey), + } + } + let x = map + .get(&Value::Integer(-2)) + .ok_or(Error::InvalidPublicKey)? + .bytes()?; + if x.len() != 32 { + return Err(Error::InvalidPublicKey); + } + let mut encoded = Vec::with_capacity(65); + match map.get(&Value::Integer(-3)) { + Some(Value::Bytes(y)) if y.len() == 32 => { + encoded.push(4); + encoded.extend_from_slice(x); + encoded.extend_from_slice(y); + } + Some(Value::Simple(parity @ (20 | 21))) => { + encoded.push(if *parity == 21 { 3 } else { 2 }); + encoded.extend_from_slice(x); + } + _ => return Err(Error::InvalidPublicKey), + } + let key = p256::PublicKey::from_sec1_bytes(&encoded).map_err(|_| Error::InvalidPublicKey)?; + + Ok(( + key.to_encoded_point(false) + .as_bytes() + .try_into() + .map_err(|_| Error::InvalidPublicKey)?, + consumed, + )) +} diff --git a/crates/buzz-push-gateway/src/app_attest/cbor.rs b/crates/buzz-push-gateway/src/app_attest/cbor.rs new file mode 100644 index 0000000000..dc4cc974e5 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/cbor.rs @@ -0,0 +1,321 @@ +//! Bounded CBOR receiver profile, built on minicbor's primitive decoder. +//! +//! Map equality follows RFC 8949 section 5.6.1, including compound keys. The +//! ordered representation is for duplicate detection only, never for signing. +use super::error::AppAttestError; +use minicbor::{data::Type, Decoder}; +use std::collections::BTreeMap; + +// Operational ceilings, not Apple protocol constants. Keep these separate from +// transport limits and report capacity failures distinctly. +pub(super) const MAX_BYTES: usize = 64 * 1024; +const MAX_ITEMS: usize = 4096; +const MAX_DEPTH: usize = 32; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Value { + Integer(i128), + Bytes(Vec), + Text(String), + Array(Vec), + Map(BTreeMap), + Tag(u64, Box), + Simple(u8), + Float(u64), + Nan(u64), +} + +impl Value { + pub(super) fn map(&self) -> Result<&BTreeMap, AppAttestError> { + match self { + Self::Map(v) => Ok(v), + _ => Err(AppAttestError::InvalidCbor), + } + } + pub(super) fn bytes(&self) -> Result<&[u8], AppAttestError> { + match self { + Self::Bytes(v) => Ok(v), + _ => Err(AppAttestError::InvalidCbor), + } + } + pub(super) fn field(&self, key: &str) -> Result<&Value, AppAttestError> { + self.map()? + .get(&Value::Text(key.into())) + .ok_or(AppAttestError::InvalidCbor) + } + pub(super) fn text_keys(&self) -> Result<(), AppAttestError> { + if self.map()?.keys().all(|k| matches!(k, Self::Text(_))) { + Ok(()) + } else { + Err(AppAttestError::InvalidCbor) + } + } +} + +pub(super) fn complete(bytes: &[u8]) -> Result { + let (value, consumed) = prefix(bytes)?; + if consumed != bytes.len() { + return Err(AppAttestError::InvalidCbor); + } + Ok(value) +} + +pub(super) fn prefix(bytes: &[u8]) -> Result<(Value, usize), AppAttestError> { + if bytes.len() > MAX_BYTES { + return Err(AppAttestError::ResourceLimit); + } + let mut reader = Reader { + decoder: Decoder::new(bytes), + items: 0, + copied: 0, + }; + let value = reader.value(0)?; + Ok((value, reader.decoder.position())) +} + +struct Reader<'a> { + decoder: Decoder<'a>, + items: usize, + copied: usize, +} +impl Reader<'_> { + fn item(&mut self) -> Result<(), AppAttestError> { + self.items += 1; + if self.items > MAX_ITEMS { + return Err(AppAttestError::ResourceLimit); + } + Ok(()) + } + fn copy(&mut self, size: usize) -> Result<(), AppAttestError> { + self.copied = self + .copied + .checked_add(size) + .ok_or(AppAttestError::ResourceLimit)?; + if self.copied > MAX_BYTES { + return Err(AppAttestError::ResourceLimit); + } + Ok(()) + } + fn end(&mut self, length: Option, seen: u64) -> Result { + if let Some(length) = length { + return Ok(seen == length); + } + if self.decoder.datatype()? == Type::Break { + self.decoder.skip()?; + return Ok(true); + } + Ok(false) + } + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(AppAttestError::ResourceLimit); + } + self.item()?; + match self.decoder.datatype()? { + Type::U8 + | Type::U16 + | Type::U32 + | Type::U64 + | Type::I8 + | Type::I16 + | Type::I32 + | Type::I64 + | Type::Int => Ok(Value::Integer(self.decoder.int()?.into())), + Type::Bytes | Type::BytesIndef => { + let mut out = Vec::new(); + // Each chunk is at most the bounded input size. No allocation + // is based solely on an untrusted declared length. + let chunks = self.decoder.bytes_iter()?; + for chunk in chunks { + let chunk = chunk?; + if out.len() + chunk.len() > MAX_BYTES { + return Err(AppAttestError::ResourceLimit); + } + out.extend_from_slice(chunk); + } + self.copy(out.len())?; + Ok(Value::Bytes(out)) + } + Type::String | Type::StringIndef => { + let mut out = String::new(); + for chunk in self.decoder.str_iter()? { + let chunk = chunk?; + if out.len() + chunk.len() > MAX_BYTES { + return Err(AppAttestError::ResourceLimit); + } + out.push_str(chunk); + } + self.copy(out.len())?; + Ok(Value::Text(out)) + } + Type::Array | Type::ArrayIndef => { + let length = self.decoder.array()?; + let mut out = Vec::new(); + while !self.end(length, out.len() as u64)? { + out.push(self.value(depth + 1)?); + } + Ok(Value::Array(out)) + } + Type::Map | Type::MapIndef => { + let length = self.decoder.map()?; + let mut out = BTreeMap::new(); + while !self.end(length, out.len() as u64)? { + let key = self.value(depth + 1)?; + let value = self.value(depth + 1)?; + if out.insert(key, value).is_some() { + return Err(AppAttestError::InvalidCbor); + } + } + Ok(Value::Map(out)) + } + Type::Tag => { + let tag = self.decoder.tag()?.as_u64(); + Ok(Value::Tag(tag, Box::new(self.value(depth + 1)?))) + } + Type::Bool => Ok(Value::Simple(if self.decoder.bool()? { 21 } else { 20 })), + Type::Null => { + self.decoder.null()?; + Ok(Value::Simple(22)) + } + Type::Undefined => { + self.decoder.undefined()?; + Ok(Value::Simple(23)) + } + Type::Simple => { + let position = self.decoder.position(); + let value = self.decoder.simple()?; + // RFC 8949 3.3: f8 followed by a value below 32 is not well formed. + if self.decoder.position() - position == 2 && value < 32 { + return Err(AppAttestError::InvalidCbor); + } + Ok(Value::Simple(value)) + } + Type::F16 | Type::F32 | Type::F64 => { + let position = self.decoder.position(); + let number = self.decoder.f64()?; + if number.is_nan() { + // Preserve signaling/payload bits before a floating-point + // conversion can quiet a NaN. The sign is not significand. + let raw = &self.decoder.input()[position + 1..self.decoder.position()]; + let bits = raw.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b)); + let width = match raw.len() { + 2 => 10, + 4 => 23, + 8 => 52, + _ => return Err(AppAttestError::InvalidCbor), + }; + Ok(Value::Nan((bits & ((1u64 << width) - 1)) << (64 - width))) + } else { + Ok(Value::Float(if number == 0.0 { + 0 + } else { + number.to_bits() + })) + } + } + _ => Err(AppAttestError::InvalidCbor), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn duplicate_keys_use_generic_cbor_equivalence() { + for bytes in [ + &b"\xa2\x01\x00\x18\x01\x01"[..], + &b"\xa2\x81\x01\x00\x81\x18\x01\x01"[..], + &b"\xa2\xa2\x01\x02\x03\x04\x00\xa2\x03\x04\x01\x02\x01"[..], + &b"\xa2\xf9\x00\x00\x00\xf9\x80\x00\x01"[..], + &b"\xa2\xf9\x7e\x00\x00\xfa\x7f\xc0\x00\x00\x01"[..], + &b"\xa2\x61x\x00\x7f\x61x\xff\x01"[..], + ] { + assert!(complete(bytes).is_err(), "{bytes:?}"); + } + assert!(complete(b"\xa2\x01\x00\xf9\x3c\x00\x01").is_ok()); + } + #[test] + fn bounded_opaque_values_and_nonpreferred_encodings() { + assert!(complete(b"\xbf\x61x\x9f\xc1\xf8\xff\x18\x01\xff\xff").is_ok()); + for bytes in [ + &b"\xff"[..], + &b"\xa0\x00"[..], + &b"\xf8\x01"[..], + &b"\x61\xff"[..], + &b"\xbf\x01\xff"[..], + ] { + assert!(complete(bytes).is_err()); + } + let mut deep = vec![0x81; MAX_DEPTH + 2]; + deep.push(0); + assert_eq!(complete(&deep), Err(AppAttestError::ResourceLimit)); + } + + #[test] + fn capacity_boundaries_and_untrusted_lengths() { + let mut at_depth = vec![0x81; MAX_DEPTH]; + at_depth.push(0); + assert!(complete(&at_depth).is_ok()); + let mut at_items = vec![0x9f]; + at_items.extend(std::iter::repeat_n(0, MAX_ITEMS - 1)); + at_items.push(0xff); + assert!(complete(&at_items).is_ok()); + at_items.insert(1, 0); + assert_eq!(complete(&at_items), Err(AppAttestError::ResourceLimit)); + let mut at_bytes = vec![0x59, 0xff, 0xfd]; + at_bytes.resize(MAX_BYTES, 0); + assert!(complete(&at_bytes).is_ok()); + at_bytes.push(0); + assert_eq!(complete(&at_bytes), Err(AppAttestError::ResourceLimit)); + for prefix in [0x5b, 0x7b, 0x9b, 0xbb] { + let mut huge_length = vec![prefix]; + huge_length.extend_from_slice(&u64::MAX.to_be_bytes()); + assert!(complete(&huge_length).is_err()); + } + } + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(512))] + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::(), 0..=MAX_BYTES)) { + let _ = complete(&bytes); + } + } + + #[test] + #[ignore = "manual resource characterization, not a timing-sensitive CI gate"] + fn characterize_capacity_work() { + use std::{hint::black_box, time::Instant}; + let mut at_items = vec![0x9f]; + at_items.extend(std::iter::repeat_n(0, MAX_ITEMS - 1)); + at_items.push(0xff); + let mut at_bytes = vec![0x59, 0xff, 0xfd]; + at_bytes.resize(MAX_BYTES, 0); + let mut at_depth = vec![0x81; MAX_DEPTH]; + at_depth.push(0); + let mut map = minicbor::Encoder::new(Vec::new()); + map.map(((MAX_ITEMS - 1) / 2) as u64).unwrap(); + for index in 0..(MAX_ITEMS - 1) / 2 { + map.str(&format!("shared-prefix-{index:08}")) + .unwrap() + .u8(0) + .unwrap(); + } + for (name, bytes) in [ + ("map", map.into_writer()), + ("items", at_items), + ("bytes", at_bytes), + ("depth", at_depth), + ] { + let start = Instant::now(); + for _ in 0..1000 { + black_box(complete(black_box(&bytes)).unwrap()); + } + eprintln!( + "{name}: {} input bytes, {} ns/parse (1000 iterations)", + bytes.len(), + start.elapsed().as_nanos() / 1000 + ); + } + } +} diff --git a/crates/buzz-push-gateway/src/app_attest/certificate.rs b/crates/buzz-push-gateway/src/app_attest/certificate.rs new file mode 100644 index 0000000000..4d60c41274 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/certificate.rs @@ -0,0 +1,118 @@ +//! Apple certificate adapters; path and signature validation remain in webpki. +use super::error::AppAttestError as Error; +use aws_lc_rs::digest::{digest, SHA256}; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use rustls_pki_types::{CertificateDer, UnixTime}; +use x509_parser::{certificate::X509Certificate, parse_x509_certificate}; + +static SIGNATURE_ALGORITHMS: &[&dyn rustls_pki_types::SignatureVerificationAlgorithm] = &[ + webpki::aws_lc_rs::ECDSA_P256_SHA256, + webpki::aws_lc_rs::ECDSA_P256_SHA384, + webpki::aws_lc_rs::ECDSA_P384_SHA256, + webpki::aws_lc_rs::ECDSA_P384_SHA384, +]; + +// App Attest is not TLS. Preserve the supported Apple purpose profile while +// consuming and validating every OID; do not skip malformed EKU input. +struct AppleUsage; +impl webpki::ExtendedKeyUsageValidator for AppleUsage { + fn validate(&self, iter: webpki::KeyPurposeIdIter<'_, '_>) -> Result<(), webpki::Error> { + for oid in iter { + oid?; + } + Ok(()) + } +} + +fn parsed(bytes: &[u8]) -> Result, Error> { + let (rest, cert) = parse_x509_certificate(bytes).map_err(|_| Error::InvalidCertificate)?; + if !rest.is_empty() { + return Err(Error::InvalidCertificate); + } + // RFC 5280 requires each extension at most once, including unknown OIDs. + cert.extensions_map() + .map_err(|_| Error::InvalidCertificate)?; + Ok(cert) +} + +pub(super) fn verify_chain(certificates: &[Vec], root: &[u8]) -> Result<(), Error> { + let leaf = certificates.first().ok_or(Error::InvalidCertificate)?; + let root = CertificateDer::from(root); + let anchor = webpki::anchor_from_trusted_cert(&root).map_err(|_| Error::InvalidCertificate)?; + let leaf_der = CertificateDer::from(leaf.as_slice()); + let end = webpki::EndEntityCert::try_from(&leaf_der).map_err(|_| Error::InvalidCertificate)?; + let intermediates: Vec<_> = certificates[1..] + .iter() + .map(|c| CertificateDer::from(c.as_slice())) + .collect(); + end.verify_for_usage( + SIGNATURE_ALGORITHMS, + &[anchor], + &intermediates, + UnixTime::now(), + AppleUsage, + None, + None, + ) + .map_err(|_| Error::InvalidCertificate)?; + for (index, bytes) in certificates.iter().enumerate() { + let cert = parsed(bytes)?; + if let Some(usage) = cert.key_usage().map_err(|_| Error::InvalidCertificate)? { + if (index == 0 && !usage.value.digital_signature()) + || (index > 0 && !usage.value.key_cert_sign()) + { + return Err(Error::InvalidCertificate); + } + } + } + Ok(()) +} + +pub(super) fn public_key(bytes: &[u8]) -> Result<[u8; 65], Error> { + let cert = parsed(bytes)?; + let spki = cert.public_key(); + if spki.algorithm.algorithm.to_id_string() != "1.2.840.10045.2.1" + || spki + .algorithm + .parameters + .as_ref() + .and_then(|p| p.as_oid().ok()) + .map(|p| p.to_id_string()) + .as_deref() + != Some("1.2.840.10045.3.1.7") + || spki.subject_public_key.unused_bits != 0 + { + return Err(Error::InvalidPublicKey); + } + let key = p256::PublicKey::from_sec1_bytes(spki.subject_public_key.data.as_ref()) + .map_err(|_| Error::InvalidPublicKey)?; + key.to_encoded_point(false) + .as_bytes() + .try_into() + .map_err(|_| Error::InvalidPublicKey) +} + +pub(super) fn verify_nonce(bytes: &[u8], expected: &[u8]) -> Result<(), Error> { + let cert = parsed(bytes)?; + let extension = cert + .extensions() + .iter() + .find(|e| e.oid.to_id_string() == "1.2.840.113635.100.8.2") + .ok_or(Error::InvalidNonce)?; + // DER has exactly one encoding for SEQUENCE { [1] EXPLICIT OCTET STRING + // (SIZE(32)) }. Comparing that complete encoding avoids a custom DER walker + // and rejects extra fields/trailing bytes/noncanonical lengths. + let mut encoded = vec![0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]; + if expected.len() != 32 { + return Err(Error::InvalidNonce); + } + encoded.extend_from_slice(expected); + if extension.value != encoded { + return Err(Error::InvalidNonce); + } + Ok(()) +} + +pub(super) fn key_id(key: &[u8]) -> Vec { + digest(&SHA256, key).as_ref().to_vec() +} diff --git a/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs b/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs new file mode 100644 index 0000000000..aa90d9f64d --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs @@ -0,0 +1,189 @@ +//! Signed enrollment fixtures exercise structure independently of path trust. +use super::testing::{build_test_attestation_with_auth_data, TEST_ROOT_CA_CERT_PEM}; +use super::{error::AppAttestError as Error, proof}; +use base64::{engine::general_purpose::STANDARD, Engine}; + +const APP: &str = "TEAMID.test.compatibility"; +const TRANSCRIPT: &str = "test enrollment transcript"; + +fn verify(change: impl FnOnce(&mut Vec)) -> Result<(), Error> { + let fixture = build_test_attestation_with_auth_data(TRANSCRIPT, APP, change); + enroll(&fixture.cbor, &fixture.key_id).map(|_| ()) +} + +fn enroll(bytes: &[u8], key_id: &str) -> Result { + proof::enroll( + bytes, + TRANSCRIPT.as_bytes(), + APP, + key_id, + TEST_ROOT_CA_CERT_PEM, + false, + ) +} + +#[test] +fn signed_enrollment_accepts_optional_extensions_and_cose_variants() { + for tail in [ + &b""[..], + &b"\xa0"[..], + &b"\xa1\x66future\x82\xc1\xf8\xff\x01"[..], + ] { + verify(|auth| auth.extend_from_slice(tail)).unwrap(); + } + // COSE's x coordinate remains the same; y may use its parity form. + verify(|auth| { + let mut key: ciborium::Value = ciborium::from_reader(&auth[87..]).unwrap(); + if let ciborium::Value::Map(fields) = &mut key { + for (label, value) in fields.iter_mut() { + if label == &ciborium::Value::Integer((-3).into()) { + let ciborium::Value::Bytes(y) = value else { + panic!("fixture y") + }; + *value = ciborium::Value::Bool(y[31] & 1 == 1); + } + } + fields.reverse(); + } + auth.truncate(87); + ciborium::into_writer(&key, auth).unwrap(); + }) + .unwrap(); +} + +#[test] +fn signed_enrollment_preserves_fixed_hash_credential_framing() { + // Upstream reports nonstandard length fields on iOS 26.x. These are + // synthetic compatibility vectors, not captures from that OS version. + for length in [0_u16, 31, 32, 33, 0x89f4, u16::MAX] { + verify(|auth| auth[53..55].copy_from_slice(&length.to_be_bytes())).unwrap(); + assert!(verify(|auth| { + auth[53..55].copy_from_slice(&length.to_be_bytes()); + auth[55] ^= 1; + }) + .is_err()); + } + // Actual missing/extra credential bytes must not shift the COSE boundary. + assert!(verify(|auth| { + auth.remove(55); + }) + .is_err()); + assert!(verify(|auth| auth.insert(55, 0)).is_err()); +} + +#[test] +fn signed_enrollment_rejects_incomplete_or_inconsistent_credential_data() { + assert!(verify(|auth| auth.truncate(87)).is_err()); // Old fixture omitted COSE. + assert!(verify(|auth| auth[55] ^= 1).is_err()); + assert!(verify(|auth| auth[36] = 1).is_err()); + assert!(verify(|auth| auth.extend_from_slice(b"\xa0\x00")).is_err()); + assert!(verify(|auth| auth.extend_from_slice(b"\xa2\x61x\x01\x61x\x02")).is_err()); + assert!(verify(|auth| { + // Valid key with the wrong algorithm label remains invalid even signed. + let mut key: ciborium::Value = ciborium::from_reader(&auth[87..]).unwrap(); + if let ciborium::Value::Map(fields) = &mut key { + for (label, value) in fields { + if label == &ciborium::Value::Integer(3.into()) { + *value = ciborium::Value::Integer((-8).into()); + } + } + } + auth.truncate(87); + ciborium::into_writer(&key, auth).unwrap(); + }) + .is_err()); +} + +#[test] +fn certificate_nonce_authenticates_every_extension_byte() { + let fixture = build_test_attestation_with_auth_data(TRANSCRIPT, APP, |a| { + a.extend_from_slice(b"\xa1\x61x\x01") + }); + // Even the compatibility length field must remain nonce-authenticated. + for offset in [None, Some(53), Some(54)] { + let mut envelope: ciborium::Value = ciborium::from_reader(fixture.cbor.as_slice()).unwrap(); + if let ciborium::Value::Map(fields) = &mut envelope { + for (key, value) in fields { + if key == &ciborium::Value::Text("authData".into()) { + let ciborium::Value::Bytes(auth) = value else { + panic!("authData") + }; + let index = offset.unwrap_or(auth.len() - 1); + auth[index] ^= 1; + } + } + } + let mut changed = Vec::new(); + ciborium::into_writer(&envelope, &mut changed).unwrap(); + assert!(matches!( + enroll(&changed, &fixture.key_id), + Err(Error::InvalidNonce) + )); + } + assert!(enroll(&fixture.cbor, &STANDARD.encode([0; 32])).is_err()); +} + +#[test] +fn shared_signed_certificate_constraint_corpus() { + super::testing::compare_certificate_constraints( + super::certificate::verify_chain, + super::certificate::verify_nonce, + ); +} + +#[test] +fn enrolled_key_verifies_following_assertion() { + use super::testing::{build_test_assertion, build_test_attestation}; + use sha2::{Digest, Sha256}; + let fixture = build_test_attestation(TRANSCRIPT, APP); + let enrolled = enroll(&fixture.cbor, &fixture.key_id).unwrap(); + let hash = Sha256::digest(TRANSCRIPT.as_bytes()); + let assertion = build_test_assertion(APP, hash, 0, &fixture.device_key); + let checked = proof::assert( + &assertion, + TRANSCRIPT.as_bytes(), + APP, + &enrolled.public_key, + 0, + ) + .unwrap(); + assert_eq!(checked.counter, 1); + assert!(proof::assert( + &assertion, + TRANSCRIPT.as_bytes(), + APP, + &enrolled.public_key, + 1 + ) + .is_err()); +} + +#[test] +fn apple_historical_fixture_has_structural_extensions_with_ed_clear() { + // Apple's public example is historical, not evidence of current chain validity. + // https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide + let bytes = STANDARD + .decode(include_str!("../../tests/fixtures/apple-structural-attestation.b64").trim()) + .unwrap(); + let envelope = super::cbor::complete(&bytes).unwrap(); + let auth = envelope.field("authData").unwrap().bytes().unwrap(); + assert_eq!(auth.len(), 226); + assert_eq!(auth[32], 0x40); + let super::cbor::Value::Array(chain) = envelope.field("attStmt").unwrap().field("x5c").unwrap() + else { + panic!("certificate chain") + }; + let leaf = chain[0].bytes().unwrap(); + let public_key = super::certificate::public_key(leaf).unwrap(); + let key_id = super::certificate::key_id(&public_key); + let parsed = super::authenticator::enrollment( + auth, + "1234567890.com.example.myapp", + &key_id, + &public_key, + false, + ) + .unwrap(); + assert_eq!(parsed.counter, 0); + assert_eq!(parsed.flags, 0x40); +} diff --git a/crates/buzz-push-gateway/src/app_attest/error.rs b/crates/buzz-push-gateway/src/app_attest/error.rs new file mode 100644 index 0000000000..5b41aed50c --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/error.rs @@ -0,0 +1,51 @@ +use std::{error::Error, fmt}; + +#[derive(Debug, PartialEq)] +pub enum AppAttestError { + /// Certificate structure or trust validation failed. + InvalidCertificate, + /// CBOR is malformed, ambiguous, or does not match the operation schema. + InvalidCbor, + /// An operational parsing budget was exceeded. + ResourceLimit, + InvalidNonce, + InvalidPublicKey, + InvalidCounter, + InvalidCredentialID, + InvalidAAGUID, + InvalidSignature, + InvalidAppID, + InvalidFormat, + AuthenticatorDataTooShort, +} + +impl fmt::Display for AppAttestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidCertificate => write!(f, "invalid certificate path or structure"), + Self::InvalidCbor => write!(f, "invalid CBOR structure"), + Self::ResourceLimit => write!(f, "verification resource limit"), + AppAttestError::InvalidNonce => write!(f, "invalid nonce"), + AppAttestError::InvalidPublicKey => write!(f, "invalid public key"), + AppAttestError::InvalidCounter => write!(f, "invalid counter"), + AppAttestError::InvalidCredentialID => write!(f, "invalid credential ID"), + AppAttestError::InvalidAAGUID => write!(f, "invalid AAGUID"), + AppAttestError::InvalidSignature => write!(f, "invalid signature"), + AppAttestError::InvalidAppID => write!(f, "invalid App ID"), + AppAttestError::InvalidFormat => { + write!(f, "invalid attestation format (expected apple-appattest)") + } + AppAttestError::AuthenticatorDataTooShort => { + write!(f, "authenticator data is too short") + } + } + } +} + +impl Error for AppAttestError {} + +impl From for AppAttestError { + fn from(_: minicbor::decode::Error) -> Self { + Self::InvalidCbor + } +} diff --git a/crates/buzz-push-gateway/src/app_attest/proof.rs b/crates/buzz-push-gateway/src/app_attest/proof.rs new file mode 100644 index 0000000000..42a629aced --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/proof.rs @@ -0,0 +1,110 @@ +//! Buzz's two App Attest operations, using established signature and path validators. +use super::{ + authenticator, + cbor::{self, Value}, + certificate, + error::AppAttestError as Error, + properties::ClientProperties, +}; +use aws_lc_rs::{ + digest::{digest, Context, SHA256}, + signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; + +pub(super) struct Enrollment { + pub key_id: Vec, + pub public_key: [u8; 65], + pub flags: u8, + pub properties: ClientProperties, +} + +pub(super) struct Assertion { + pub counter: u32, + pub flags: u8, + pub properties: ClientProperties, +} + +// Both operations authenticate precisely the received bytes with this nonce. +fn nonce(auth: &[u8], transcript: &[u8]) -> aws_lc_rs::digest::Digest { + let mut hash = Context::new(&SHA256); + hash.update(auth); + hash.update(digest(&SHA256, transcript).as_ref()); + hash.finish() +} + +pub(super) fn enroll( + bytes: &[u8], + transcript: &[u8], + app_id: &str, + key_id: &str, + root_pem: &[u8], + development: bool, +) -> Result { + let envelope = cbor::complete(bytes)?; + envelope.text_keys()?; + if envelope.field("fmt")? != &Value::Text("apple-appattest".into()) { + return Err(Error::InvalidFormat); + } + let statement = envelope.field("attStmt")?; + statement.text_keys()?; + let Value::Array(chain) = statement.field("x5c")? else { + return Err(Error::InvalidCertificate); + }; + let certificates = chain + .iter() + .map(|c| c.bytes().map(|b| b.to_vec())) + .collect::, _>>()?; + let leaf = certificates.first().ok_or(Error::InvalidCertificate)?; + statement.field("receipt")?.bytes()?; + let auth_bytes = envelope.field("authData")?.bytes()?; + if root_pem.len() > cbor::MAX_BYTES { + return Err(Error::ResourceLimit); + } + let (rest, root) = + x509_parser::pem::parse_x509_pem(root_pem).map_err(|_| Error::InvalidCertificate)?; + if root.label != "CERTIFICATE" || !rest.iter().all(u8::is_ascii_whitespace) { + return Err(Error::InvalidCertificate); + } + certificate::verify_chain(&certificates, &root.contents)?; + let public_key = certificate::public_key(leaf)?; + if key_id.len() > 44 { + return Err(Error::InvalidPublicKey); + } + let key_id = STANDARD + .decode(key_id) + .map_err(|_| Error::InvalidPublicKey)?; + if key_id.len() != 32 || certificate::key_id(&public_key) != key_id { + return Err(Error::InvalidPublicKey); + } + certificate::verify_nonce(leaf, nonce(auth_bytes, transcript).as_ref())?; + let auth = authenticator::enrollment(auth_bytes, app_id, &key_id, &public_key, development)?; + Ok(Enrollment { + key_id, + public_key, + flags: auth.flags, + properties: auth.properties, + }) +} + +pub(super) fn assert( + bytes: &[u8], + transcript: &[u8], + app_id: &str, + public_key: &[u8], + previous_counter: u32, +) -> Result { + let envelope = cbor::complete(bytes)?; + envelope.text_keys()?; + let auth_bytes = envelope.field("authenticatorData")?.bytes()?; + let signature = envelope.field("signature")?.bytes()?; + UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, public_key) + .verify(nonce(auth_bytes, transcript).as_ref(), signature) + .map_err(|_| Error::InvalidSignature)?; + let auth = authenticator::assertion(auth_bytes, app_id, previous_counter)?; + Ok(Assertion { + counter: auth.counter, + flags: auth.flags, + properties: auth.properties, + }) +} diff --git a/crates/buzz-push-gateway/src/app_attest/properties.rs b/crates/buzz-push-gateway/src/app_attest/properties.rs new file mode 100644 index 0000000000..5dcef04530 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/properties.rs @@ -0,0 +1,39 @@ +//! Authenticated optional Apple properties. These never authorize a request. +use super::{cbor::Value, error::AppAttestError}; + +/// Bounded observations, without coercing unsupported optional representations. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ClientProperties { + /// Text version when representable within the diagnostic length budget. + pub bundle_version: Option, + /// Raw four-byte category. The profile does not assume undocumented endian. + pub validation_category: Option<[u8; 4]>, + /// A known property was present with an unsupported type or diagnostic size. + pub opaque_known_property: bool, + /// Unknown identifiers ignored after structural and cryptographic checks. + pub unknown_extensions: usize, +} +impl ClientProperties { + pub(super) fn from_extensions(value: &Value) -> Result { + value.text_keys()?; + let mut out = Self::default(); + for (key, value) in value.map()? { + match key { + Value::Text(name) if name == "apple_bundle_version_01" => match value { + Value::Text(version) if version.len() <= 128 => { + out.bundle_version = Some(version.clone()) + } + _ => out.opaque_known_property = true, + }, + Value::Text(name) if name == "apple_validation_category_01" => match value { + Value::Bytes(category) if category.len() == 4 => { + out.validation_category = category.as_slice().try_into().ok() + } + _ => out.opaque_known_property = true, + }, + _ => out.unknown_extensions += 1, + } + } + Ok(out) + } +} diff --git a/crates/buzz-push-gateway/src/app_attest/testing.rs b/crates/buzz-push-gateway/src/app_attest/testing.rs new file mode 100644 index 0000000000..22eacaafdf --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest/testing.rs @@ -0,0 +1,394 @@ +//! Synthetic signed fixtures. Adapted from appattest 0.1.1; see LICENSE. +use aws_lc_rs::digest::{digest, Context as DigestContext, SHA256}; +use base64::{engine::general_purpose, Engine}; +use ciborium::{cbor, Value}; +use openssl::{ + asn1::{Asn1Integer, Asn1Object, Asn1OctetString, Asn1Time}, + bn::BigNum, + ec::{EcGroup, EcKey, PointConversionForm}, + hash::{hash, MessageDigest}, + nid::Nid, + pkey::PKey, + rand::rand_bytes, + x509::{ + extension::{BasicConstraints, ExtendedKeyUsage}, + X509Builder, X509NameBuilder, X509, + }, +}; +use p256::ecdsa::{signature::Signer, DerSignature, SigningKey}; + +/// PEM bytes of the test root CA certificate (P-384). +/// Pass this to `verify_with_cert` / `app_id_verifies_with_cert` in tests. +pub const TEST_ROOT_CA_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\ +MIICNzCCAb6gAwIBAgIUFeZt+1JiuWUm7RJtzNA3etiyaTgwCgYIKoZIzj0EAwIw\n\ +SzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\n\ +CgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\n\ +NjAyMjIxODU2MTBaMEsxJTAjBgNVBAMMHFRlc3QgQXBwIEF0dGVzdGF0aW9uIFJv\n\ +b3QgQ0ExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcq\n\ +hkjOPQIBBgUrgQQAIgNiAAT2cBxT0pWtGUECFzUn+Hdx6MaDyVoOHFcjsd+wqlKZ\n\ +IKNg+bopRItVz79g5Rn3F7126h7Q6ZEosNMCMiV2u1t47NBVGAvs0FtM/gqN2hZv\n\ +4vy3F2l3Vwd2TatWzpRC0iKjYzBhMB0GA1UdDgQWBBR9vyJAdCH/3azKhgGa9JSh\n\ +5LVM+DAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAPBgNVHRMBAf8E\n\ +BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNnADBkAjBi8yL62XLW\n\ +g604KgEaEn9IFxR2c4vx+K0ZhYjPq6OU8X4pq9757JityNdJW1THgxgCMAo9bL44\n\ +UUoFJPTgvmKobdPOIUQ1oRNRYnGmtfovWa31WM2IK0mOs+lpWYncFZOr3Q==\n\ +-----END CERTIFICATE-----\n"; + +const TEST_INT_CA_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\ +MIICHDCCAaGgAwIBAgIUYM1hx4yf7fLmLiIQ57Ov3d5h27cwCgYIKoZIzj0EAwIw\n\ +SzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\n\ +CgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\n\ +NjAyMjIxODU2MTBaMEgxIjAgBgNVBAMMGVRlc3QgQXBwIEF0dGVzdGF0aW9uIENB\n\ +IDExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjO\n\ +PQIBBggqhkjOPQMBBwNCAARXQJU3qZo9zCz2xZpq0ZyFfYN+WpG6uEdt++GKRcaj\n\ +jmJiMYBpxOBtrecCgvPlrZdS7UlHhmUg2Zdfnvu7+DEIo2YwZDASBgNVHRMBAf8E\n\ +CDAGAQH/AgEAMB0GA1UdDgQWBBQzJ5DNIxBnjG4jq4wZ0ZgB+4SYsDAfBgNVHSME\n\ +GDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZI\n\ +zj0EAwIDaQAwZgIxAJ2KT9ZsXtEtvemPNnp5FsyM1WEli1qztnjhjDV5qHcCFydq\n\ +g8rl2BjX1qwXyl9odQIxAMXiN2QWgtbC7VqNUjZFevnwVPwXgkRr+g6mqKjWVTTf\n\ +rGDANtkIDXqrnt8GyCZQWw==\n\ +-----END CERTIFICATE-----\n"; + +// Published upstream synthetic CA key; the gateway pins Apple's root and never trusts this CA. +const TEST_INT_CA_KEY_PEM: &[u8] = include_bytes!("../../tests/fixtures/appattest-test-ca-key.pem"); + +/// Output of [`build_test_attestation`]. +pub struct TestAttestation { + /// CBOR-encoded attestation bytes. Base64-encode and pass to + /// `Attestation::from_base64`, or decode directly via + /// `Attestation::from_cbor` if exposed. + pub cbor: Vec, + /// Base64-encoded key ID (standard encoding). Pass to `verify` / + /// `app_id_verifies`. + pub key_id: String, + /// The device's P-256 private key. Keep this to sign assertions in + /// subsequent calls. + pub device_key: SigningKey, +} + +/// Build a fully valid synthetic attestation signed by the embedded test CA. +/// +/// The returned [`TestAttestation`] will pass +/// `Attestation::app_id_verifies(challenge, &[app_id], &key_id, TEST_ROOT_CA_CERT_PEM)`. +pub fn build_test_attestation(challenge: &str, app_id: &str) -> TestAttestation { + build_test_attestation_with_auth_data(challenge, app_id, |_| {}) +} + +/// Build a signed fixture after deliberately varying authenticator fields. +/// This permits independent positive and negative structural test cases. +pub fn build_test_attestation_with_auth_data( + challenge: &str, + app_id: &str, + change: impl FnOnce(&mut Vec), +) -> TestAttestation { + // Generate a P-256 device keypair via openssl, then import the scalar into + // p256 for signing. + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let openssl_key = EcKey::generate(&group).unwrap(); + + let scalar_bytes = openssl_key.private_key().to_vec(); + // p256 expects a 32-byte scalar; left-pad if shorter. + let mut scalar32 = [0u8; 32]; + let off = 32 - scalar_bytes.len(); + scalar32[off..].copy_from_slice(&scalar_bytes); + let signing_key = SigningKey::from_bytes(&scalar32.into()).unwrap(); + + let device_pkey = PKey::from_ec_key(openssl_key.clone()).unwrap(); + + // key_id = base64(SHA256(uncompressed public key bytes)) + let mut ctx = openssl::bn::BigNumContext::new().unwrap(); + let pub_key_bytes = openssl_key + .public_key() + .to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut ctx) + .unwrap(); + let pub_key_hash = hash(MessageDigest::sha256(), &pub_key_bytes).unwrap(); + let key_id = general_purpose::STANDARD.encode(pub_key_hash.as_ref()); + let key_id_bytes = pub_key_hash.to_vec(); + + // authenticator data layout: + // [0..32] rp_id_hash = SHA256(app_id) + // [32] flags = 0x41 (attested credential data included) + // [33..37] counter = 0u32 big-endian + // [37..53] aaguid = b"appattest\0\0\0\0\0\0\0" (16 bytes) + // [53..55] cred_id_len = 32u16 big-endian + // [55..87] cred_id = key_id raw bytes + let rp_id_hash: [u8; 32] = digest(&SHA256, app_id.as_bytes()) + .as_ref() + .try_into() + .unwrap(); + let mut aaguid = [0u8; 16]; + aaguid[..9].copy_from_slice(b"appattest"); + + let mut auth_data = Vec::with_capacity(87); + auth_data.extend_from_slice(&rp_id_hash); + auth_data.push(0x41); + auth_data.extend_from_slice(&[0u8; 4]); + auth_data.extend_from_slice(&aaguid); + let mut len_bytes = [0u8; 2]; + len_bytes.copy_from_slice(&(key_id_bytes.len() as u16).to_be_bytes()); + auth_data.extend_from_slice(&len_bytes); + auth_data.extend_from_slice(&key_id_bytes); + let cose = Value::Map(vec![ + (Value::Integer(1.into()), Value::Integer(2.into())), + (Value::Integer(3.into()), Value::Integer((-7).into())), + (Value::Integer((-1).into()), Value::Integer(1.into())), + ( + Value::Integer((-2).into()), + Value::Bytes(pub_key_bytes[1..33].to_vec()), + ), + ( + Value::Integer((-3).into()), + Value::Bytes(pub_key_bytes[33..65].to_vec()), + ), + ]); + ciborium::into_writer(&cose, &mut auth_data).unwrap(); + change(&mut auth_data); + + // nonce = SHA256(authData || SHA256(challenge)) + let client_data_hash: [u8; 32] = digest(&SHA256, challenge.as_bytes()) + .as_ref() + .try_into() + .unwrap(); + let nonce: [u8; 32] = { + let mut ctx = DigestContext::new(&SHA256); + ctx.update(&auth_data); + ctx.update(&client_data_hash); + ctx.finish().as_ref().try_into().unwrap() + }; + + let cred_cert_der = build_cred_cert(&device_pkey, nonce.as_slice(), &key_id); + let int_cert_der = X509::from_pem(TEST_INT_CA_CERT_PEM) + .unwrap() + .to_der() + .unwrap(); + + let cbor = encode_attestation_cbor(&cred_cert_der, &int_cert_der, &auth_data); + + TestAttestation { + cbor, + key_id, + device_key: signing_key, + } +} + +/// Build a synthetic assertion signed by the given device private key. +/// +/// Returns raw CBOR bytes ready for `Assertion::from_assertion`. +/// +/// `previous_counter` should be the counter value stored server-side; +/// the assertion will use `previous_counter + 1`. +pub fn build_test_assertion( + app_id: &str, + client_data_hash: impl AsRef<[u8]>, + previous_counter: u32, + device_key: &SigningKey, +) -> Vec { + let rp_id_hash: [u8; 32] = digest(&SHA256, app_id.as_bytes()) + .as_ref() + .try_into() + .unwrap(); + let counter = previous_counter + 1; + + let mut auth_data = Vec::with_capacity(37); + auth_data.extend_from_slice(&rp_id_hash); + auth_data.push(0x01); + let mut counter_bytes = [0u8; 4]; + counter_bytes.copy_from_slice(&counter.to_be_bytes()); + auth_data.extend_from_slice(&counter_bytes); + + let nonce: Vec = { + let mut ctx = DigestContext::new(&SHA256); + ctx.update(&auth_data); + ctx.update(client_data_hash.as_ref()); + ctx.finish().as_ref().to_vec() + }; + + let sig: DerSignature = device_key.sign(&nonce); + encode_assertion_cbor(&auth_data, sig.as_bytes()) +} + +fn build_cred_cert( + device_pkey: &PKey, + nonce: &[u8], + key_id: &str, +) -> Vec { + build_cred_cert_with(device_pkey, nonce, key_id, |_| {}) +} + +fn build_cred_cert_with( + device_pkey: &PKey, + nonce: &[u8], + key_id: &str, + change: impl FnOnce(&mut X509Builder), +) -> Vec { + assert_eq!(nonce.len(), 32); + + let int_cert = X509::from_pem(TEST_INT_CA_CERT_PEM).unwrap(); + let int_key = PKey::private_key_from_pem(TEST_INT_CA_KEY_PEM).unwrap(); + + let mut name_builder = X509NameBuilder::new().unwrap(); + name_builder.append_entry_by_text("CN", key_id).unwrap(); + name_builder + .append_entry_by_text("OU", "AAA Certification") + .unwrap(); + name_builder.append_entry_by_text("O", "Test").unwrap(); + name_builder + .append_entry_by_text("ST", "California") + .unwrap(); + let subject_name = name_builder.build(); + + let mut builder = X509Builder::new().unwrap(); + builder.set_version(2).unwrap(); + builder.set_subject_name(&subject_name).unwrap(); + builder.set_issuer_name(int_cert.subject_name()).unwrap(); + builder.set_pubkey(device_pkey).unwrap(); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(365).unwrap()) + .unwrap(); + + let mut serial_bytes = [0u8; 8]; + rand_bytes(&mut serial_bytes).unwrap(); + let serial_asn1 = Asn1Integer::from_bn(&BigNum::from_slice(&serial_bytes).unwrap()).unwrap(); + builder.set_serial_number(&serial_asn1).unwrap(); + + builder + .append_extension(BasicConstraints::new().build().unwrap()) + .unwrap(); + + // Apple's proprietary App Attest EKU (OID 1.2.840.113635.100.4.24). + builder + .append_extension( + ExtendedKeyUsage::new() + .other("1.2.840.113635.100.4.24") + .build() + .unwrap(), + ) + .unwrap(); + + // Nonce extension OID 1.2.840.113635.100.8.2, encoding: + // 30 24 SEQUENCE, length 36 + // a1 22 [1] EXPLICIT, length 34 + // 04 20 OCTET STRING, length 32 + // + // + // `extract_nonce_from_cert` walks this with `der_unwrap_tag`, stripping the + // 04 20 header to yield the raw 32-byte nonce. + let mut ext_value = Vec::with_capacity(38); + ext_value.extend_from_slice(&[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]); + ext_value.extend_from_slice(nonce); + + let oid = Asn1Object::from_str("1.2.840.113635.100.8.2").unwrap(); + let octet_val = Asn1OctetString::new_from_bytes(&ext_value).unwrap(); + builder + .append_extension( + openssl::x509::X509Extension::new_from_der(&oid, false, &octet_val).unwrap(), + ) + .unwrap(); + + change(&mut builder); + builder.sign(&int_key, MessageDigest::sha256()).unwrap(); + builder.build().to_der().unwrap() +} + +fn encode_attestation_cbor(cred_cert_der: &[u8], int_cert_der: &[u8], auth_data: &[u8]) -> Vec { + let val = cbor!({ + "fmt" => "apple-appattest", + "attStmt" => { + "x5c" => [ + Value::Bytes(cred_cert_der.to_vec()), + Value::Bytes(int_cert_der.to_vec()) + ], + "receipt" => Value::Bytes(vec![]) + }, + "authData" => Value::Bytes(auth_data.to_vec()) + }) + .unwrap(); + + let mut buf = Vec::new(); + ciborium::into_writer(&val, &mut buf).unwrap(); + buf +} + +fn encode_assertion_cbor(auth_data: &[u8], signature: &[u8]) -> Vec { + let val = cbor!({ + "authenticatorData" => Value::Bytes(auth_data.to_vec()), + "signature" => Value::Bytes(signature.to_vec()) + }) + .unwrap(); + + let mut buf = Vec::new(); + ciborium::into_writer(&val, &mut buf).unwrap(); + buf +} + +fn certificate_chain(change: impl FnOnce(&mut X509Builder)) -> Vec> { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap(); + vec![ + build_cred_cert_with(&key, &[7; 32], "test", change), + X509::from_pem(TEST_INT_CA_CERT_PEM) + .unwrap() + .to_der() + .unwrap(), + ] +} + +/// Run the same signed-certificate corpus against an alternative adapter. +pub fn compare_certificate_constraints( + verify_chain: impl Fn(&[Vec], &[u8]) -> Result<(), E>, + verify_nonce: impl Fn(&[u8], &[u8]) -> Result<(), E>, +) { + use openssl::x509::extension::KeyUsage; + let root = X509::from_pem(TEST_ROOT_CA_CERT_PEM) + .unwrap() + .to_der() + .unwrap(); + let valid = certificate_chain(|builder| { + builder + .append_extension(KeyUsage::new().digital_signature().build().unwrap()) + .unwrap(); + }); + verify_chain(&valid, &root).unwrap(); + let wrong_usage = certificate_chain(|builder| { + builder + .append_extension(KeyUsage::new().key_encipherment().build().unwrap()) + .unwrap(); + }); + assert!(verify_chain(&wrong_usage, &root).is_err()); + let expired = certificate_chain(|builder| { + builder + .set_not_before(&Asn1Time::from_unix(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::from_unix(1).unwrap()) + .unwrap(); + }); + assert!(verify_chain(&expired, &root).is_err()); + let unknown_critical = certificate_chain(|builder| { + let oid = Asn1Object::from_str("1.2.3.4.5").unwrap(); + let value = Asn1OctetString::new_from_bytes(b"\x05\x00").unwrap(); + builder + .append_extension( + openssl::x509::X509Extension::new_from_der(&oid, true, &value).unwrap(), + ) + .unwrap(); + }); + assert!(verify_chain(&unknown_critical, &root).is_err()); + let duplicate_nonce = certificate_chain(|builder| { + let oid = Asn1Object::from_str("1.2.840.113635.100.8.2").unwrap(); + let value = Asn1OctetString::new_from_bytes(b"\x30\x00").unwrap(); + builder + .append_extension( + openssl::x509::X509Extension::new_from_der(&oid, false, &value).unwrap(), + ) + .unwrap(); + }); + assert!(verify_chain(&duplicate_nonce, &root).is_err()); + assert!(verify_nonce(&duplicate_nonce[0], &[7; 32]).is_err()); + let mut trailing = valid; + trailing[0].push(0); + assert!(verify_chain(&trailing, &root).is_err()); +} diff --git a/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs b/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs new file mode 100644 index 0000000000..6e85e4958d --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs @@ -0,0 +1,465 @@ +use super::*; +use p256::ecdsa::{signature::Signer, Signature, SigningKey}; + +const APP_ID: &str = "TEAM.test.app"; +const TRANSCRIPT: &[u8] = b"buzz.push.delegate.v1\n{\"challenge\":\"test-only\"}"; + +fn signing_key() -> SigningKey { + SigningKey::from_slice(&[7; 32]).unwrap() +} + +fn auth_data(extensions: &[u8]) -> Vec { + let mut data = Sha256::digest(APP_ID.as_bytes()).to_vec(); + data.push(if extensions.is_empty() { 0x40 } else { 0xc0 }); + data.extend_from_slice(&1u32.to_be_bytes()); + data.extend_from_slice(extensions); + data +} + +fn signed_assertion(data: &[u8]) -> Vec { + let mut hash = Sha256::new(); + hash.update(data); + hash.update(Sha256::digest(TRANSCRIPT)); + let signature: Signature = signing_key().sign(&hash.finalize()); + let mut buffer = [0u8; 2048]; + let mut encoder = + minicbor::Encoder::new(minicbor::encode::write::Cursor::new(buffer.as_mut_slice())); + encoder + .map(2) + .unwrap() + .str("authenticatorData") + .unwrap() + .bytes(data) + .unwrap() + .str("signature") + .unwrap() + .bytes(signature.to_der().as_bytes()) + .unwrap(); + let length = encoder.writer().position(); + buffer[..length].to_vec() +} + +fn verify(cbor: &[u8]) -> Result { + let verifier = AppAttestVerifier::new( + APP_ID.into(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .unwrap(); + verifier.verify_assertion( + &STANDARD.encode(cbor), + TRANSCRIPT, + signing_key() + .verifying_key() + .to_encoded_point(false) + .as_ref(), + 0, + "challenge", + "challenge", + ) +} + +#[test] +fn legacy_assertion_round_trip() { + assert_eq!( + verify(&signed_assertion(&auth_data(&[]))).unwrap().counter, + 1 + ); +} + +#[test] +fn production_verifier_accepts_extended_assertion() { + // General unknown extension with a nested array, not an SE-specific length. + let extension = b"\xa1\x66future\x83\x01\xf5\x62ok"; + assert_eq!( + verify(&signed_assertion(&auth_data(extension))) + .unwrap() + .counter, + 1 + ); +} + +// Every conformance case exercises the shipped verification boundary. +mod conformance { + use super::*; + use ciborium::Value; + + fn value(bytes: &[u8]) -> Result { + ciborium::from_reader(bytes).map_err(|_| "CBOR test fixture") + } + + fn verify_bytes( + cbor: &[u8], + transcript: &[u8], + app: &str, + key: &[u8], + previous: u32, + ) -> Result { + let verifier = AppAttestVerifier::new( + app.into(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .map_err(|_| "configuration")?; + verifier + .verify_assertion( + &STANDARD.encode(cbor), + transcript, + key, + previous, + "fresh", + "fresh", + ) + .map(|verified| verified.counter) + .map_err(|_| "invalid") + } + + fn verify(cbor: &[u8]) -> Result { + verify_bytes( + cbor, + TRANSCRIPT, + APP_ID, + signing_key() + .verifying_key() + .to_encoded_point(false) + .as_ref(), + 0, + ) + } + + #[test] + fn varied_extensions_and_legacy_verify() { + for ext in [ + b"".as_slice(), + b"\xa0", + b"\xa1\x66future\x83\x01\xf5\x62ok", + b"\xbf\x66future\x9f\x01\xf5\xff\xff", + b"\xa2\x61a\x18\x01\x61b\x43abc", + b"\xa2\x61b\x43abc\x61a\x18\x01", + b"\xa1\x61x\xa1\x01\xc1\x00", + ] { + assert_eq!(verify(&signed_assertion(&auth_data(ext))), Ok(1)); + } + } + + #[test] + fn malformed_signed_extensions_rejected() { + for ext in [ + b"\xa1".as_slice(), + b"\xa0\x00", + b"\x81\x01", + b"\xa2\x61x\x01\x61x\x02", + b"\xa1\x61x\xa2\x01\x01\x18\x01\x02", + b"\xa1\x01\x01", + b"\xa1\x61x\xff", + ] { + assert!( + verify(&signed_assertion(&auth_data(ext))).is_err(), + "{ext:?}" + ); + } + for len in 0..37 { + assert!(verify(&signed_assertion(&auth_data(&[])[..len])).is_err()); + } + } + + #[test] + fn all_truncations_and_envelope_ambiguity_rejected() { + let cbor = signed_assertion(&auth_data(b"\xa1\x61x\x01")); + for len in 0..cbor.len() { + assert!(verify(&cbor[..len]).is_err()); + } + let mut trailing = cbor.clone(); + trailing.push(0); + assert!(verify(&trailing).is_err()); + let mut duplicate = cbor.clone(); + duplicate[0] = 0xa3; + duplicate.extend_from_slice(b"\x69signature\x40"); + assert!(verify(&duplicate).is_err()); + } + + #[test] + fn signed_bytes_identity_key_and_replay_preserved() { + let cbor = signed_assertion(&auth_data(b"\xa1\x61x\x01")); + let key = signing_key().verifying_key().to_encoded_point(false); + assert!(verify_bytes(&cbor, b"changed", APP_ID, key.as_ref(), 0).is_err()); + assert!(verify_bytes(&cbor, TRANSCRIPT, "other.app", key.as_ref(), 0).is_err()); + assert!(verify_bytes(&cbor, TRANSCRIPT, APP_ID, key.as_ref(), 1).is_err()); + assert!(verify_bytes(&cbor, TRANSCRIPT, APP_ID, key.as_ref(), 2).is_err()); + let other = SigningKey::from_slice(&[8; 32]).unwrap(); + assert!(verify_bytes( + &cbor, + TRANSCRIPT, + APP_ID, + other.verifying_key().to_encoded_point(false).as_ref(), + 0 + ) + .is_err()); + let mut decoded = value(&cbor).unwrap(); + if let Value::Map(ref mut entries) = decoded { + for (k, v) in entries { + if k.as_text() == Some("authenticatorData") { + if let Value::Bytes(data) = v { + *data.last_mut().unwrap() = 2; + } + } + } + } + let mut changed = Vec::new(); + ciborium::into_writer(&decoded, &mut changed).unwrap(); + assert!(verify(&changed).is_err()); + } + + #[test] + fn extension_size_is_not_a_format_constant() { + for len in 0..512 { + let extension = Value::Map(vec![( + Value::Text(format!("future_{len}")), + Value::Bytes(vec![0x71; len]), + )]); + let mut bytes = Vec::new(); + ciborium::into_writer(&extension, &mut bytes).unwrap(); + assert_eq!(verify(&signed_assertion(&auth_data(&bytes))), Ok(1)); + } + } + + #[test] + #[ignore = "requires protected local capture; never commit raw device assertions"] + fn captured_device_assertion_verifies_offline() { + let root = std::path::PathBuf::from(std::env::var("APP_ATTEST_EVIDENCE_DIR").unwrap()); + let capture: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join("se-attest-diagnostic.json")).unwrap()) + .unwrap(); + let stored: serde_json::Value = serde_json::from_slice( + &std::fs::read(root.join("gateway-verification-input.json")).unwrap(), + ) + .unwrap(); + let assertion = STANDARD + .decode(capture["assertion"].as_str().unwrap()) + .unwrap(); + let transcript = STANDARD + .decode(capture["transcript"].as_str().unwrap()) + .unwrap(); + let key = STANDARD + .decode(stored["public_key"].as_str().unwrap().replace('\n', "")) + .unwrap(); + assert_eq!( + verify_bytes( + &assertion, + &transcript, + "DE7S2975CH.com.tombrow.buzz.mobile", + &key, + stored["assertion_counter"].as_u64().unwrap() as u32 + ), + Ok(64) + ); + } +} + +/// Private device evidence stays outside the repository. This exercises the +/// production verification boundary. +#[test] +#[ignore = "requires protected same-key SDK comparison captures"] +fn same_key_sdk26_and_sdk27_assertions_verify() { + let root = std::path::PathBuf::from(std::env::var("APP_ATTEST_SDK_EVIDENCE_DIR").unwrap()); + let mut previous = 0; + let mut enrolled_key = None; + for build in ["2601", "2702"] { + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(root.join(format!("verifier-input-{build}.json"))).unwrap(), + ) + .unwrap(); + let verifier = AppAttestVerifier::new( + fixture["app_id"].as_str().unwrap().into(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .unwrap(); + let assertion = fixture["assertion"].as_str().unwrap(); + let transcript = STANDARD + .decode(fixture["transcript"].as_str().unwrap()) + .unwrap(); + let key = STANDARD + .decode(fixture["public_key"].as_str().unwrap()) + .unwrap(); + if let Some(expected) = &enrolled_key { + assert_eq!( + &key, expected, + "the SDK comparison must retain the same key" + ); + } else { + enrolled_key = Some(key.clone()); + } + let verified = verifier + .verify_assertion(assertion, &transcript, &key, previous, "fresh", "fresh") + .unwrap_or_else(|_| panic!("SDK probe build {build} must verify")); + assert_eq!( + u64::from(verified.counter), + fixture["counter"].as_u64().unwrap() + ); + assert!(verifier + .verify_assertion( + assertion, + &transcript, + &key, + verified.counter, + "fresh", + "fresh" + ) + .is_err()); + let mut wrong_transcript = transcript.clone(); + wrong_transcript.push(0); + assert!(verifier + .verify_assertion( + assertion, + &wrong_transcript, + &key, + previous, + "fresh", + "fresh" + ) + .is_err()); + previous = verified.counter; + } +} + +#[cfg(feature = "personal-dev-app-attest")] +#[test] +#[ignore = "requires protected SDK comparison enrollment capture"] +fn sdk26_and_sdk27_device_enrollments_verify_with_exact_environment() { + let root = std::path::PathBuf::from(std::env::var("APP_ATTEST_SDK_EVIDENCE_DIR").unwrap()); + for build in ["2601", "2703"] { + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(root.join(format!("enrollment-input-{build}.json"))).unwrap(), + ) + .unwrap(); + let transcript = STANDARD + .decode(fixture["transcript"].as_str().unwrap()) + .unwrap(); + let verifier = AppAttestVerifier::with_environment( + fixture["app_id"].as_str().unwrap().into(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + AppAttestEnvironment::Development, + ) + .unwrap(); + let verified = verifier + .verify_attestation( + fixture["attestation"].as_str().unwrap(), + fixture["key_id"].as_str().unwrap(), + &transcript, + ) + .unwrap(); + let expected: serde_json::Value = serde_json::from_slice( + &std::fs::read(root.join(format!("verifier-input-{build}.json"))).unwrap(), + ) + .unwrap(); + assert_eq!( + verified.public_key, + STANDARD + .decode(expected["public_key"].as_str().unwrap()) + .unwrap() + ); + assert_eq!( + verifier + .verify_assertion( + expected["assertion"].as_str().unwrap(), + &STANDARD + .decode(expected["transcript"].as_str().unwrap()) + .unwrap(), + &verified.public_key, + 0, + "fresh", + "fresh", + ) + .unwrap() + .counter, + expected["counter"].as_u64().unwrap() as u32 + ); + let production = AppAttestVerifier::new( + fixture["app_id"].as_str().unwrap().into(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .unwrap(); + assert!(production + .verify_attestation( + fixture["attestation"].as_str().unwrap(), + fixture["key_id"].as_str().unwrap(), + &transcript + ) + .is_err()); + } +} + +mod properties { + use super::*; + use ciborium::Value; + use proptest::prelude::*; + + fn optional_value() -> impl Strategy { + prop_oneof![ + any::().prop_map(|v| Value::Integer(v.into())), + any::().prop_map(Value::Bool), + proptest::collection::vec(any::(), 0..16).prop_map(Value::Bytes), + "[a-z]{0,12}".prop_map(Value::Text), + ] + .prop_recursive(4, 32, 4, |inner| { + prop_oneof![ + proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::Array), + proptest::collection::btree_map("[a-z]{1,6}", inner.clone(), 0..4).prop_map(|m| { + Value::Map(m.into_iter().map(|(k, v)| (Value::Text(k), v)).collect()) + }), + inner.prop_map(|v| Value::Tag(1000, Box::new(v))), + ] + }) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + #[test] + fn signed_optional_values_preserve_byte_authentication(v in optional_value(), selector in any::()) { + let mut tail = Vec::new(); + ciborium::into_writer(&Value::Map(vec![(Value::Text("future".into()), v)]), &mut tail).unwrap(); + prop_assert!(tail.len() < 800); + let auth = auth_data(&tail); + let signed = signed_assertion(&auth); + prop_assert!(verify(&signed).is_ok()); + // Mutate an authenticated extension byte while retaining its original signature. + let mut envelope: Value = ciborium::from_reader(signed.as_slice()).unwrap(); + let Value::Map(fields) = &mut envelope else { unreachable!() }; + for (key, value) in fields { + if key.as_text() == Some("authenticatorData") { + let Value::Bytes(bytes) = value else { unreachable!() }; + bytes[37 + selector % tail.len()] ^= 1; + } + } + let mut changed = Vec::new(); + ciborium::into_writer(&envelope, &mut changed).unwrap(); + prop_assert!(verify(&changed).is_err()); + prop_assert!(verify(&signed[..selector % signed.len()]).is_err()); + } + } + + #[test] + fn maximum_counter_is_accepted_once_and_cannot_wrap() { + let key = signing_key().verifying_key().to_encoded_point(false); + let mut auth = auth_data(b"\xa0"); + auth[33..37].copy_from_slice(&u32::MAX.to_be_bytes()); + let signed = signed_assertion(&auth); + assert_eq!( + super::super::proof::assert(&signed, TRANSCRIPT, APP_ID, key.as_ref(), u32::MAX - 1) + .unwrap() + .counter, + u32::MAX + ); + assert!( + super::super::proof::assert(&signed, TRANSCRIPT, APP_ID, key.as_ref(), u32::MAX) + .is_err() + ); + auth[33..37].copy_from_slice(&0u32.to_be_bytes()); + assert!(super::super::proof::assert( + &signed_assertion(&auth), + TRANSCRIPT, + APP_ID, + key.as_ref(), + u32::MAX + ) + .is_err()); + } +} diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 74a288827b..a58d8c209b 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -1277,6 +1277,86 @@ mod postgres_tests { drop_schema(&schema).await; } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn app_attest_challenge_and_counter_fences_survive_races_and_exhaustion() { + let (pool, schema) = full_schema(4).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let challenge = Challenge { + id: Uuid::new_v4(), + value: [7; 32], + created_at: now, + expires_at: now + 300, + }; + store.put_challenge(challenge.clone()).await.unwrap(); + assert_eq!( + store.consume_challenge(challenge.id, [8; 32], now).await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store + .consume_challenge(Uuid::new_v4(), challenge.value, now) + .await, + Err(AuthorityError::Rejected) + ); + store + .consume_challenge(challenge.id, challenge.value, now) + .await + .unwrap(); + assert_eq!( + store + .consume_challenge(challenge.id, challenge.value, now) + .await, + Err(AuthorityError::Rejected) + ); + let expired = Challenge { + id: Uuid::new_v4(), + expires_at: now - 1, + ..challenge + }; + store.put_challenge(expired.clone()).await.unwrap(); + assert_eq!( + store + .consume_challenge(expired.id, expired.value, now) + .await, + Err(AuthorityError::Rejected) + ); + + install_authority(&pool).await; + let id = Uuid::from_u128(1); + let (a, b) = tokio::join!( + store.advance_assertion_counter(id, 0, 1), + store.advance_assertion_counter(id, 0, 2) + ); + assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); + let previous = store.installation(id, now).await.unwrap().assertion_counter; + assert!(previous == 1 || previous == 2); + store + .advance_assertion_counter(id, previous, u32::MAX) + .await + .unwrap(); + assert_eq!( + store.advance_assertion_counter(id, u32::MAX, 0).await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store + .advance_assertion_counter(id, u32::MAX, u32::MAX) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store.installation(id, now).await.unwrap().assertion_counter, + u32::MAX + ); + assert!(store.installation(id, now + 31 * 86400).await.is_err()); + store.revoke_installation(id, 1, 2).await.unwrap(); + assert!(store.installation(id, now).await.is_err()); + pool.close().await; + drop_schema(&schema).await; + } + async fn drop_schema(schema: &str) { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json index 3bdff5ccdc..643cc70692 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -1,9 +1,7 @@ { - "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", - "app_id": "TEAMID.xyz.buzz.mobile", - "challenge": "buzz-app-attest-strict-verifier-fixture", + "description": "Synthetic enrollment with complete COSE key, signed by test CA", + "app_id": "TEAMID.xyz.buzz.test", + "challenge": "fixture-enrollment-challenge", "aaguid": "appattest", - "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk", - "key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=", - "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUFeZt+1JiuWUm7RJtzNA3etiyaTgwCgYIKoZIzj0EAwIw\nSzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\nCgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\nNjAyMjIxODU2MTBaMEsxJTAjBgNVBAMMHFRlc3QgQXBwIEF0dGVzdGF0aW9uIFJv\nb3QgQ0ExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcq\nhkjOPQIBBgUrgQQAIgNiAAT2cBxT0pWtGUECFzUn+Hdx6MaDyVoOHFcjsd+wqlKZ\nIKNg+bopRItVz79g5Rn3F7126h7Q6ZEosNMCMiV2u1t47NBVGAvs0FtM/gqN2hZv\n4vy3F2l3Vwd2TatWzpRC0iKjYzBhMB0GA1UdDgQWBBR9vyJAdCH/3azKhgGa9JSh\n5LVM+DAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAPBgNVHRMBAf8E\nBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNnADBkAjBi8yL62XLW\ng604KgEaEn9IFxR2c4vx+K0ZhYjPq6OU8X4pq9757JityNdJW1THgxgCMAo9bL44\nUUoFJPTgvmKobdPOIUQ1oRNRYnGmtfovWa31WM2IK0mOs+lpWYncFZOr3Q==\n-----END CERTIFICATE-----\n" } diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json index 56d1260bea..fb6eae36d1 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -1,9 +1,7 @@ { - "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", - "app_id": "TEAMID.xyz.buzz.mobile", - "challenge": "buzz-app-attest-strict-verifier-fixture", + "description": "Synthetic enrollment with complete COSE key, signed by test CA", + "app_id": "TEAMID.xyz.buzz.test", + "challenge": "fixture-enrollment-challenge", "aaguid": "appattestdevelop", - "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", - "key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", - "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUFeZt+1JiuWUm7RJtzNA3etiyaTgwCgYIKoZIzj0EAwIw\nSzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\nCgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\nNjAyMjIxODU2MTBaMEsxJTAjBgNVBAMMHFRlc3QgQXBwIEF0dGVzdGF0aW9uIFJv\nb3QgQ0ExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcq\nhkjOPQIBBgUrgQQAIgNiAAT2cBxT0pWtGUECFzUn+Hdx6MaDyVoOHFcjsd+wqlKZ\nIKNg+bopRItVz79g5Rn3F7126h7Q6ZEosNMCMiV2u1t47NBVGAvs0FtM/gqN2hZv\n4vy3F2l3Vwd2TatWzpRC0iKjYzBhMB0GA1UdDgQWBBR9vyJAdCH/3azKhgGa9JSh\n5LVM+DAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAPBgNVHRMBAf8E\nBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNnADBkAjBi8yL62XLW\ng604KgEaEn9IFxR2c4vx+K0ZhYjPq6OU8X4pq9757JityNdJW1THgxgCMAo9bL44\nUUoFJPTgvmKobdPOIUQ1oRNRYnGmtfovWa31WM2IK0mOs+lpWYncFZOr3Q==\n-----END CERTIFICATE-----\n" } diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json index 7129b63939..bb648e35e8 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -1,9 +1,7 @@ { - "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + "description": "Unrelated root: must reject the fresh synthetic enrollment signed by the test CA.", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattest", - "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==", - "key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\n-----END CERTIFICATE-----\n" } diff --git a/crates/buzz-push-gateway/tests/fixtures/appattest-test-ca-key.pem b/crates/buzz-push-gateway/tests/fixtures/appattest-test-ca-key.pem new file mode 100644 index 0000000000..f9bfb70d31 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/appattest-test-ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFM/DSPwekMvsqNpx/wpynoxNHL6driuUlXKdzaxVuIEoAoGCCqGSM49 +AwEHoUQDQgAEV0CVN6maPcws9sWaatGchX2DflqRurhHbfvhikXGo45iYjGAacTg +ba3nAoLz5a2XUu1JR4ZlINmXX577u/gxCA== +-----END EC PRIVATE KEY----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apple-structural-attestation.b64 b/crates/buzz-push-gateway/tests/fixtures/apple-structural-attestation.b64 new file mode 100644 index 0000000000..d81cd1e338 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apple-structural-attestation.b64 @@ -0,0 +1 @@ +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 diff --git a/docs/app-attest-compatibility.md b/docs/app-attest-compatibility.md new file mode 100644 index 0000000000..fb453f9559 --- /dev/null +++ b/docs/app-attest-compatibility.md @@ -0,0 +1,114 @@ +# App Attest compatibility boundary + +The push gateway verifies enrollment and assertions through a private +[Buzz-owned module](../crates/buzz-push-gateway/src/app_attest.rs). Extended authenticators must +remain compatible without weakening the checks that bind a request to an app, +key, challenge, and increasing counter. + +## Receiver policy + +Enrollment requires the fixed authenticator prefix, AAGUID, credential ID, and a +complete COSE P-256 key. The COSE key must match the leaf certificate key. Any +remaining bytes form one complete extension map. Assertions require the fixed +37-byte prefix and, when present, one complete extension map. Neither operation +uses a total length or flags allowlist to recognize an Apple SDK version. + +Credential IDs use the fixed 32-byte SHA-256 size, preserving appattest 0.1.1's +workaround for reported iOS 26.x nonstandard credential-length fields. The two +length bytes remain authenticated; the credential ID must match the supplied +key ID and the certificate key hash, and the COSE key at offset 87 must match +the certificate. Signed synthetic tests cover nonstandard lengths, actual +missing/extra credential bytes, key mismatch, and unsigned length mutations. +No enrollment captured on iOS 26.x has yet validated this reported OS variant. +SDK 26.5 tests on iOS 27.0.1 do not substitute for that evidence. + +Every original authenticator byte participates in the certificate nonce or +assertion signature. Verification does not truncate or re-encode that byte +string. Assertion signatures and enrollment certificate nonces are checked +before decoding the extension payload. Unknown extension identifiers and opaque +optional property values do not authorize or reject a request by themselves. +Malformed structure, ambiguous duplicate keys, and trailing data are rejected. + +CBOR accepts well-formed definite/indefinite containers and strings, nonpreferred +integer widths, and opaque tagged/simple values under bounded resource limits. +Map-key equality follows the generic CBOR model, including compound keys; +integers and floating-point values remain distinct. Schema-bearing maps require +their specified text or COSE labels. + +The gateway retains its configured Apple root and exact server-selected app and +attestation environment. Certificate validation uses webpki; field extraction +uses x509-parser. The nonce extension must have its complete canonical structure. +The leaf key, credential ID, supplied key ID, zero enrollment counter, and app +hash must agree. Assertions retain signature, app hash, challenge, and increasing +counter checks. Existing atomic database updates remain the replay boundary. + +The verifier consumes EKU OIDs without applying TLS purposes to App Attest and +honors supplied digital-signature/key-cert-sign usage restrictions. It does not +perform revocation checking or Apple's separate receipt risk assessment. + +Version text and raw four-byte category values are bounded diagnostic +observations only. Unsupported optional representations remain opaque. No +version or category restriction is introduced. + +## Capacity and tests + +Parser limits are service policy: 64 KiB encoded input, 4096 decoded nodes, +depth 32, and 64 KiB copied strings/bytes. Declared lengths do not drive +preallocation. Gateway transport limits remain 16 KiB for enrollment and 1 KiB +for assertions. Tests cover boundaries, oversized declared lengths, truncations, +duplicate equivalence, and structurally invalid signed payloads. + +Signed synthetic enrollment tests cover COSE parity/reordering, extension +variation, certificate constraints, nonce integrity, wrong identity/key binding, +and incomplete credentials. Gateway tests verify legacy and extended assertions, +tamper rejection, wrong transcript/key/app, and replay rejection. The signed-certificate suite runs with the gateway unit tests. Fixture helpers +are compiled only for tests, including in personal-development builds. + +Physical-device validation used one probe identity on iOS 27.0.1: + +| SDK | Enrollment authenticator | Assertion authenticator | Key relationship | +| --- | ---: | ---: | --- | +| 26.5 | 164 bytes | 37 bytes | Original probe key | +| 27.1 | Previously enrolled | 102 bytes | Same original key | +| 27.1 | 229 bytes | 102 bytes | Additional fresh probe key | + +All three assertions and both enrollments pass the gateway verifier. Same-key +counters increase; repeated counters and altered transcripts fail. Development +enrollments fail under the production environment. These are local verifier +results, not evidence of a deployed gateway or APNs delivery. Raw device captures +stay outside the repository; optional ignored tests consume protected local +inputs for repeatable validation. + +## Source provenance + +Adapted portions originate in [appattest 0.1.1](https://crates.io/crates/appattest/0.1.1), +[upstream commit d1ffda7](https://github.com/PrismaPhonic/appattest/commit/d1ffda733f6e7f267aab79ae42b8ec7f60ceada1). +The package SHA-256 is `5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7`. +Both upstream MIT notices are retained in the module's +[LICENSE](../crates/buzz-push-gateway/src/app_attest/LICENSE). +The gateway owns its verification policy and tracks Apple protocol and underlying +cryptographic-library updates directly. There is no remaining appattest dependency. + +## Resource characterization + +A manual debug-build run on arm64 macOS (1,000 parses per case) measured about +1.61 ms for a 49,131-byte map near the 4,096-node limit, 247 microseconds for a +4,097-byte array at that node limit, 0.93 microseconds for a 65,536-byte byte +string, and 3.8 microseconds for depth 32. The test process peaked at 10.4 MB +resident memory; that includes the test harness and is not a parser-allocation +measurement. These are local observations, not latency guarantees or production +benchmarks. The manual `characterize_capacity_work` test reproduces the cases +without imposing a timing-sensitive CI assertion. + +Node and depth limits bound traversal and key-comparison work; byte limits bound +copied payloads. Container capacity grows only after successfully decoding each +item, never from an untrusted declared length. The gateway's smaller transport +caps apply before these parser limits. Future valid objects can exceed service +capacity and receive the distinct internal capacity diagnostic; raising limits +requires review and repeating this characterization. + +Generated tests cover 256 nested optional-value/signature-mutation cases and +512 arbitrary byte inputs per run. They complement the explicit boundary and +encoding-equivalence corpus. Apple's public historical attestation fixture also +exercises a complete credential key and extension tail with ED clear; its +certificate is not treated as a currently valid enrollment.