Files
buzz/crates/buzz-push-gateway/src/app_attest.rs
Tom Brow 5397ea49e2 fix(push): verify extended App Attest proofs with a focused owned module (#8329)
## Summary

Apple SDK 27.1 device captures include authenticator extension data that
the current verifier rejects. Replace the general appattest dependency
with a private gateway module that verifies the complete original bytes,
frames enrollment and assertions separately, and accepts well-formed
optional extensions without SDK-specific length or flag allowlists.

The module retains established signature, P-256, CBOR, and
certificate-chain libraries. App identity, exact enrollment environment,
credential/key binding, nonce, challenge, and increasing-counter checks
remain enforced. Bounded parsing rejects ambiguous duplicate keys,
truncation, trailing data, and oversized inputs. Fixture helpers are
test-only. Adapted code retains both upstream MIT notices and source
provenance.

Category/version properties remain authenticated observations under the
chosen compatibility-release policy. This does not implement Apple's
additional distribution-category/version enforcement; introducing those
restrictions requires a separately defined deployment policy. The
[verification
contract](https://github.com/block/buzz/blob/app-attest-owned-verifier/docs/app-attest-compatibility.md)
records this and other limits.

Related: https://github.com/block/buzz/pull/8020 addresses assertion
extension handling. This change also covers enrollment extensions,
credential-key parsing, and certificate validation.

### Validation

- Gateway suites: 77 default tests and 79 personal-development tests
passed, plus startup policy and Clippy.
- Added signed enrollment/certificate, generated CBOR mutation,
maximum-counter, and PostgreSQL race/replay coverage. All 12 isolated
Postgres tests passed. Apple's historical fixture is tested
structurally, not accepted as a current enrollment. Positive synthetic
leaf certificates are generated at test time.
- Protected SDK 26.5/27.1 enrollments and assertions pass the verifier,
including the same key across SDK upgrades; development enrollment
rejects under production policy. Captures are personal-development
identities, not supported production-identity evidence.
- Full local CI passed Rust tests and reached 6,828 passing desktop JS
tests before an unchanged 10 ms focus-timer test failed; its five-test
file passed in isolation. The pre-push native suite had 3,421 passes and
an unchanged 300 ms child-PID test failure, also passing in isolation.
Prior ACP timing flakes and these unrelated hook exceptions do not waive
required CI.
- Windows CI exposed a missing system OpenSSL installation for the test
fixture generator. The test dependency now builds bundled OpenSSL; all
30 focused App Attest tests pass locally. [Current-head
CI](https://github.com/block/buzz/actions/runs/38636062347) passed,
including Windows Rust, PostgreSQL, and desktop integration tests.
- [Prior-head Codex
review](https://github.com/block/buzz/pull/8329#issuecomment-6177515598)
found no major issues. Carl subsequently identified a dropped
credential-length workaround; fixed in
[27f9f26](27f9f2624f),
with 34 focused/protected tests and Clippy passing. [Current-head Codex
review](https://github.com/block/buzz/pull/8329#issuecomment-6178361143)
found no major issues; physical iOS 26 testing was explicitly waived by
Tom on October 14, 2026. No iOS 26 device test was performed. The SDK
comparison used the same iOS 27.0.1 device, so it demonstrates
SDK-linked variation without establishing OS-version causation.
- Isolated candidate HTTP/device validation passed with separate
authority state and no APNs delivery: SDK 26.5 enrollment/delegation,
SDK 27.1 delegation with the exact same key and installation, and fresh
SDK 27.1 enrollment/delegation. Assertions changed from 37 to 102 bytes
across the upgrade; accepted counter increased from 1 to 3 and
delegation generation from 1 to 2. All three runs rejected tampering and
replay (401) and consumed challenges (404). Existing Buzz app builds
were preserved. No production rollout is included.

- Existing-installation validation passed against the current-head Dev
gateway: a guarded SDK 27.1 diagnostic reused the stored key and
installation, advanced the assertion counter from 88 to 89 and
delegation from 89 to 90, and published the existing active relay lease
at generation 9 (previously 8). Independent database reads confirmed the
changes and preserved key, epoch, and expiry. The original app build was
restored in place with current data and keys intact.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 07:15:19 -07:00

581 lines
19 KiB
Rust

//! Narrow App Attest verification boundary. Production enrollment accepts only
//! Apple production AAGUID material by default; personal development requires
//! an explicit build feature and environment. No cryptographic checks are skipped.
use crate::config::AppAttestEnvironment;
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 sha2::{Digest, Sha256};
use thiserror::Error;
const MAX_ASSERTION_BYTES: usize = 1024;
const APPLE_APP_ATTEST_ROOT_PEM_SHA256: [u8; 32] = [
0xc7, 0x78, 0xd0, 0x9a, 0xc3, 0x41, 0xf7, 0xfd, 0x9f, 0x8f, 0x3b, 0x19, 0xe2, 0xb8, 0x15, 0xaf,
0x6a, 0xed, 0x4a, 0xd4, 0x49, 0x0e, 0x1e, 0x92, 0xc0, 0x5c, 0xb3, 0x55, 0x21, 0x2a, 0x50, 0x13,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAttestation {
pub key_id: Vec<u8>,
pub public_key: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifiedAssertion {
pub counter: u32,
}
#[derive(Debug, Error)]
pub enum AppAttestError {
#[error("invalid app attestation or assertion")]
Invalid,
}
#[derive(Clone)]
pub struct AppAttestVerifier {
app_id: String,
apple_root_cert_pem: Vec<u8>,
environment: AppAttestEnvironment,
}
impl AppAttestVerifier {
/// Construct a production-only verifier.
pub fn new(app_id: String, apple_root_cert_pem: Vec<u8>) -> Result<Self, AppAttestError> {
Self::with_environment(
app_id,
apple_root_cert_pem,
AppAttestEnvironment::Production,
)
}
/// Construct a verifier for exactly one server-selected attestation environment.
pub fn with_environment(
app_id: String,
apple_root_cert_pem: Vec<u8>,
environment: AppAttestEnvironment,
) -> Result<Self, AppAttestError> {
if app_id.is_empty()
|| Sha256::digest(&apple_root_cert_pem).as_slice() != APPLE_APP_ATTEST_ROOT_PEM_SHA256
{
return Err(AppAttestError::Invalid);
}
Ok(Self {
app_id,
apple_root_cert_pem,
environment,
})
}
/// Exact server-selected environment accepted by this verifier.
pub fn environment(&self) -> AppAttestEnvironment {
self.environment
}
/// `client_data` is the exact canonical enrollment transcript represented by
/// the challenge string passed to `attestKey`; callers must include every
/// authority-bearing enrollment field in it.
pub fn verify_attestation(
&self,
attestation_b64: &str,
key_id_b64: &str,
client_data: &[u8],
) -> Result<VerifiedAttestation, AppAttestError> {
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);
}
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: verified.key_id,
public_key: verified.public_key.to_vec(),
})
}
pub fn verify_assertion(
&self,
assertion_b64: &str,
client_data: &[u8],
public_key: &[u8],
previous_counter: u32,
challenge: &str,
stored_challenge: &str,
) -> Result<VerifiedAssertion, AppAttestError> {
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);
}
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,
})
}
}
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"
}
};
tracing::debug!(reason, "App Attest verification rejected");
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,
}
}
#[cfg(test)]
mod tests {
use super::error::AppAttestError as ProofError;
use super::*;
use serde::Deserialize;
const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json");
const WRONG_AAGUID_FIXTURE_JSON: &str =
include_str!("../tests/fixtures/app-attest-wrong-aaguid.json");
const WRONG_ROOT_FIXTURE_JSON: &str =
include_str!("../tests/fixtures/app-attest-wrong-root.json");
const APPLE_ROOT_CERT_PEM: &[u8] =
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem");
#[derive(Deserialize)]
struct Fixture {
description: String,
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 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
}
fn verifier(app_id: &str, root_cert_pem: &[u8]) -> AppAttestVerifier {
AppAttestVerifier {
app_id: app_id.to_owned(),
apple_root_cert_pem: root_cert_pem.to_vec(),
environment: AppAttestEnvironment::Production,
}
}
fn verify_proof(
fixture: &Fixture,
app_id: &str,
challenge: &str,
key_id_b64: &str,
root_cert_pem: &[u8],
) -> Result<(), ProofError> {
let cbor = STANDARD
.decode(&fixture.attestation_b64)
.expect("fixture attestation is base64");
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_proof(
&fixture,
&fixture.app_id,
&fixture.challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
)
.expect("strict dependency verifier accepts the generated encoding");
let verified = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
&fixture.attestation_b64,
&fixture.key_id_b64,
fixture.challenge.as_bytes(),
)
.expect("shipped gateway wrapper accepts the generated encoding");
assert_eq!(verified.key_id.len(), 32);
assert_eq!(verified.public_key.len(), 65);
}
#[test]
fn wrong_root_is_rejected() {
let good = fixture(GOOD_FIXTURE_JSON);
let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON);
assert!(verify_proof(
&good,
&good.app_id,
&good.challenge,
&good.key_id_b64,
wrong_root.root_cert_pem.as_bytes(),
)
.is_err());
assert!(verifier(&good.app_id, wrong_root.root_cert_pem.as_bytes())
.verify_attestation(
&good.attestation_b64,
&good.key_id_b64,
good.challenge.as_bytes()
)
.is_err());
}
#[test]
fn wrong_app_id_is_rejected() {
let fixture = fixture(GOOD_FIXTURE_JSON);
let wrong_app_id = "TEAMID.xyz.buzz.wrong";
assert_eq!(
verify_proof(
&fixture,
wrong_app_id,
&fixture.challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
),
Err(ProofError::InvalidAppID)
);
assert!(verifier(wrong_app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
&fixture.attestation_b64,
&fixture.key_id_b64,
fixture.challenge.as_bytes(),
)
.is_err());
}
#[test]
fn wrong_challenge_is_rejected() {
let fixture = fixture(GOOD_FIXTURE_JSON);
let wrong_challenge = "wrong-challenge";
assert_eq!(
verify_proof(
&fixture,
&fixture.app_id,
wrong_challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
),
Err(ProofError::InvalidNonce)
);
assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
&fixture.attestation_b64,
&fixture.key_id_b64,
wrong_challenge.as_bytes(),
)
.is_err());
}
#[test]
fn wrong_aaguid_is_rejected_as_invalid_aaguid() {
let fixture = fixture(WRONG_AAGUID_FIXTURE_JSON);
assert_eq!(fixture.aaguid, "appattestdevelop");
#[cfg(not(feature = "personal-dev-app-attest"))]
assert_eq!(
verify_proof(
&fixture,
&fixture.app_id,
&fixture.challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
),
Err(ProofError::InvalidAAGUID)
);
assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
&fixture.attestation_b64,
&fixture.key_id_b64,
fixture.challenge.as_bytes(),
)
.is_err());
}
#[cfg(feature = "personal-dev-app-attest")]
#[test]
fn development_mode_preserves_verification_and_rejects_production() {
let dev = fixture(WRONG_AAGUID_FIXTURE_JSON);
let mut v = verifier(&dev.app_id, dev.root_cert_pem.as_bytes());
v.environment = AppAttestEnvironment::Development;
assert!(v
.verify_attestation(
&dev.attestation_b64,
&dev.key_id_b64,
dev.challenge.as_bytes()
)
.is_ok());
let prod = fixture(GOOD_FIXTURE_JSON);
assert!(v
.verify_attestation(
&prod.attestation_b64,
&prod.key_id_b64,
prod.challenge.as_bytes()
)
.is_err());
assert!(v
.verify_attestation(&dev.attestation_b64, &dev.key_id_b64, b"wrong challenge")
.is_err());
assert!(v
.verify_attestation(
&dev.attestation_b64,
&STANDARD.encode([0; 32]),
dev.challenge.as_bytes()
)
.is_err());
v.app_id = "OTHER.wrong.app".into();
assert!(v
.verify_attestation(
&dev.attestation_b64,
&dev.key_id_b64,
dev.challenge.as_bytes()
)
.is_err());
v.app_id = dev.app_id;
v.apple_root_cert_pem = fixture(WRONG_ROOT_FIXTURE_JSON).root_cert_pem.into_bytes();
assert!(v
.verify_attestation(
&dev.attestation_b64,
&dev.key_id_b64,
dev.challenge.as_bytes()
)
.is_err());
}
#[cfg(feature = "personal-dev-app-attest")]
#[test]
fn nested_statement_cannot_bypass_environment_fence() {
for (json, environment, decoy_aaguid) in [
(
WRONG_AAGUID_FIXTURE_JSON,
AppAttestEnvironment::Production,
&b"appattest\0\0\0\0\0\0\0"[..],
),
(
GOOD_FIXTURE_JSON,
AppAttestEnvironment::Development,
&b"appattestdevelop"[..],
),
] {
let fixture = fixture(json);
let original = STANDARD.decode(&fixture.attestation_b64).unwrap();
let mut decoder = minicbor::Decoder::new(&original);
let count = decoder.map().unwrap().unwrap();
let mut auth = &[][..];
let mut statement = &[][..];
for _ in 0..count {
let key = decoder.str().unwrap();
let start = decoder.position();
decoder.skip().unwrap();
match key {
"authData" => auth = &original[start..decoder.position()],
"attStmt" => statement = &original[start..decoder.position()],
_ => {}
}
}
let mut decoy = [0u8; 53];
decoy[37..53].copy_from_slice(decoy_aaguid);
let mut buffer = vec![0; original.len() + 256];
let mut encoder =
minicbor::Encoder::new(minicbor::encode::write::Cursor::new(buffer.as_mut_slice()));
encoder
.map(5)
.unwrap()
.str("fmt")
.unwrap()
.str("apple-appattest")
.unwrap()
.str("authData")
.unwrap()
.bytes(&decoy)
.unwrap()
.str("attStmt")
.unwrap()
.begin_map()
.unwrap()
.str("attStmt")
.unwrap();
use minicbor::encode::Write;
encoder.writer_mut().write_all(statement).unwrap();
encoder.str("authData").unwrap();
encoder.writer_mut().write_all(auth).unwrap();
encoder
.end()
.unwrap()
.str("padding1")
.unwrap()
.u8(0)
.unwrap()
.str("padding2")
.unwrap()
.u8(0)
.unwrap();
let length = encoder.writer().position();
let malicious = &buffer[..length];
// 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!(
verifier
.verify_attestation(
&STANDARD.encode(malicious),
&fixture.key_id_b64,
fixture.challenge.as_bytes(),
)
.is_err(),
"cross-environment nested statement accepted: {environment:?}"
);
}
}
#[test]
fn environment_parser_rejects_ambiguous_or_truncated_auth_data() {
for data in [vec![], vec![0xa0], vec![0xa1, 0x68], vec![0xbf, 0xff]] {
assert!(proof::enroll(&data, b"challenge", "app", "key", b"root", false).is_err());
}
let mut data = [0u8; 256];
let mut encoder =
minicbor::Encoder::new(minicbor::encode::write::Cursor::new(data.as_mut_slice()));
let mut auth = [0u8; 53];
auth[37..53].copy_from_slice(b"appattest\0\0\0\0\0\0\0");
encoder
.map(2)
.unwrap()
.str("authData")
.unwrap()
.bytes(&auth)
.unwrap()
.str("authData")
.unwrap()
.bytes(&auth)
.unwrap();
let length = encoder.writer().position();
assert!(
proof::enroll(&data[..length], b"challenge", "app", "key", b"root", false).is_err()
);
}
#[test]
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_proof(
&fixture,
&fixture.app_id,
&fixture.challenge,
&key_id_b64,
fixture.root_cert_pem.as_bytes(),
)
.is_err());
assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
&fixture.attestation_b64,
&key_id_b64,
fixture.challenge.as_bytes(),
)
.is_err());
}
}
#[test]
#[allow(clippy::assertions_on_constants, unexpected_cfgs)]
fn gateway_test_build_does_not_define_testing_feature() {
assert!(!cfg!(feature = "testing"));
}
#[test]
fn constructor_still_pins_the_apple_root() {
let fixture = fixture(GOOD_FIXTURE_JSON);
assert!(
AppAttestVerifier::new(fixture.app_id.clone(), APPLE_ROOT_CERT_PEM.to_vec()).is_ok()
);
assert!(
AppAttestVerifier::new(fixture.app_id, fixture.root_cert_pem.as_bytes().to_vec(),)
.is_err()
);
}
}
#[cfg(test)]
#[path = "app_attest_assertion_tests.rs"]
mod assertion_tests;