mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Preserve fixed-size App Attest credential framing
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user