mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +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> {
|
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() {
|
if tail.is_empty() {
|
||||||
return Ok(ClientProperties::default());
|
return Ok(ClientProperties::default());
|
||||||
}
|
}
|
||||||
@@ -65,8 +67,12 @@ pub(super) fn enrollment(
|
|||||||
if &credential_prefix[37..53] != expected {
|
if &credential_prefix[37..53] != expected {
|
||||||
return Err(Error::InvalidAAGUID);
|
return Err(Error::InvalidAAGUID);
|
||||||
}
|
}
|
||||||
let length = u16::from_be_bytes([credential_prefix[53], credential_prefix[54]]) as usize;
|
// App Attest credential IDs are SHA-256 hashes, always 32 bytes. Preserve
|
||||||
let end = 55 + length;
|
// 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 {
|
if bytes.get(55..end).ok_or(Error::AuthenticatorDataTooShort)? != key_id {
|
||||||
return Err(Error::InvalidCredentialID);
|
return Err(Error::InvalidCredentialID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,29 @@ fn signed_enrollment_accepts_optional_extensions_and_cose_variants() {
|
|||||||
.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]
|
#[test]
|
||||||
fn signed_enrollment_rejects_incomplete_or_inconsistent_credential_data() {
|
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.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[55] ^= 1).is_err());
|
||||||
assert!(verify(|auth| auth[36] = 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"\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| {
|
let fixture = build_test_attestation_with_auth_data(TRANSCRIPT, APP, |a| {
|
||||||
a.extend_from_slice(b"\xa1\x61x\x01")
|
a.extend_from_slice(b"\xa1\x61x\x01")
|
||||||
});
|
});
|
||||||
let mut envelope: ciborium::Value = ciborium::from_reader(fixture.cbor.as_slice()).unwrap();
|
// Even the compatibility length field must remain nonce-authenticated.
|
||||||
if let ciborium::Value::Map(fields) = &mut envelope {
|
for offset in [None, Some(53), Some(54)] {
|
||||||
for (key, value) in fields {
|
let mut envelope: ciborium::Value = ciborium::from_reader(fixture.cbor.as_slice()).unwrap();
|
||||||
if key == &ciborium::Value::Text("authData".into()) {
|
if let ciborium::Value::Map(fields) = &mut envelope {
|
||||||
let ciborium::Value::Bytes(auth) = value else {
|
for (key, value) in fields {
|
||||||
panic!("authData")
|
if key == &ciborium::Value::Text("authData".into()) {
|
||||||
};
|
let ciborium::Value::Bytes(auth) = value else {
|
||||||
*auth.last_mut().unwrap() = 2;
|
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());
|
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
|
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.
|
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
|
Every original authenticator byte participates in the certificate nonce or
|
||||||
assertion signature. Verification does not truncate or re-encode that byte
|
assertion signature. Verification does not truncate or re-encode that byte
|
||||||
string. Assertion signatures and enrollment certificate nonces are checked
|
string. Assertion signatures and enrollment certificate nonces are checked
|
||||||
|
|||||||
Reference in New Issue
Block a user