Preserve fixed-size App Attest credential framing

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-14 13:54:31 -07:00
parent 5849edcdb0
commit 27f9f2624f
3 changed files with 55 additions and 18 deletions

View File

@@ -30,6 +30,8 @@ fn header(bytes: &[u8], app_id: &str) -> Result<Checked, Error> {
}
fn properties(tail: &[u8]) -> Result<ClientProperties, Error> {
// 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);
}

View File

@@ -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());
}

View File

@@ -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