From 27f9f2624fcabd4918248c721e2a194dbed62bde Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 14 Oct 2026 13:54:31 -0700 Subject: [PATCH] Preserve fixed-size App Attest credential framing Signed-off-by: Tom Brow --- .../src/app_attest/authenticator.rs | 10 +++- .../src/app_attest/compatibility_tests.rs | 54 +++++++++++++------ docs/app-attest-compatibility.md | 9 ++++ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/buzz-push-gateway/src/app_attest/authenticator.rs b/crates/buzz-push-gateway/src/app_attest/authenticator.rs index 35e2517dbb..7bc4ed060d 100644 --- a/crates/buzz-push-gateway/src/app_attest/authenticator.rs +++ b/crates/buzz-push-gateway/src/app_attest/authenticator.rs @@ -30,6 +30,8 @@ fn header(bytes: &[u8], app_id: &str) -> Result { } 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()); } @@ -65,8 +67,12 @@ pub(super) fn enrollment( if &credential_prefix[37..53] != expected { return Err(Error::InvalidAAGUID); } - let length = u16::from_be_bytes([credential_prefix[53], credential_prefix[54]]) as usize; - let end = 55 + length; + // 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); } diff --git a/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs b/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs index f796528502..aa90d9f64d 100644 --- a/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs +++ b/crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs @@ -51,11 +51,29 @@ fn signed_enrollment_accepts_optional_extensions_and_cose_variants() { .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[54] = 31).is_err()); - assert!(verify(|auth| auth[54] = 33).is_err()); 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()); @@ -81,23 +99,27 @@ 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") }); - 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") - }; - *auth.last_mut().unwrap() = 2; + // 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) + )); } - 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()); } diff --git a/docs/app-attest-compatibility.md b/docs/app-attest-compatibility.md index 2ac658cdfa..fb453f9559 100644 --- a/docs/app-attest-compatibility.md +++ b/docs/app-attest-compatibility.md @@ -13,6 +13,15 @@ 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