fix(push): verify App Attest proofs with a focused owned module

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-14 12:39:44 -07:00
parent e68977f270
commit 8b573da916
18 changed files with 1900 additions and 212 deletions

View File

@@ -4,11 +4,15 @@ exclude_rules_for_files:
- Cargo.lock
# Self-signed test fixture generated solely to exercise reqwest identity parsing.
kingfisher.privkey.2:
# Published upstream synthetic CA key, compiled only under cfg(test).
# The gateway pins Apple's root and cannot trust this fixture CA.
- "*appattest-test-ca-key.pem"
- "*apns-test-identity.pem"
- "*apns-test-key-only.pem"
- "*apns-test-encrypted-identity.pem"
- "*apns-test-mismatched-identity.pem"
np.pem.1:
- "*appattest-test-ca-key.pem"
- "*apns-test-identity.pem"
- "*apns-test-key-only.pem"
- "*apns-test-encrypted-identity.pem"

42
Cargo.lock generated
View File

@@ -158,24 +158,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "appattest"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7"
dependencies = [
"arrayvec",
"aws-lc-rs",
"base64 0.22.1",
"byteorder",
"ciborium",
"minicbor",
"openssl",
"p256",
"rustls-pki-types",
"rustls-webpki",
]
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
@@ -1403,21 +1385,25 @@ name = "buzz-push-gateway"
version = "0.1.0"
dependencies = [
"aes-gcm",
"appattest",
"async-trait",
"aws-lc-rs",
"axum",
"base64 0.22.1",
"byteorder",
"chrono",
"ciborium",
"getrandom 0.4.3",
"hex",
"metrics",
"metrics-exporter-prometheus",
"minicbor",
"nostr 0.44.7",
"openssl",
"p256",
"proptest",
"rand 0.10.1",
"reqwest 0.13.4",
"rustls-pki-types",
"rustls-webpki",
"serde",
"serde_json",
"sha2 0.11.0",
@@ -1430,6 +1416,7 @@ dependencies = [
"tracing-subscriber",
"url",
"uuid",
"x509-parser",
]
[[package]]
@@ -5963,6 +5950,21 @@ name = "minicbor"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0"
dependencies = [
"half",
"minicbor-derive",
]
[[package]]
name = "minicbor-derive"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "minidom"

View File

@@ -17,16 +17,19 @@ path = "src/main.rs"
[features]
# Personal device development only. Production builds remain production-only.
personal-dev-app-attest = ["appattest/testing"]
personal-dev-app-attest = []
[dependencies]
aes-gcm = "0.10"
appattest = { version = "0.1.1", default-features = false }
aws-lc-rs = { version = "1", default-features = false }
minicbor = { version = "0.25", features = ["std", "half"] }
p256 = { version = "0.13", features = ["ecdsa"] }
rustls-pki-types = { version = "1", features = ["std"] }
webpki = { package = "rustls-webpki", version = "0.103.9", default-features = false, features = ["aws-lc-rs", "alloc"] }
x509-parser = { version = "0.18.1", default-features = false }
axum = { workspace = true }
async-trait = "0.1"
base64 = "0.22"
byteorder = "1.5"
minicbor = "0.25"
chrono = { workspace = true }
hex = { workspace = true }
getrandom = "0.4"
@@ -49,5 +52,7 @@ url = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
openssl = "0.10"
ciborium = "0.2"
proptest = { workspace = true }
reqwest = { workspace = true }

View File

@@ -2,9 +2,17 @@
//! Apple production AAGUID material by default; personal development requires
//! an explicit build feature and environment. No cryptographic checks are skipped.
use crate::config::AppAttestEnvironment;
use appattest::{assertion::Assertion, attestation::Attestation};
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 byteorder::{BigEndian, ByteOrder};
use sha2::{Digest, Sha256};
use thiserror::Error;
@@ -76,32 +84,28 @@ impl AppAttestVerifier {
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);
}
verify_attestation_environment(&cbor, self.environment)?;
let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?;
let att = Attestation::from_cbor_bytes(&cbor).map_err(|_| AppAttestError::Invalid)?;
let (public_key, _) = att
.verify(
challenge,
&self.app_id,
key_id_b64,
&self.apple_root_cert_pem,
)
.map_err(|_| AppAttestError::Invalid)?;
let key_id = STANDARD
.decode(key_id_b64)
.map_err(|_| AppAttestError::Invalid)?;
if key_id.len() != 32 {
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,
public_key: public_key.to_vec(),
key_id: verified.key_id,
public_key: verified.public_key.to_vec(),
})
}
pub fn verify_assertion(
@@ -113,136 +117,71 @@ impl AppAttestVerifier {
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);
}
let counter = assertion_counter(&cbor)?;
let client_data_hash = Sha256::digest(client_data);
Assertion::from_assertion(&cbor)
.map_err(|_| AppAttestError::Invalid)?
.verify(
client_data_hash,
challenge,
&self.app_id,
public_key,
previous_counter,
stored_challenge,
)
.map_err(|_| AppAttestError::Invalid)?;
Ok(VerifiedAssertion { counter })
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,
})
}
}
// The dependency's development feature accepts both Apple environments. Fence
// the exact signed authData here, then let its full verifier validate those same
// bytes (chain, nonce, app ID, counter, public key and credential ID).
fn verify_attestation_environment(
cbor: &[u8],
environment: AppAttestEnvironment,
) -> Result<(), AppAttestError> {
let mut decoder = minicbor::Decoder::new(cbor);
let count = decoder
.map()
.map_err(|_| AppAttestError::Invalid)?
.ok_or(AppAttestError::Invalid)?;
if count != 3 {
return Err(AppAttestError::Invalid);
}
let mut auth_data = None;
let mut format_seen = false;
let mut statement_seen = false;
for _ in 0..count {
match decoder.str().map_err(|_| AppAttestError::Invalid)? {
"authData" if auth_data.is_none() => {
auth_data = Some(decoder.bytes().map_err(|_| AppAttestError::Invalid)?);
}
"fmt" if !format_seen => {
if decoder.str().map_err(|_| AppAttestError::Invalid)? != "apple-appattest" {
return Err(AppAttestError::Invalid);
}
format_seen = true;
}
"attStmt" if !statement_seen => {
// appattest 0.1.1 treats indefinite containers as empty without
// consuming their contents. Accept only the definite schema so
// its cursor cannot reinterpret nested fields as root fields.
if decoder.map().map_err(|_| AppAttestError::Invalid)? != Some(2) {
return Err(AppAttestError::Invalid);
}
let mut certs_seen = false;
let mut receipt_seen = false;
for _ in 0..2 {
match decoder.str().map_err(|_| AppAttestError::Invalid)? {
"x5c" if !certs_seen => {
let count = decoder
.array()
.map_err(|_| AppAttestError::Invalid)?
.filter(|count| (1..=3).contains(count))
.ok_or(AppAttestError::Invalid)?;
for _ in 0..count {
decoder.bytes().map_err(|_| AppAttestError::Invalid)?;
}
certs_seen = true;
}
"receipt" if !receipt_seen => {
decoder.bytes().map_err(|_| AppAttestError::Invalid)?;
receipt_seen = true;
}
_ => return Err(AppAttestError::Invalid),
}
}
statement_seen = true;
}
_ => return Err(AppAttestError::Invalid),
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"
}
}
if decoder.position() != cbor.len() {
return Err(AppAttestError::Invalid);
}
let expected: &[u8] = match environment {
AppAttestEnvironment::Production => b"appattest\0\0\0\0\0\0\0",
#[cfg(feature = "personal-dev-app-attest")]
AppAttestEnvironment::Development => b"appattestdevelop",
};
if auth_data.and_then(|data| data.get(37..53)) != Some(expected) {
return Err(AppAttestError::Invalid);
}
Ok(())
tracing::debug!(reason, "App Attest verification rejected");
AppAttestError::Invalid
}
/// App Attest assertion CBOR is a closed two-field map. Extracting signCount
/// from authenticatorData is safe only after the library verifies the same
/// bytes' RP ID, signature, and monotonic relation.
fn assertion_counter(cbor: &[u8]) -> Result<u32, AppAttestError> {
let mut d = minicbor::Decoder::new(cbor);
let count = d
.map()
.map_err(|_| AppAttestError::Invalid)?
.ok_or(AppAttestError::Invalid)?;
let mut auth = None;
for _ in 0..count {
let k = d.str().map_err(|_| AppAttestError::Invalid)?;
match k {
"authenticatorData" => auth = Some(d.bytes().map_err(|_| AppAttestError::Invalid)?),
"signature" => {
d.bytes().map_err(|_| AppAttestError::Invalid)?;
}
_ => return Err(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,
}
let auth = auth
.filter(|a| a.len() == 37)
.ok_or(AppAttestError::Invalid)?;
Ok(BigEndian::read_u32(&auth[33..37]))
}
#[cfg(test)]
mod tests {
use super::error::AppAttestError as ProofError;
use super::*;
use appattest::error::AppAttestError as DependencyAppAttestError;
use serde::Deserialize;
const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json");
@@ -278,28 +217,32 @@ mod tests {
}
}
fn verify_dependency(
fn verify_proof(
fixture: &Fixture,
app_id: &str,
challenge: &str,
key_id_b64: &str,
root_cert_pem: &[u8],
) -> Result<(), DependencyAppAttestError> {
) -> Result<(), ProofError> {
let cbor = STANDARD
.decode(&fixture.attestation_b64)
.expect("fixture attestation is base64");
let attestation = Attestation::from_cbor_bytes(&cbor)?;
let result = attestation
.verify(challenge, app_id, key_id_b64, root_cert_pem)
.map(|_| ());
result
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_dependency(
verify_proof(
&fixture,
&fixture.app_id,
&fixture.challenge,
@@ -323,7 +266,7 @@ mod tests {
fn wrong_root_is_rejected() {
let good = fixture(GOOD_FIXTURE_JSON);
let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON);
assert!(verify_dependency(
assert!(verify_proof(
&wrong_root,
&wrong_root.app_id,
&wrong_root.challenge,
@@ -345,14 +288,14 @@ mod tests {
let fixture = fixture(GOOD_FIXTURE_JSON);
let wrong_app_id = "TEAMID.xyz.buzz.wrong";
assert_eq!(
verify_dependency(
verify_proof(
&fixture,
wrong_app_id,
&fixture.challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
),
Err(DependencyAppAttestError::InvalidAppID)
Err(ProofError::InvalidAppID)
);
assert!(verifier(wrong_app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
@@ -368,14 +311,14 @@ mod tests {
let fixture = fixture(GOOD_FIXTURE_JSON);
let wrong_challenge = "wrong-challenge";
assert_eq!(
verify_dependency(
verify_proof(
&fixture,
&fixture.app_id,
wrong_challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
),
Err(DependencyAppAttestError::InvalidNonce)
Err(ProofError::InvalidNonce)
);
assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
@@ -392,14 +335,14 @@ mod tests {
assert_eq!(fixture.aaguid, "appattestdevelop");
#[cfg(not(feature = "personal-dev-app-attest"))]
assert_eq!(
verify_dependency(
verify_proof(
&fixture,
&fixture.app_id,
&fixture.challenge,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
),
Err(DependencyAppAttestError::InvalidAAGUID)
Err(ProofError::InvalidAAGUID)
);
assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes())
.verify_attestation(
@@ -416,19 +359,6 @@ mod tests {
let dev = fixture(WRONG_AAGUID_FIXTURE_JSON);
let mut v = verifier(&dev.app_id, dev.root_cert_pem.as_bytes());
v.environment = AppAttestEnvironment::Development;
let cbor = STANDARD.decode(&dev.attestation_b64).unwrap();
assert!(
verify_attestation_environment(&cbor, v.environment).is_ok(),
"environment parser"
);
verify_dependency(
&dev,
&dev.app_id,
&dev.challenge,
&dev.key_id_b64,
dev.root_cert_pem.as_bytes(),
)
.expect("development dependency");
assert!(v
.verify_attestation(
&dev.attestation_b64,
@@ -543,16 +473,7 @@ mod tests {
.unwrap();
let length = encoder.writer().position();
let malicious = &buffer[..length];
// Prove this envelope reaches valid signed material in the dependency.
Attestation::from_cbor_bytes(malicious)
.unwrap()
.verify(
&fixture.challenge,
&fixture.app_id,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
)
.unwrap();
// 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!(
@@ -571,9 +492,7 @@ mod tests {
#[test]
fn environment_parser_rejects_ambiguous_or_truncated_auth_data() {
for data in [vec![], vec![0xa0], vec![0xa1, 0x68], vec![0xbf, 0xff]] {
assert!(
verify_attestation_environment(&data, AppAttestEnvironment::Production).is_err()
);
assert!(proof::enroll(&data, b"challenge", "app", "key", b"root", false).is_err());
}
let mut data = [0u8; 256];
let mut encoder =
@@ -593,8 +512,7 @@ mod tests {
.unwrap();
let length = encoder.writer().position();
assert!(
verify_attestation_environment(&data[..length], AppAttestEnvironment::Production)
.is_err()
proof::enroll(&data[..length], b"challenge", "app", "key", b"root", false).is_err()
);
}
@@ -602,7 +520,7 @@ mod tests {
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_dependency(
assert!(verify_proof(
&fixture,
&fixture.app_id,
&fixture.challenge,
@@ -638,3 +556,7 @@ mod tests {
);
}
}
#[cfg(test)]
#[path = "app_attest_assertion_tests.rs"]
mod assertion_tests;

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Peter Farr
Copyright (c) 2024 Ayodeji Akinola
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,133 @@
//! Operation-specific framing. No optional credential state exists on assertions.
use super::{
cbor::{self, Value},
error::AppAttestError as Error,
properties::ClientProperties,
};
use aws_lc_rs::digest::{digest, SHA256};
use p256::elliptic_curve::sec1::ToEncodedPoint;
pub(super) struct Checked {
pub counter: u32,
pub flags: u8,
pub properties: ClientProperties,
}
fn header(bytes: &[u8], app_id: &str) -> Result<Checked, Error> {
let prefix = bytes.get(..37).ok_or(Error::AuthenticatorDataTooShort)?;
if prefix[..32] != *digest(&SHA256, app_id.as_bytes()).as_ref() {
return Err(Error::InvalidAppID);
}
Ok(Checked {
counter: u32::from_be_bytes(
prefix[33..37]
.try_into()
.map_err(|_| Error::AuthenticatorDataTooShort)?,
),
flags: prefix[32],
properties: ClientProperties::default(),
})
}
fn properties(tail: &[u8]) -> Result<ClientProperties, Error> {
if tail.is_empty() {
return Ok(ClientProperties::default());
}
ClientProperties::from_extensions(&cbor::complete(tail)?)
}
pub(super) fn assertion(bytes: &[u8], app_id: &str, previous: u32) -> Result<Checked, Error> {
let mut checked = header(bytes, app_id)?;
if checked.counter <= previous {
return Err(Error::InvalidCounter);
}
checked.properties = properties(&bytes[37..])?;
Ok(checked)
}
pub(super) fn enrollment(
bytes: &[u8],
app_id: &str,
key_id: &[u8],
public_key: &[u8; 65],
development: bool,
) -> Result<Checked, Error> {
let mut checked = header(bytes, app_id)?;
if checked.counter != 0 {
return Err(Error::InvalidCounter);
}
let credential_prefix = bytes.get(..55).ok_or(Error::AuthenticatorDataTooShort)?;
let expected = if development {
b"appattestdevelop"
} else {
b"appattest\0\0\0\0\0\0\0"
};
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;
if bytes.get(55..end).ok_or(Error::AuthenticatorDataTooShort)? != key_id {
return Err(Error::InvalidCredentialID);
}
let tail = bytes.get(end..).ok_or(Error::AuthenticatorDataTooShort)?;
let (key, consumed) = credential_key(tail)?;
if &key != public_key {
return Err(Error::InvalidPublicKey);
}
checked.properties = properties(&tail[consumed..])?;
Ok(checked)
}
fn credential_key(tail: &[u8]) -> Result<([u8; 65], usize), Error> {
let (cose, consumed) = cbor::prefix(tail)?;
let map = cose.map()?;
if !map
.keys()
.all(|k| matches!(k, Value::Integer(_) | Value::Text(_)))
{
return Err(Error::InvalidPublicKey);
}
for (label, expected) in [(1, 2), (3, -7), (-1, 1)] {
if map.get(&Value::Integer(label)) != Some(&Value::Integer(expected)) {
return Err(Error::InvalidPublicKey);
}
}
if let Some(operations) = map.get(&Value::Integer(4)) {
match operations {
Value::Array(v)
if v.iter().all(|op| matches!(op, Value::Integer(_)))
&& v.contains(&Value::Integer(2)) => {}
_ => return Err(Error::InvalidPublicKey),
}
}
let x = map
.get(&Value::Integer(-2))
.ok_or(Error::InvalidPublicKey)?
.bytes()?;
if x.len() != 32 {
return Err(Error::InvalidPublicKey);
}
let mut encoded = Vec::with_capacity(65);
match map.get(&Value::Integer(-3)) {
Some(Value::Bytes(y)) if y.len() == 32 => {
encoded.push(4);
encoded.extend_from_slice(x);
encoded.extend_from_slice(y);
}
Some(Value::Simple(parity @ (20 | 21))) => {
encoded.push(if *parity == 21 { 3 } else { 2 });
encoded.extend_from_slice(x);
}
_ => return Err(Error::InvalidPublicKey),
}
let key = p256::PublicKey::from_sec1_bytes(&encoded).map_err(|_| Error::InvalidPublicKey)?;
Ok((
key.to_encoded_point(false)
.as_bytes()
.try_into()
.map_err(|_| Error::InvalidPublicKey)?,
consumed,
))
}

View File

@@ -0,0 +1,277 @@
//! Bounded CBOR receiver profile, built on minicbor's primitive decoder.
//!
//! Map equality follows RFC 8949 section 5.6.1, including compound keys. The
//! ordered representation is for duplicate detection only, never for signing.
use super::error::AppAttestError;
use minicbor::{data::Type, Decoder};
use std::collections::BTreeMap;
// Operational ceilings, not Apple protocol constants. Keep these separate from
// transport limits and report capacity failures distinctly.
pub(super) const MAX_BYTES: usize = 64 * 1024;
const MAX_ITEMS: usize = 4096;
const MAX_DEPTH: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Value {
Integer(i128),
Bytes(Vec<u8>),
Text(String),
Array(Vec<Value>),
Map(BTreeMap<Value, Value>),
Tag(u64, Box<Value>),
Simple(u8),
Float(u64),
Nan(u64),
}
impl Value {
pub(super) fn map(&self) -> Result<&BTreeMap<Value, Value>, AppAttestError> {
match self {
Self::Map(v) => Ok(v),
_ => Err(AppAttestError::InvalidCbor),
}
}
pub(super) fn bytes(&self) -> Result<&[u8], AppAttestError> {
match self {
Self::Bytes(v) => Ok(v),
_ => Err(AppAttestError::InvalidCbor),
}
}
pub(super) fn field(&self, key: &str) -> Result<&Value, AppAttestError> {
self.map()?
.get(&Value::Text(key.into()))
.ok_or(AppAttestError::InvalidCbor)
}
pub(super) fn text_keys(&self) -> Result<(), AppAttestError> {
if self.map()?.keys().all(|k| matches!(k, Self::Text(_))) {
Ok(())
} else {
Err(AppAttestError::InvalidCbor)
}
}
}
pub(super) fn complete(bytes: &[u8]) -> Result<Value, AppAttestError> {
let (value, consumed) = prefix(bytes)?;
if consumed != bytes.len() {
return Err(AppAttestError::InvalidCbor);
}
Ok(value)
}
pub(super) fn prefix(bytes: &[u8]) -> Result<(Value, usize), AppAttestError> {
if bytes.len() > MAX_BYTES {
return Err(AppAttestError::ResourceLimit);
}
let mut reader = Reader {
decoder: Decoder::new(bytes),
items: 0,
copied: 0,
};
let value = reader.value(0)?;
Ok((value, reader.decoder.position()))
}
struct Reader<'a> {
decoder: Decoder<'a>,
items: usize,
copied: usize,
}
impl Reader<'_> {
fn item(&mut self) -> Result<(), AppAttestError> {
self.items += 1;
if self.items > MAX_ITEMS {
return Err(AppAttestError::ResourceLimit);
}
Ok(())
}
fn copy(&mut self, size: usize) -> Result<(), AppAttestError> {
self.copied = self
.copied
.checked_add(size)
.ok_or(AppAttestError::ResourceLimit)?;
if self.copied > MAX_BYTES {
return Err(AppAttestError::ResourceLimit);
}
Ok(())
}
fn end(&mut self, length: Option<u64>, seen: u64) -> Result<bool, AppAttestError> {
if let Some(length) = length {
return Ok(seen == length);
}
if self.decoder.datatype()? == Type::Break {
self.decoder.skip()?;
return Ok(true);
}
Ok(false)
}
fn value(&mut self, depth: usize) -> Result<Value, AppAttestError> {
if depth > MAX_DEPTH {
return Err(AppAttestError::ResourceLimit);
}
self.item()?;
match self.decoder.datatype()? {
Type::U8
| Type::U16
| Type::U32
| Type::U64
| Type::I8
| Type::I16
| Type::I32
| Type::I64
| Type::Int => Ok(Value::Integer(self.decoder.int()?.into())),
Type::Bytes | Type::BytesIndef => {
let mut out = Vec::new();
// Each chunk is at most the bounded input size. No allocation
// is based solely on an untrusted declared length.
let chunks = self.decoder.bytes_iter()?;
for chunk in chunks {
let chunk = chunk?;
if out.len() + chunk.len() > MAX_BYTES {
return Err(AppAttestError::ResourceLimit);
}
out.extend_from_slice(chunk);
}
self.copy(out.len())?;
Ok(Value::Bytes(out))
}
Type::String | Type::StringIndef => {
let mut out = String::new();
for chunk in self.decoder.str_iter()? {
let chunk = chunk?;
if out.len() + chunk.len() > MAX_BYTES {
return Err(AppAttestError::ResourceLimit);
}
out.push_str(chunk);
}
self.copy(out.len())?;
Ok(Value::Text(out))
}
Type::Array | Type::ArrayIndef => {
let length = self.decoder.array()?;
let mut out = Vec::new();
while !self.end(length, out.len() as u64)? {
out.push(self.value(depth + 1)?);
}
Ok(Value::Array(out))
}
Type::Map | Type::MapIndef => {
let length = self.decoder.map()?;
let mut out = BTreeMap::new();
while !self.end(length, out.len() as u64)? {
let key = self.value(depth + 1)?;
let value = self.value(depth + 1)?;
if out.insert(key, value).is_some() {
return Err(AppAttestError::InvalidCbor);
}
}
Ok(Value::Map(out))
}
Type::Tag => {
let tag = self.decoder.tag()?.as_u64();
Ok(Value::Tag(tag, Box::new(self.value(depth + 1)?)))
}
Type::Bool => Ok(Value::Simple(if self.decoder.bool()? { 21 } else { 20 })),
Type::Null => {
self.decoder.null()?;
Ok(Value::Simple(22))
}
Type::Undefined => {
self.decoder.undefined()?;
Ok(Value::Simple(23))
}
Type::Simple => {
let position = self.decoder.position();
let value = self.decoder.simple()?;
// RFC 8949 3.3: f8 followed by a value below 32 is not well formed.
if self.decoder.position() - position == 2 && value < 32 {
return Err(AppAttestError::InvalidCbor);
}
Ok(Value::Simple(value))
}
Type::F16 | Type::F32 | Type::F64 => {
let position = self.decoder.position();
let number = self.decoder.f64()?;
if number.is_nan() {
// Preserve signaling/payload bits before a floating-point
// conversion can quiet a NaN. The sign is not significand.
let raw = &self.decoder.input()[position + 1..self.decoder.position()];
let bits = raw.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b));
let width = match raw.len() {
2 => 10,
4 => 23,
8 => 52,
_ => return Err(AppAttestError::InvalidCbor),
};
Ok(Value::Nan((bits & ((1u64 << width) - 1)) << (64 - width)))
} else {
Ok(Value::Float(if number == 0.0 {
0
} else {
number.to_bits()
}))
}
}
_ => Err(AppAttestError::InvalidCbor),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_keys_use_generic_cbor_equivalence() {
for bytes in [
&b"\xa2\x01\x00\x18\x01\x01"[..],
&b"\xa2\x81\x01\x00\x81\x18\x01\x01"[..],
&b"\xa2\xa2\x01\x02\x03\x04\x00\xa2\x03\x04\x01\x02\x01"[..],
&b"\xa2\xf9\x00\x00\x00\xf9\x80\x00\x01"[..],
&b"\xa2\xf9\x7e\x00\x00\xfa\x7f\xc0\x00\x00\x01"[..],
&b"\xa2\x61x\x00\x7f\x61x\xff\x01"[..],
] {
assert!(complete(bytes).is_err(), "{bytes:?}");
}
assert!(complete(b"\xa2\x01\x00\xf9\x3c\x00\x01").is_ok());
}
#[test]
fn bounded_opaque_values_and_nonpreferred_encodings() {
assert!(complete(b"\xbf\x61x\x9f\xc1\xf8\xff\x18\x01\xff\xff").is_ok());
for bytes in [
&b"\xff"[..],
&b"\xa0\x00"[..],
&b"\xf8\x01"[..],
&b"\x61\xff"[..],
&b"\xbf\x01\xff"[..],
] {
assert!(complete(bytes).is_err());
}
let mut deep = vec![0x81; MAX_DEPTH + 2];
deep.push(0);
assert_eq!(complete(&deep), Err(AppAttestError::ResourceLimit));
}
#[test]
fn capacity_boundaries_and_untrusted_lengths() {
let mut at_depth = vec![0x81; MAX_DEPTH];
at_depth.push(0);
assert!(complete(&at_depth).is_ok());
let mut at_items = vec![0x9f];
at_items.extend(std::iter::repeat_n(0, MAX_ITEMS - 1));
at_items.push(0xff);
assert!(complete(&at_items).is_ok());
at_items.insert(1, 0);
assert_eq!(complete(&at_items), Err(AppAttestError::ResourceLimit));
let mut at_bytes = vec![0x59, 0xff, 0xfd];
at_bytes.resize(MAX_BYTES, 0);
assert!(complete(&at_bytes).is_ok());
at_bytes.push(0);
assert_eq!(complete(&at_bytes), Err(AppAttestError::ResourceLimit));
for prefix in [0x5b, 0x7b, 0x9b, 0xbb] {
let mut huge_length = vec![prefix];
huge_length.extend_from_slice(&u64::MAX.to_be_bytes());
assert!(complete(&huge_length).is_err());
}
}
}

View File

@@ -0,0 +1,118 @@
//! Apple certificate adapters; path and signature validation remain in webpki.
use super::error::AppAttestError as Error;
use aws_lc_rs::digest::{digest, SHA256};
use p256::elliptic_curve::sec1::ToEncodedPoint;
use rustls_pki_types::{CertificateDer, UnixTime};
use x509_parser::{certificate::X509Certificate, parse_x509_certificate};
static SIGNATURE_ALGORITHMS: &[&dyn rustls_pki_types::SignatureVerificationAlgorithm] = &[
webpki::aws_lc_rs::ECDSA_P256_SHA256,
webpki::aws_lc_rs::ECDSA_P256_SHA384,
webpki::aws_lc_rs::ECDSA_P384_SHA256,
webpki::aws_lc_rs::ECDSA_P384_SHA384,
];
// App Attest is not TLS. Preserve the supported Apple purpose profile while
// consuming and validating every OID; do not skip malformed EKU input.
struct AppleUsage;
impl webpki::ExtendedKeyUsageValidator for AppleUsage {
fn validate(&self, iter: webpki::KeyPurposeIdIter<'_, '_>) -> Result<(), webpki::Error> {
for oid in iter {
oid?;
}
Ok(())
}
}
fn parsed(bytes: &[u8]) -> Result<X509Certificate<'_>, Error> {
let (rest, cert) = parse_x509_certificate(bytes).map_err(|_| Error::InvalidCertificate)?;
if !rest.is_empty() {
return Err(Error::InvalidCertificate);
}
// RFC 5280 requires each extension at most once, including unknown OIDs.
cert.extensions_map()
.map_err(|_| Error::InvalidCertificate)?;
Ok(cert)
}
pub(super) fn verify_chain(certificates: &[Vec<u8>], root: &[u8]) -> Result<(), Error> {
let leaf = certificates.first().ok_or(Error::InvalidCertificate)?;
let root = CertificateDer::from(root);
let anchor = webpki::anchor_from_trusted_cert(&root).map_err(|_| Error::InvalidCertificate)?;
let leaf_der = CertificateDer::from(leaf.as_slice());
let end = webpki::EndEntityCert::try_from(&leaf_der).map_err(|_| Error::InvalidCertificate)?;
let intermediates: Vec<_> = certificates[1..]
.iter()
.map(|c| CertificateDer::from(c.as_slice()))
.collect();
end.verify_for_usage(
SIGNATURE_ALGORITHMS,
&[anchor],
&intermediates,
UnixTime::now(),
AppleUsage,
None,
None,
)
.map_err(|_| Error::InvalidCertificate)?;
for (index, bytes) in certificates.iter().enumerate() {
let cert = parsed(bytes)?;
if let Some(usage) = cert.key_usage().map_err(|_| Error::InvalidCertificate)? {
if (index == 0 && !usage.value.digital_signature())
|| (index > 0 && !usage.value.key_cert_sign())
{
return Err(Error::InvalidCertificate);
}
}
}
Ok(())
}
pub(super) fn public_key(bytes: &[u8]) -> Result<[u8; 65], Error> {
let cert = parsed(bytes)?;
let spki = cert.public_key();
if spki.algorithm.algorithm.to_id_string() != "1.2.840.10045.2.1"
|| spki
.algorithm
.parameters
.as_ref()
.and_then(|p| p.as_oid().ok())
.map(|p| p.to_id_string())
.as_deref()
!= Some("1.2.840.10045.3.1.7")
|| spki.subject_public_key.unused_bits != 0
{
return Err(Error::InvalidPublicKey);
}
let key = p256::PublicKey::from_sec1_bytes(spki.subject_public_key.data.as_ref())
.map_err(|_| Error::InvalidPublicKey)?;
key.to_encoded_point(false)
.as_bytes()
.try_into()
.map_err(|_| Error::InvalidPublicKey)
}
pub(super) fn verify_nonce(bytes: &[u8], expected: &[u8]) -> Result<(), Error> {
let cert = parsed(bytes)?;
let extension = cert
.extensions()
.iter()
.find(|e| e.oid.to_id_string() == "1.2.840.113635.100.8.2")
.ok_or(Error::InvalidNonce)?;
// DER has exactly one encoding for SEQUENCE { [1] EXPLICIT OCTET STRING
// (SIZE(32)) }. Comparing that complete encoding avoids a custom DER walker
// and rejects extra fields/trailing bytes/noncanonical lengths.
let mut encoded = vec![0x30, 0x24, 0xa1, 0x22, 0x04, 0x20];
if expected.len() != 32 {
return Err(Error::InvalidNonce);
}
encoded.extend_from_slice(expected);
if extension.value != encoded {
return Err(Error::InvalidNonce);
}
Ok(())
}
pub(super) fn key_id(key: &[u8]) -> Vec<u8> {
digest(&SHA256, key).as_ref().to_vec()
}

View File

@@ -0,0 +1,137 @@
//! Signed enrollment fixtures exercise structure independently of path trust.
use super::testing::{build_test_attestation_with_auth_data, TEST_ROOT_CA_CERT_PEM};
use super::{error::AppAttestError as Error, proof};
use base64::{engine::general_purpose::STANDARD, Engine};
const APP: &str = "TEAMID.test.compatibility";
const TRANSCRIPT: &str = "test enrollment transcript";
fn verify(change: impl FnOnce(&mut Vec<u8>)) -> Result<(), Error> {
let fixture = build_test_attestation_with_auth_data(TRANSCRIPT, APP, change);
enroll(&fixture.cbor, &fixture.key_id).map(|_| ())
}
fn enroll(bytes: &[u8], key_id: &str) -> Result<proof::Enrollment, Error> {
proof::enroll(
bytes,
TRANSCRIPT.as_bytes(),
APP,
key_id,
TEST_ROOT_CA_CERT_PEM,
false,
)
}
#[test]
fn signed_enrollment_accepts_optional_extensions_and_cose_variants() {
for tail in [
&b""[..],
&b"\xa0"[..],
&b"\xa1\x66future\x82\xc1\xf8\xff\x01"[..],
] {
verify(|auth| auth.extend_from_slice(tail)).unwrap();
}
// COSE's x coordinate remains the same; y may use its parity form.
verify(|auth| {
let mut key: ciborium::Value = ciborium::from_reader(&auth[87..]).unwrap();
if let ciborium::Value::Map(fields) = &mut key {
for (label, value) in fields.iter_mut() {
if label == &ciborium::Value::Integer((-3).into()) {
let ciborium::Value::Bytes(y) = value else {
panic!("fixture y")
};
*value = ciborium::Value::Bool(y[31] & 1 == 1);
}
}
fields.reverse();
}
auth.truncate(87);
ciborium::into_writer(&key, auth).unwrap();
})
.unwrap();
}
#[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());
assert!(verify(|auth| auth.extend_from_slice(b"\xa2\x61x\x01\x61x\x02")).is_err());
assert!(verify(|auth| {
// Valid key with the wrong algorithm label remains invalid even signed.
let mut key: ciborium::Value = ciborium::from_reader(&auth[87..]).unwrap();
if let ciborium::Value::Map(fields) = &mut key {
for (label, value) in fields {
if label == &ciborium::Value::Integer(3.into()) {
*value = ciborium::Value::Integer((-8).into());
}
}
}
auth.truncate(87);
ciborium::into_writer(&key, auth).unwrap();
})
.is_err());
}
#[test]
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;
}
}
}
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());
}
#[test]
fn shared_signed_certificate_constraint_corpus() {
super::testing::compare_certificate_constraints(
super::certificate::verify_chain,
super::certificate::verify_nonce,
);
}
#[test]
fn enrolled_key_verifies_following_assertion() {
use super::testing::{build_test_assertion, build_test_attestation};
use sha2::{Digest, Sha256};
let fixture = build_test_attestation(TRANSCRIPT, APP);
let enrolled = enroll(&fixture.cbor, &fixture.key_id).unwrap();
let hash = Sha256::digest(TRANSCRIPT.as_bytes());
let assertion = build_test_assertion(APP, hash, 0, &fixture.device_key);
let checked = proof::assert(
&assertion,
TRANSCRIPT.as_bytes(),
APP,
&enrolled.public_key,
0,
)
.unwrap();
assert_eq!(checked.counter, 1);
assert!(proof::assert(
&assertion,
TRANSCRIPT.as_bytes(),
APP,
&enrolled.public_key,
1
)
.is_err());
}

View File

@@ -0,0 +1,51 @@
use std::{error::Error, fmt};
#[derive(Debug, PartialEq)]
pub enum AppAttestError {
/// Certificate structure or trust validation failed.
InvalidCertificate,
/// CBOR is malformed, ambiguous, or does not match the operation schema.
InvalidCbor,
/// An operational parsing budget was exceeded.
ResourceLimit,
InvalidNonce,
InvalidPublicKey,
InvalidCounter,
InvalidCredentialID,
InvalidAAGUID,
InvalidSignature,
InvalidAppID,
InvalidFormat,
AuthenticatorDataTooShort,
}
impl fmt::Display for AppAttestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidCertificate => write!(f, "invalid certificate path or structure"),
Self::InvalidCbor => write!(f, "invalid CBOR structure"),
Self::ResourceLimit => write!(f, "verification resource limit"),
AppAttestError::InvalidNonce => write!(f, "invalid nonce"),
AppAttestError::InvalidPublicKey => write!(f, "invalid public key"),
AppAttestError::InvalidCounter => write!(f, "invalid counter"),
AppAttestError::InvalidCredentialID => write!(f, "invalid credential ID"),
AppAttestError::InvalidAAGUID => write!(f, "invalid AAGUID"),
AppAttestError::InvalidSignature => write!(f, "invalid signature"),
AppAttestError::InvalidAppID => write!(f, "invalid App ID"),
AppAttestError::InvalidFormat => {
write!(f, "invalid attestation format (expected apple-appattest)")
}
AppAttestError::AuthenticatorDataTooShort => {
write!(f, "authenticator data is too short")
}
}
}
}
impl Error for AppAttestError {}
impl From<minicbor::decode::Error> for AppAttestError {
fn from(_: minicbor::decode::Error) -> Self {
Self::InvalidCbor
}
}

View File

@@ -0,0 +1,110 @@
//! Buzz's two App Attest operations, using established signature and path validators.
use super::{
authenticator,
cbor::{self, Value},
certificate,
error::AppAttestError as Error,
properties::ClientProperties,
};
use aws_lc_rs::{
digest::{digest, Context, SHA256},
signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1},
};
use base64::{engine::general_purpose::STANDARD, Engine};
pub(super) struct Enrollment {
pub key_id: Vec<u8>,
pub public_key: [u8; 65],
pub flags: u8,
pub properties: ClientProperties,
}
pub(super) struct Assertion {
pub counter: u32,
pub flags: u8,
pub properties: ClientProperties,
}
// Both operations authenticate precisely the received bytes with this nonce.
fn nonce(auth: &[u8], transcript: &[u8]) -> aws_lc_rs::digest::Digest {
let mut hash = Context::new(&SHA256);
hash.update(auth);
hash.update(digest(&SHA256, transcript).as_ref());
hash.finish()
}
pub(super) fn enroll(
bytes: &[u8],
transcript: &[u8],
app_id: &str,
key_id: &str,
root_pem: &[u8],
development: bool,
) -> Result<Enrollment, Error> {
let envelope = cbor::complete(bytes)?;
envelope.text_keys()?;
if envelope.field("fmt")? != &Value::Text("apple-appattest".into()) {
return Err(Error::InvalidFormat);
}
let statement = envelope.field("attStmt")?;
statement.text_keys()?;
let Value::Array(chain) = statement.field("x5c")? else {
return Err(Error::InvalidCertificate);
};
let certificates = chain
.iter()
.map(|c| c.bytes().map(|b| b.to_vec()))
.collect::<Result<Vec<_>, _>>()?;
let leaf = certificates.first().ok_or(Error::InvalidCertificate)?;
statement.field("receipt")?.bytes()?;
let auth_bytes = envelope.field("authData")?.bytes()?;
if root_pem.len() > cbor::MAX_BYTES {
return Err(Error::ResourceLimit);
}
let (rest, root) =
x509_parser::pem::parse_x509_pem(root_pem).map_err(|_| Error::InvalidCertificate)?;
if root.label != "CERTIFICATE" || !rest.iter().all(u8::is_ascii_whitespace) {
return Err(Error::InvalidCertificate);
}
certificate::verify_chain(&certificates, &root.contents)?;
let public_key = certificate::public_key(leaf)?;
if key_id.len() > 44 {
return Err(Error::InvalidPublicKey);
}
let key_id = STANDARD
.decode(key_id)
.map_err(|_| Error::InvalidPublicKey)?;
if key_id.len() != 32 || certificate::key_id(&public_key) != key_id {
return Err(Error::InvalidPublicKey);
}
certificate::verify_nonce(leaf, nonce(auth_bytes, transcript).as_ref())?;
let auth = authenticator::enrollment(auth_bytes, app_id, &key_id, &public_key, development)?;
Ok(Enrollment {
key_id,
public_key,
flags: auth.flags,
properties: auth.properties,
})
}
pub(super) fn assert(
bytes: &[u8],
transcript: &[u8],
app_id: &str,
public_key: &[u8],
previous_counter: u32,
) -> Result<Assertion, Error> {
let envelope = cbor::complete(bytes)?;
envelope.text_keys()?;
let auth_bytes = envelope.field("authenticatorData")?.bytes()?;
let signature = envelope.field("signature")?.bytes()?;
UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, public_key)
.verify(nonce(auth_bytes, transcript).as_ref(), signature)
.map_err(|_| Error::InvalidSignature)?;
let auth = authenticator::assertion(auth_bytes, app_id, previous_counter)?;
Ok(Assertion {
counter: auth.counter,
flags: auth.flags,
properties: auth.properties,
})
}

View File

@@ -0,0 +1,39 @@
//! Authenticated optional Apple properties. These never authorize a request.
use super::{cbor::Value, error::AppAttestError};
/// Bounded observations, without coercing unsupported optional representations.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientProperties {
/// Text version when representable within the diagnostic length budget.
pub bundle_version: Option<String>,
/// Raw four-byte category. The profile does not assume undocumented endian.
pub validation_category: Option<[u8; 4]>,
/// A known property was present with an unsupported type or diagnostic size.
pub opaque_known_property: bool,
/// Unknown identifiers ignored after structural and cryptographic checks.
pub unknown_extensions: usize,
}
impl ClientProperties {
pub(super) fn from_extensions(value: &Value) -> Result<Self, AppAttestError> {
value.text_keys()?;
let mut out = Self::default();
for (key, value) in value.map()? {
match key {
Value::Text(name) if name == "apple_bundle_version_01" => match value {
Value::Text(version) if version.len() <= 128 => {
out.bundle_version = Some(version.clone())
}
_ => out.opaque_known_property = true,
},
Value::Text(name) if name == "apple_validation_category_01" => match value {
Value::Bytes(category) if category.len() == 4 => {
out.validation_category = category.as_slice().try_into().ok()
}
_ => out.opaque_known_property = true,
},
_ => out.unknown_extensions += 1,
}
}
Ok(out)
}
}

View File

@@ -0,0 +1,394 @@
//! Synthetic signed fixtures. Adapted from appattest 0.1.1; see LICENSE.
use aws_lc_rs::digest::{digest, Context as DigestContext, SHA256};
use base64::{engine::general_purpose, Engine};
use ciborium::{cbor, Value};
use openssl::{
asn1::{Asn1Integer, Asn1Object, Asn1OctetString, Asn1Time},
bn::BigNum,
ec::{EcGroup, EcKey, PointConversionForm},
hash::{hash, MessageDigest},
nid::Nid,
pkey::PKey,
rand::rand_bytes,
x509::{
extension::{BasicConstraints, ExtendedKeyUsage},
X509Builder, X509NameBuilder, X509,
},
};
use p256::ecdsa::{signature::Signer, DerSignature, SigningKey};
/// PEM bytes of the test root CA certificate (P-384).
/// Pass this to `verify_with_cert` / `app_id_verifies_with_cert` in tests.
pub const TEST_ROOT_CA_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
MIICNzCCAb6gAwIBAgIUFeZt+1JiuWUm7RJtzNA3etiyaTgwCgYIKoZIzj0EAwIw\n\
SzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\n\
CgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\n\
NjAyMjIxODU2MTBaMEsxJTAjBgNVBAMMHFRlc3QgQXBwIEF0dGVzdGF0aW9uIFJv\n\
b3QgQ0ExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcq\n\
hkjOPQIBBgUrgQQAIgNiAAT2cBxT0pWtGUECFzUn+Hdx6MaDyVoOHFcjsd+wqlKZ\n\
IKNg+bopRItVz79g5Rn3F7126h7Q6ZEosNMCMiV2u1t47NBVGAvs0FtM/gqN2hZv\n\
4vy3F2l3Vwd2TatWzpRC0iKjYzBhMB0GA1UdDgQWBBR9vyJAdCH/3azKhgGa9JSh\n\
5LVM+DAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAPBgNVHRMBAf8E\n\
BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNnADBkAjBi8yL62XLW\n\
g604KgEaEn9IFxR2c4vx+K0ZhYjPq6OU8X4pq9757JityNdJW1THgxgCMAo9bL44\n\
UUoFJPTgvmKobdPOIUQ1oRNRYnGmtfovWa31WM2IK0mOs+lpWYncFZOr3Q==\n\
-----END CERTIFICATE-----\n";
const TEST_INT_CA_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
MIICHDCCAaGgAwIBAgIUYM1hx4yf7fLmLiIQ57Ov3d5h27cwCgYIKoZIzj0EAwIw\n\
SzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\n\
CgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\n\
NjAyMjIxODU2MTBaMEgxIjAgBgNVBAMMGVRlc3QgQXBwIEF0dGVzdGF0aW9uIENB\n\
IDExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjO\n\
PQIBBggqhkjOPQMBBwNCAARXQJU3qZo9zCz2xZpq0ZyFfYN+WpG6uEdt++GKRcaj\n\
jmJiMYBpxOBtrecCgvPlrZdS7UlHhmUg2Zdfnvu7+DEIo2YwZDASBgNVHRMBAf8E\n\
CDAGAQH/AgEAMB0GA1UdDgQWBBQzJ5DNIxBnjG4jq4wZ0ZgB+4SYsDAfBgNVHSME\n\
GDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZI\n\
zj0EAwIDaQAwZgIxAJ2KT9ZsXtEtvemPNnp5FsyM1WEli1qztnjhjDV5qHcCFydq\n\
g8rl2BjX1qwXyl9odQIxAMXiN2QWgtbC7VqNUjZFevnwVPwXgkRr+g6mqKjWVTTf\n\
rGDANtkIDXqrnt8GyCZQWw==\n\
-----END CERTIFICATE-----\n";
// Published upstream synthetic CA key; the gateway pins Apple's root and never trusts this CA.
const TEST_INT_CA_KEY_PEM: &[u8] = include_bytes!("../../tests/fixtures/appattest-test-ca-key.pem");
/// Output of [`build_test_attestation`].
pub struct TestAttestation {
/// CBOR-encoded attestation bytes. Base64-encode and pass to
/// `Attestation::from_base64`, or decode directly via
/// `Attestation::from_cbor` if exposed.
pub cbor: Vec<u8>,
/// Base64-encoded key ID (standard encoding). Pass to `verify` /
/// `app_id_verifies`.
pub key_id: String,
/// The device's P-256 private key. Keep this to sign assertions in
/// subsequent calls.
pub device_key: SigningKey,
}
/// Build a fully valid synthetic attestation signed by the embedded test CA.
///
/// The returned [`TestAttestation`] will pass
/// `Attestation::app_id_verifies(challenge, &[app_id], &key_id, TEST_ROOT_CA_CERT_PEM)`.
pub fn build_test_attestation(challenge: &str, app_id: &str) -> TestAttestation {
build_test_attestation_with_auth_data(challenge, app_id, |_| {})
}
/// Build a signed fixture after deliberately varying authenticator fields.
/// This permits independent positive and negative structural test cases.
pub fn build_test_attestation_with_auth_data(
challenge: &str,
app_id: &str,
change: impl FnOnce(&mut Vec<u8>),
) -> TestAttestation {
// Generate a P-256 device keypair via openssl, then import the scalar into
// p256 for signing.
let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap();
let openssl_key = EcKey::generate(&group).unwrap();
let scalar_bytes = openssl_key.private_key().to_vec();
// p256 expects a 32-byte scalar; left-pad if shorter.
let mut scalar32 = [0u8; 32];
let off = 32 - scalar_bytes.len();
scalar32[off..].copy_from_slice(&scalar_bytes);
let signing_key = SigningKey::from_bytes(&scalar32.into()).unwrap();
let device_pkey = PKey::from_ec_key(openssl_key.clone()).unwrap();
// key_id = base64(SHA256(uncompressed public key bytes))
let mut ctx = openssl::bn::BigNumContext::new().unwrap();
let pub_key_bytes = openssl_key
.public_key()
.to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut ctx)
.unwrap();
let pub_key_hash = hash(MessageDigest::sha256(), &pub_key_bytes).unwrap();
let key_id = general_purpose::STANDARD.encode(pub_key_hash.as_ref());
let key_id_bytes = pub_key_hash.to_vec();
// authenticator data layout:
// [0..32] rp_id_hash = SHA256(app_id)
// [32] flags = 0x41 (attested credential data included)
// [33..37] counter = 0u32 big-endian
// [37..53] aaguid = b"appattest\0\0\0\0\0\0\0" (16 bytes)
// [53..55] cred_id_len = 32u16 big-endian
// [55..87] cred_id = key_id raw bytes
let rp_id_hash: [u8; 32] = digest(&SHA256, app_id.as_bytes())
.as_ref()
.try_into()
.unwrap();
let mut aaguid = [0u8; 16];
aaguid[..9].copy_from_slice(b"appattest");
let mut auth_data = Vec::with_capacity(87);
auth_data.extend_from_slice(&rp_id_hash);
auth_data.push(0x41);
auth_data.extend_from_slice(&[0u8; 4]);
auth_data.extend_from_slice(&aaguid);
let mut len_bytes = [0u8; 2];
len_bytes.copy_from_slice(&(key_id_bytes.len() as u16).to_be_bytes());
auth_data.extend_from_slice(&len_bytes);
auth_data.extend_from_slice(&key_id_bytes);
let cose = Value::Map(vec![
(Value::Integer(1.into()), Value::Integer(2.into())),
(Value::Integer(3.into()), Value::Integer((-7).into())),
(Value::Integer((-1).into()), Value::Integer(1.into())),
(
Value::Integer((-2).into()),
Value::Bytes(pub_key_bytes[1..33].to_vec()),
),
(
Value::Integer((-3).into()),
Value::Bytes(pub_key_bytes[33..65].to_vec()),
),
]);
ciborium::into_writer(&cose, &mut auth_data).unwrap();
change(&mut auth_data);
// nonce = SHA256(authData || SHA256(challenge))
let client_data_hash: [u8; 32] = digest(&SHA256, challenge.as_bytes())
.as_ref()
.try_into()
.unwrap();
let nonce: [u8; 32] = {
let mut ctx = DigestContext::new(&SHA256);
ctx.update(&auth_data);
ctx.update(&client_data_hash);
ctx.finish().as_ref().try_into().unwrap()
};
let cred_cert_der = build_cred_cert(&device_pkey, nonce.as_slice(), &key_id);
let int_cert_der = X509::from_pem(TEST_INT_CA_CERT_PEM)
.unwrap()
.to_der()
.unwrap();
let cbor = encode_attestation_cbor(&cred_cert_der, &int_cert_der, &auth_data);
TestAttestation {
cbor,
key_id,
device_key: signing_key,
}
}
/// Build a synthetic assertion signed by the given device private key.
///
/// Returns raw CBOR bytes ready for `Assertion::from_assertion`.
///
/// `previous_counter` should be the counter value stored server-side;
/// the assertion will use `previous_counter + 1`.
pub fn build_test_assertion(
app_id: &str,
client_data_hash: impl AsRef<[u8]>,
previous_counter: u32,
device_key: &SigningKey,
) -> Vec<u8> {
let rp_id_hash: [u8; 32] = digest(&SHA256, app_id.as_bytes())
.as_ref()
.try_into()
.unwrap();
let counter = previous_counter + 1;
let mut auth_data = Vec::with_capacity(37);
auth_data.extend_from_slice(&rp_id_hash);
auth_data.push(0x01);
let mut counter_bytes = [0u8; 4];
counter_bytes.copy_from_slice(&counter.to_be_bytes());
auth_data.extend_from_slice(&counter_bytes);
let nonce: Vec<u8> = {
let mut ctx = DigestContext::new(&SHA256);
ctx.update(&auth_data);
ctx.update(client_data_hash.as_ref());
ctx.finish().as_ref().to_vec()
};
let sig: DerSignature = device_key.sign(&nonce);
encode_assertion_cbor(&auth_data, sig.as_bytes())
}
fn build_cred_cert(
device_pkey: &PKey<openssl::pkey::Private>,
nonce: &[u8],
key_id: &str,
) -> Vec<u8> {
build_cred_cert_with(device_pkey, nonce, key_id, |_| {})
}
fn build_cred_cert_with(
device_pkey: &PKey<openssl::pkey::Private>,
nonce: &[u8],
key_id: &str,
change: impl FnOnce(&mut X509Builder),
) -> Vec<u8> {
assert_eq!(nonce.len(), 32);
let int_cert = X509::from_pem(TEST_INT_CA_CERT_PEM).unwrap();
let int_key = PKey::private_key_from_pem(TEST_INT_CA_KEY_PEM).unwrap();
let mut name_builder = X509NameBuilder::new().unwrap();
name_builder.append_entry_by_text("CN", key_id).unwrap();
name_builder
.append_entry_by_text("OU", "AAA Certification")
.unwrap();
name_builder.append_entry_by_text("O", "Test").unwrap();
name_builder
.append_entry_by_text("ST", "California")
.unwrap();
let subject_name = name_builder.build();
let mut builder = X509Builder::new().unwrap();
builder.set_version(2).unwrap();
builder.set_subject_name(&subject_name).unwrap();
builder.set_issuer_name(int_cert.subject_name()).unwrap();
builder.set_pubkey(device_pkey).unwrap();
builder
.set_not_before(&Asn1Time::days_from_now(0).unwrap())
.unwrap();
builder
.set_not_after(&Asn1Time::days_from_now(365).unwrap())
.unwrap();
let mut serial_bytes = [0u8; 8];
rand_bytes(&mut serial_bytes).unwrap();
let serial_asn1 = Asn1Integer::from_bn(&BigNum::from_slice(&serial_bytes).unwrap()).unwrap();
builder.set_serial_number(&serial_asn1).unwrap();
builder
.append_extension(BasicConstraints::new().build().unwrap())
.unwrap();
// Apple's proprietary App Attest EKU (OID 1.2.840.113635.100.4.24).
builder
.append_extension(
ExtendedKeyUsage::new()
.other("1.2.840.113635.100.4.24")
.build()
.unwrap(),
)
.unwrap();
// Nonce extension OID 1.2.840.113635.100.8.2, encoding:
// 30 24 SEQUENCE, length 36
// a1 22 [1] EXPLICIT, length 34
// 04 20 OCTET STRING, length 32
// <nonce bytes>
//
// `extract_nonce_from_cert` walks this with `der_unwrap_tag`, stripping the
// 04 20 header to yield the raw 32-byte nonce.
let mut ext_value = Vec::with_capacity(38);
ext_value.extend_from_slice(&[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]);
ext_value.extend_from_slice(nonce);
let oid = Asn1Object::from_str("1.2.840.113635.100.8.2").unwrap();
let octet_val = Asn1OctetString::new_from_bytes(&ext_value).unwrap();
builder
.append_extension(
openssl::x509::X509Extension::new_from_der(&oid, false, &octet_val).unwrap(),
)
.unwrap();
change(&mut builder);
builder.sign(&int_key, MessageDigest::sha256()).unwrap();
builder.build().to_der().unwrap()
}
fn encode_attestation_cbor(cred_cert_der: &[u8], int_cert_der: &[u8], auth_data: &[u8]) -> Vec<u8> {
let val = cbor!({
"fmt" => "apple-appattest",
"attStmt" => {
"x5c" => [
Value::Bytes(cred_cert_der.to_vec()),
Value::Bytes(int_cert_der.to_vec())
],
"receipt" => Value::Bytes(vec![])
},
"authData" => Value::Bytes(auth_data.to_vec())
})
.unwrap();
let mut buf = Vec::new();
ciborium::into_writer(&val, &mut buf).unwrap();
buf
}
fn encode_assertion_cbor(auth_data: &[u8], signature: &[u8]) -> Vec<u8> {
let val = cbor!({
"authenticatorData" => Value::Bytes(auth_data.to_vec()),
"signature" => Value::Bytes(signature.to_vec())
})
.unwrap();
let mut buf = Vec::new();
ciborium::into_writer(&val, &mut buf).unwrap();
buf
}
fn certificate_chain(change: impl FnOnce(&mut X509Builder)) -> Vec<Vec<u8>> {
let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap();
let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap();
vec![
build_cred_cert_with(&key, &[7; 32], "test", change),
X509::from_pem(TEST_INT_CA_CERT_PEM)
.unwrap()
.to_der()
.unwrap(),
]
}
/// Run the same signed-certificate corpus against an alternative adapter.
pub fn compare_certificate_constraints<E: std::fmt::Debug>(
verify_chain: impl Fn(&[Vec<u8>], &[u8]) -> Result<(), E>,
verify_nonce: impl Fn(&[u8], &[u8]) -> Result<(), E>,
) {
use openssl::x509::extension::KeyUsage;
let root = X509::from_pem(TEST_ROOT_CA_CERT_PEM)
.unwrap()
.to_der()
.unwrap();
let valid = certificate_chain(|builder| {
builder
.append_extension(KeyUsage::new().digital_signature().build().unwrap())
.unwrap();
});
verify_chain(&valid, &root).unwrap();
let wrong_usage = certificate_chain(|builder| {
builder
.append_extension(KeyUsage::new().key_encipherment().build().unwrap())
.unwrap();
});
assert!(verify_chain(&wrong_usage, &root).is_err());
let expired = certificate_chain(|builder| {
builder
.set_not_before(&Asn1Time::from_unix(0).unwrap())
.unwrap();
builder
.set_not_after(&Asn1Time::from_unix(1).unwrap())
.unwrap();
});
assert!(verify_chain(&expired, &root).is_err());
let unknown_critical = certificate_chain(|builder| {
let oid = Asn1Object::from_str("1.2.3.4.5").unwrap();
let value = Asn1OctetString::new_from_bytes(b"\x05\x00").unwrap();
builder
.append_extension(
openssl::x509::X509Extension::new_from_der(&oid, true, &value).unwrap(),
)
.unwrap();
});
assert!(verify_chain(&unknown_critical, &root).is_err());
let duplicate_nonce = certificate_chain(|builder| {
let oid = Asn1Object::from_str("1.2.840.113635.100.8.2").unwrap();
let value = Asn1OctetString::new_from_bytes(b"\x30\x00").unwrap();
builder
.append_extension(
openssl::x509::X509Extension::new_from_der(&oid, false, &value).unwrap(),
)
.unwrap();
});
assert!(verify_chain(&duplicate_nonce, &root).is_err());
assert!(verify_nonce(&duplicate_nonce[0], &[7; 32]).is_err());
let mut trailing = valid;
trailing[0].push(0);
assert!(verify_chain(&trailing, &root).is_err());
}

View File

@@ -0,0 +1,388 @@
use super::*;
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
const APP_ID: &str = "TEAM.test.app";
const TRANSCRIPT: &[u8] = b"buzz.push.delegate.v1\n{\"challenge\":\"test-only\"}";
fn signing_key() -> SigningKey {
SigningKey::from_slice(&[7; 32]).unwrap()
}
fn auth_data(extensions: &[u8]) -> Vec<u8> {
let mut data = Sha256::digest(APP_ID.as_bytes()).to_vec();
data.push(if extensions.is_empty() { 0x40 } else { 0xc0 });
data.extend_from_slice(&1u32.to_be_bytes());
data.extend_from_slice(extensions);
data
}
fn signed_assertion(data: &[u8]) -> Vec<u8> {
let mut hash = Sha256::new();
hash.update(data);
hash.update(Sha256::digest(TRANSCRIPT));
let signature: Signature = signing_key().sign(&hash.finalize());
let mut buffer = [0u8; 2048];
let mut encoder =
minicbor::Encoder::new(minicbor::encode::write::Cursor::new(buffer.as_mut_slice()));
encoder
.map(2)
.unwrap()
.str("authenticatorData")
.unwrap()
.bytes(data)
.unwrap()
.str("signature")
.unwrap()
.bytes(signature.to_der().as_bytes())
.unwrap();
let length = encoder.writer().position();
buffer[..length].to_vec()
}
fn verify(cbor: &[u8]) -> Result<VerifiedAssertion, AppAttestError> {
let verifier = AppAttestVerifier::new(
APP_ID.into(),
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(),
)
.unwrap();
verifier.verify_assertion(
&STANDARD.encode(cbor),
TRANSCRIPT,
signing_key()
.verifying_key()
.to_encoded_point(false)
.as_ref(),
0,
"challenge",
"challenge",
)
}
#[test]
fn legacy_assertion_round_trip() {
assert_eq!(
verify(&signed_assertion(&auth_data(&[]))).unwrap().counter,
1
);
}
#[test]
fn production_verifier_accepts_extended_assertion() {
// General unknown extension with a nested array, not an SE-specific length.
let extension = b"\xa1\x66future\x83\x01\xf5\x62ok";
assert_eq!(
verify(&signed_assertion(&auth_data(extension)))
.unwrap()
.counter,
1
);
}
// Every conformance case exercises the shipped verification boundary.
mod conformance {
use super::*;
use ciborium::Value;
fn value(bytes: &[u8]) -> Result<Value, &'static str> {
ciborium::from_reader(bytes).map_err(|_| "CBOR test fixture")
}
fn verify_bytes(
cbor: &[u8],
transcript: &[u8],
app: &str,
key: &[u8],
previous: u32,
) -> Result<u32, &'static str> {
let verifier = AppAttestVerifier::new(
app.into(),
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(),
)
.map_err(|_| "configuration")?;
verifier
.verify_assertion(
&STANDARD.encode(cbor),
transcript,
key,
previous,
"fresh",
"fresh",
)
.map(|verified| verified.counter)
.map_err(|_| "invalid")
}
fn verify(cbor: &[u8]) -> Result<u32, &'static str> {
verify_bytes(
cbor,
TRANSCRIPT,
APP_ID,
signing_key()
.verifying_key()
.to_encoded_point(false)
.as_ref(),
0,
)
}
#[test]
fn varied_extensions_and_legacy_verify() {
for ext in [
b"".as_slice(),
b"\xa0",
b"\xa1\x66future\x83\x01\xf5\x62ok",
b"\xbf\x66future\x9f\x01\xf5\xff\xff",
b"\xa2\x61a\x18\x01\x61b\x43abc",
b"\xa2\x61b\x43abc\x61a\x18\x01",
b"\xa1\x61x\xa1\x01\xc1\x00",
] {
assert_eq!(verify(&signed_assertion(&auth_data(ext))), Ok(1));
}
}
#[test]
fn malformed_signed_extensions_rejected() {
for ext in [
b"\xa1".as_slice(),
b"\xa0\x00",
b"\x81\x01",
b"\xa2\x61x\x01\x61x\x02",
b"\xa1\x61x\xa2\x01\x01\x18\x01\x02",
b"\xa1\x01\x01",
b"\xa1\x61x\xff",
] {
assert!(
verify(&signed_assertion(&auth_data(ext))).is_err(),
"{ext:?}"
);
}
for len in 0..37 {
assert!(verify(&signed_assertion(&auth_data(&[])[..len])).is_err());
}
}
#[test]
fn all_truncations_and_envelope_ambiguity_rejected() {
let cbor = signed_assertion(&auth_data(b"\xa1\x61x\x01"));
for len in 0..cbor.len() {
assert!(verify(&cbor[..len]).is_err());
}
let mut trailing = cbor.clone();
trailing.push(0);
assert!(verify(&trailing).is_err());
let mut duplicate = cbor.clone();
duplicate[0] = 0xa3;
duplicate.extend_from_slice(b"\x69signature\x40");
assert!(verify(&duplicate).is_err());
}
#[test]
fn signed_bytes_identity_key_and_replay_preserved() {
let cbor = signed_assertion(&auth_data(b"\xa1\x61x\x01"));
let key = signing_key().verifying_key().to_encoded_point(false);
assert!(verify_bytes(&cbor, b"changed", APP_ID, key.as_ref(), 0).is_err());
assert!(verify_bytes(&cbor, TRANSCRIPT, "other.app", key.as_ref(), 0).is_err());
assert!(verify_bytes(&cbor, TRANSCRIPT, APP_ID, key.as_ref(), 1).is_err());
assert!(verify_bytes(&cbor, TRANSCRIPT, APP_ID, key.as_ref(), 2).is_err());
let other = SigningKey::from_slice(&[8; 32]).unwrap();
assert!(verify_bytes(
&cbor,
TRANSCRIPT,
APP_ID,
other.verifying_key().to_encoded_point(false).as_ref(),
0
)
.is_err());
let mut decoded = value(&cbor).unwrap();
if let Value::Map(ref mut entries) = decoded {
for (k, v) in entries {
if k.as_text() == Some("authenticatorData") {
if let Value::Bytes(data) = v {
*data.last_mut().unwrap() = 2;
}
}
}
}
let mut changed = Vec::new();
ciborium::into_writer(&decoded, &mut changed).unwrap();
assert!(verify(&changed).is_err());
}
#[test]
fn extension_size_is_not_a_format_constant() {
for len in 0..512 {
let extension = Value::Map(vec![(
Value::Text(format!("future_{len}")),
Value::Bytes(vec![0x71; len]),
)]);
let mut bytes = Vec::new();
ciborium::into_writer(&extension, &mut bytes).unwrap();
assert_eq!(verify(&signed_assertion(&auth_data(&bytes))), Ok(1));
}
}
#[test]
#[ignore = "requires protected local capture; never commit raw device assertions"]
fn captured_device_assertion_verifies_offline() {
let root = std::path::PathBuf::from(std::env::var("APP_ATTEST_EVIDENCE_DIR").unwrap());
let capture: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("se-attest-diagnostic.json")).unwrap())
.unwrap();
let stored: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join("gateway-verification-input.json")).unwrap(),
)
.unwrap();
let assertion = STANDARD
.decode(capture["assertion"].as_str().unwrap())
.unwrap();
let transcript = STANDARD
.decode(capture["transcript"].as_str().unwrap())
.unwrap();
let key = STANDARD
.decode(stored["public_key"].as_str().unwrap().replace('\n', ""))
.unwrap();
assert_eq!(
verify_bytes(
&assertion,
&transcript,
"DE7S2975CH.com.tombrow.buzz.mobile",
&key,
stored["assertion_counter"].as_u64().unwrap() as u32
),
Ok(64)
);
}
}
/// Private device evidence stays outside the repository. This exercises the
/// production verification boundary.
#[test]
#[ignore = "requires protected same-key SDK comparison captures"]
fn same_key_sdk26_and_sdk27_assertions_verify() {
let root = std::path::PathBuf::from(std::env::var("APP_ATTEST_SDK_EVIDENCE_DIR").unwrap());
let mut previous = 0;
let mut enrolled_key = None;
for build in ["2601", "2702"] {
let fixture: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join(format!("verifier-input-{build}.json"))).unwrap(),
)
.unwrap();
let verifier = AppAttestVerifier::new(
fixture["app_id"].as_str().unwrap().into(),
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(),
)
.unwrap();
let assertion = fixture["assertion"].as_str().unwrap();
let transcript = STANDARD
.decode(fixture["transcript"].as_str().unwrap())
.unwrap();
let key = STANDARD
.decode(fixture["public_key"].as_str().unwrap())
.unwrap();
if let Some(expected) = &enrolled_key {
assert_eq!(
&key, expected,
"the SDK comparison must retain the same key"
);
} else {
enrolled_key = Some(key.clone());
}
let verified = verifier
.verify_assertion(assertion, &transcript, &key, previous, "fresh", "fresh")
.unwrap_or_else(|_| panic!("SDK probe build {build} must verify"));
assert_eq!(
u64::from(verified.counter),
fixture["counter"].as_u64().unwrap()
);
assert!(verifier
.verify_assertion(
assertion,
&transcript,
&key,
verified.counter,
"fresh",
"fresh"
)
.is_err());
let mut wrong_transcript = transcript.clone();
wrong_transcript.push(0);
assert!(verifier
.verify_assertion(
assertion,
&wrong_transcript,
&key,
previous,
"fresh",
"fresh"
)
.is_err());
previous = verified.counter;
}
}
#[cfg(feature = "personal-dev-app-attest")]
#[test]
#[ignore = "requires protected SDK comparison enrollment capture"]
fn sdk26_and_sdk27_device_enrollments_verify_with_exact_environment() {
let root = std::path::PathBuf::from(std::env::var("APP_ATTEST_SDK_EVIDENCE_DIR").unwrap());
for build in ["2601", "2703"] {
let fixture: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join(format!("enrollment-input-{build}.json"))).unwrap(),
)
.unwrap();
let transcript = STANDARD
.decode(fixture["transcript"].as_str().unwrap())
.unwrap();
let verifier = AppAttestVerifier::with_environment(
fixture["app_id"].as_str().unwrap().into(),
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(),
AppAttestEnvironment::Development,
)
.unwrap();
let verified = verifier
.verify_attestation(
fixture["attestation"].as_str().unwrap(),
fixture["key_id"].as_str().unwrap(),
&transcript,
)
.unwrap();
let expected: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join(format!("verifier-input-{build}.json"))).unwrap(),
)
.unwrap();
assert_eq!(
verified.public_key,
STANDARD
.decode(expected["public_key"].as_str().unwrap())
.unwrap()
);
assert_eq!(
verifier
.verify_assertion(
expected["assertion"].as_str().unwrap(),
&STANDARD
.decode(expected["transcript"].as_str().unwrap())
.unwrap(),
&verified.public_key,
0,
"fresh",
"fresh",
)
.unwrap()
.counter,
expected["counter"].as_u64().unwrap() as u32
);
let production = AppAttestVerifier::new(
fixture["app_id"].as_str().unwrap().into(),
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(),
)
.unwrap();
assert!(production
.verify_attestation(
fixture["attestation"].as_str().unwrap(),
fixture["key_id"].as_str().unwrap(),
&transcript
)
.is_err());
}
}

View File

@@ -1,9 +1,9 @@
{
"description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.",
"app_id": "TEAMID.xyz.buzz.mobile",
"challenge": "buzz-app-attest-strict-verifier-fixture",
"description": "Synthetic enrollment with complete COSE key, signed by test CA",
"app_id": "TEAMID.xyz.buzz.test",
"challenge": "fixture-enrollment-challenge",
"aaguid": "appattest",
"attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk",
"key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=",
"root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n"
"attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAhAwggIMMIIBs6ADAgECAgh1EiZfFnrRWjAKBggqhkjOPQQDAjBIMSIwIAYDVQQDDBlUZXN0IEFwcCBBdHRlc3RhdGlvbiBDQSAxMQ0wCwYDVQQKDARUZXN0MRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTI2MTAxNDE3MzE0NloXDTI3MTAxNDE3MzE0NlowdzE1MDMGA1UEAwwsUi9DaG5aNUNRYjNNeDhzaENadzMremxyY2ZSZEl2UWdrVkZVYmUzeWg1az0xGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMQ0wCwYDVQQKDARUZXN0MRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7bj4oS5sxVsn+hrgWmoa7ApEWHS5zGviJElsnyowqT6Hi/bxkTmfhuub7ZiPj7gFLh4L8Nz0v1jCFwpo2Ha4TaNYMFYwCQYDVR0TBAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCA9//n/Fd1aZ9360iUFh2B2N5w2YYGFBngIuRfAnI4FKjAKBggqhkjOPQQDAgNHADBEAiBxjdpjMEL3KwFZZ/zKs+Nj7MnsrlgLCWM8abzTLYerAgIgfXSqjaMT/mpJD0grzaSwSOfL0x6l41VWZKSlfQMns71ZAiAwggIcMIIBoaADAgECAhRgzWHHjJ/t8uYuIhDns6/d3mHbtzAKBggqhkjOPQQDAjBLMSUwIwYDVQQDDBxUZXN0IEFwcCBBdHRlc3RhdGlvbiBSb290IENBMQ0wCwYDVQQKDARUZXN0MRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTI2MDIyNzE4NTYxMFoXDTQ2MDIyMjE4NTYxMFowSDEiMCAGA1UEAwwZVGVzdCBBcHAgQXR0ZXN0YXRpb24gQ0EgMTENMAsGA1UECgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFdAlTepmj3MLPbFmmrRnIV9g35akbq4R2374YpFxqOOYmIxgGnE4G2t5wKC8+Wtl1LtSUeGZSDZl1+e+7v4MQijZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFDMnkM0jEGeMbiOrjBnRmAH7hJiwMB8GA1UdIwQYMBaAFH2/IkB0If/drMqGAZr0lKHktUz4MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNpADBmAjEAnYpP1mxe0S296Y82enkWzIzVYSWLWrO2eOGMNXmodwIXJ2qDyuXYGNfWrBfKX2h1AjEAxeI3ZBaC1sLtWo1SNkV6+fBU/BeCRGv6DqaoqNZVNN+sYMA22QgNeque3wbIJlBbZ3JlY2VpcHRAaGF1dGhEYXRhWKRAwbIqQ4Pi7DICxDUSOKvAz16aJJL6oaGyfnacPhq1xEEAAAAAYXBwYXR0ZXN0AAAAAAAAAAAgR/ChnZ5CQb3Mx8shCZw3+zlrcfRdIvQgkVFUbe3yh5mlAQIDJiABIVgg7bj4oS5sxVsn+hrgWmoa7ApEWHS5zGviJElsnyowqT4iWCCHi/bxkTmfhuub7ZiPj7gFLh4L8Nz0v1jCFwpo2Ha4TQ==",
"key_id_b64": "R/ChnZ5CQb3Mx8shCZw3+zlrcfRdIvQgkVFUbe3yh5k=",
"root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUFeZt+1JiuWUm7RJtzNA3etiyaTgwCgYIKoZIzj0EAwIw\nSzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\nCgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\nNjAyMjIxODU2MTBaMEsxJTAjBgNVBAMMHFRlc3QgQXBwIEF0dGVzdGF0aW9uIFJv\nb3QgQ0ExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcq\nhkjOPQIBBgUrgQQAIgNiAAT2cBxT0pWtGUECFzUn+Hdx6MaDyVoOHFcjsd+wqlKZ\nIKNg+bopRItVz79g5Rn3F7126h7Q6ZEosNMCMiV2u1t47NBVGAvs0FtM/gqN2hZv\n4vy3F2l3Vwd2TatWzpRC0iKjYzBhMB0GA1UdDgQWBBR9vyJAdCH/3azKhgGa9JSh\n5LVM+DAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAPBgNVHRMBAf8E\nBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNnADBkAjBi8yL62XLW\ng604KgEaEn9IFxR2c4vx+K0ZhYjPq6OU8X4pq9757JityNdJW1THgxgCMAo9bL44\nUUoFJPTgvmKobdPOIUQ1oRNRYnGmtfovWa31WM2IK0mOs+lpWYncFZOr3Q==\n-----END CERTIFICATE-----\n"
}

View File

@@ -1,9 +1,9 @@
{
"description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.",
"app_id": "TEAMID.xyz.buzz.mobile",
"challenge": "buzz-app-attest-strict-verifier-fixture",
"description": "Synthetic enrollment with complete COSE key, signed by test CA",
"app_id": "TEAMID.xyz.buzz.test",
"challenge": "fixture-enrollment-challenge",
"aaguid": "appattestdevelop",
"attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=",
"key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=",
"root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n"
"attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAhEwggINMIIBs6ADAgECAghwzLB5jqA0VDAKBggqhkjOPQQDAjBIMSIwIAYDVQQDDBlUZXN0IEFwcCBBdHRlc3RhdGlvbiBDQSAxMQ0wCwYDVQQKDARUZXN0MRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTI2MTAxNDE3MzE0NloXDTI3MTAxNDE3MzE0NlowdzE1MDMGA1UEAwwsY2FkcGFuNG93cDIxYll4Z1l2cWhUQW5Ic1hzOWlqTm9LRVdXQWFMYU5STT0xGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMQ0wCwYDVQQKDARUZXN0MRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA7MYfoZIHJFQvgiqzxPu3RUVI2aMTYVxmAjgFZK30WowShI7JR3yrJ4cAnoOnFjm/cKPvSeR4P4CxxLxWUjLwqNYMFYwCQYDVR0TBAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCC5fPFFXiJqTp5Ubq5CV/Z33MFi21au+UdD+QpOqDGXsTAKBggqhkjOPQQDAgNIADBFAiEAmFm+5rmXjmr2gx4vGOeOuZyzDR3s9lkLRFzYfXGZQmoCIBXSOkx5RbUUYEQcD5K2CyrRrm1TzS0wD/+Fwkm1MS9NWQIgMIICHDCCAaGgAwIBAgIUYM1hx4yf7fLmLiIQ57Ov3d5h27cwCgYIKoZIzj0EAwIwSzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UECgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00NjAyMjIxODU2MTBaMEgxIjAgBgNVBAMMGVRlc3QgQXBwIEF0dGVzdGF0aW9uIENBIDExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARXQJU3qZo9zCz2xZpq0ZyFfYN+WpG6uEdt++GKRcajjmJiMYBpxOBtrecCgvPlrZdS7UlHhmUg2Zdfnvu7+DEIo2YwZDASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQzJ5DNIxBnjG4jq4wZ0ZgB+4SYsDAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDaQAwZgIxAJ2KT9ZsXtEtvemPNnp5FsyM1WEli1qztnjhjDV5qHcCFydqg8rl2BjX1qwXyl9odQIxAMXiN2QWgtbC7VqNUjZFevnwVPwXgkRr+g6mqKjWVTTfrGDANtkIDXqrnt8GyCZQW2dyZWNlaXB0QGhhdXRoRGF0YVikQMGyKkOD4uwyAsQ1EjirwM9emiSS+qGhsn52nD4atcRBAAAAAGFwcGF0dGVzdGRldmVsb3AAIHGnaWp+KMKdtW2MYGL6oUwJx7F7PYozaChFlgGi2jUTpQECAyYgASFYIAOzGH6GSByRUL4Iqs8T7t0VFSNmjE2FcZgI4BWSt9FqIlggMEoSOyUd8qyeHAJ6DpxY5v3Cj70nkeD+AscS8VlIy8I=",
"key_id_b64": "cadpan4owp21bYxgYvqhTAnHsXs9ijNoKEWWAaLaNRM=",
"root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUFeZt+1JiuWUm7RJtzNA3etiyaTgwCgYIKoZIzj0EAwIw\nSzElMCMGA1UEAwwcVGVzdCBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTENMAsGA1UE\nCgwEVGVzdDETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNjAyMjcxODU2MTBaFw00\nNjAyMjIxODU2MTBaMEsxJTAjBgNVBAMMHFRlc3QgQXBwIEF0dGVzdGF0aW9uIFJv\nb3QgQ0ExDTALBgNVBAoMBFRlc3QxEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcq\nhkjOPQIBBgUrgQQAIgNiAAT2cBxT0pWtGUECFzUn+Hdx6MaDyVoOHFcjsd+wqlKZ\nIKNg+bopRItVz79g5Rn3F7126h7Q6ZEosNMCMiV2u1t47NBVGAvs0FtM/gqN2hZv\n4vy3F2l3Vwd2TatWzpRC0iKjYzBhMB0GA1UdDgQWBBR9vyJAdCH/3azKhgGa9JSh\n5LVM+DAfBgNVHSMEGDAWgBR9vyJAdCH/3azKhgGa9JSh5LVM+DAPBgNVHRMBAf8E\nBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNnADBkAjBi8yL62XLW\ng604KgEaEn9IFxR2c4vx+K0ZhYjPq6OU8X4pq9757JityNdJW1THgxgCMAo9bL44\nUUoFJPTgvmKobdPOIUQ1oRNRYnGmtfovWa31WM2IK0mOs+lpWYncFZOr3Q==\n-----END CERTIFICATE-----\n"
}

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIFM/DSPwekMvsqNpx/wpynoxNHL6driuUlXKdzaxVuIEoAoGCCqGSM49
AwEHoUQDQgAEV0CVN6maPcws9sWaatGchX2DflqRurhHbfvhikXGo45iYjGAacTg
ba3nAoLz5a2XUu1JR4ZlINmXX577u/gxCA==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1,81 @@
# App Attest compatibility boundary
The push gateway verifies enrollment and assertions through a private
[Buzz-owned module](../crates/buzz-push-gateway/src/app_attest.rs). Extended authenticators must
remain compatible without weakening the checks that bind a request to an app,
key, challenge, and increasing counter.
## Receiver policy
Enrollment requires the fixed authenticator prefix, AAGUID, credential ID, and a
complete COSE P-256 key. The COSE key must match the leaf certificate key. Any
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.
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
before decoding the extension payload. Unknown extension identifiers and opaque
optional property values do not authorize or reject a request by themselves.
Malformed structure, ambiguous duplicate keys, and trailing data are rejected.
CBOR accepts well-formed definite/indefinite containers and strings, nonpreferred
integer widths, and opaque tagged/simple values under bounded resource limits.
Map-key equality follows the generic CBOR model, including compound keys;
integers and floating-point values remain distinct. Schema-bearing maps require
their specified text or COSE labels.
The gateway retains its configured Apple root and exact server-selected app and
attestation environment. Certificate validation uses webpki; field extraction
uses x509-parser. The nonce extension must have its complete canonical structure.
The leaf key, credential ID, supplied key ID, zero enrollment counter, and app
hash must agree. Assertions retain signature, app hash, challenge, and increasing
counter checks. Existing atomic database updates remain the replay boundary.
The verifier consumes EKU OIDs without applying TLS purposes to App Attest and
honors supplied digital-signature/key-cert-sign usage restrictions. It does not
perform revocation checking or Apple's separate receipt risk assessment.
Version text and raw four-byte category values are bounded diagnostic
observations only. Unsupported optional representations remain opaque. No
version or category restriction is introduced.
## Capacity and tests
Parser limits are service policy: 64 KiB encoded input, 4096 decoded nodes,
depth 32, and 64 KiB copied strings/bytes. Declared lengths do not drive
preallocation. Gateway transport limits remain 16 KiB for enrollment and 1 KiB
for assertions. Tests cover boundaries, oversized declared lengths, truncations,
duplicate equivalence, and structurally invalid signed payloads.
Signed synthetic enrollment tests cover COSE parity/reordering, extension
variation, certificate constraints, nonce integrity, wrong identity/key binding,
and incomplete credentials. Gateway tests verify legacy and extended assertions,
tamper rejection, wrong transcript/key/app, and replay rejection. The signed-certificate suite runs with the gateway unit tests. Fixture helpers
are compiled only for tests, including in personal-development builds.
Physical-device validation used one probe identity on iOS 27.0.1:
| SDK | Enrollment authenticator | Assertion authenticator | Key relationship |
| --- | ---: | ---: | --- |
| 26.5 | 164 bytes | 37 bytes | Original probe key |
| 27.1 | Previously enrolled | 102 bytes | Same original key |
| 27.1 | 229 bytes | 102 bytes | Additional fresh probe key |
All three assertions and both enrollments pass the gateway verifier. Same-key
counters increase; repeated counters and altered transcripts fail. Development
enrollments fail under the production environment. These are local verifier
results, not evidence of a deployed gateway or APNs delivery. Raw device captures
stay outside the repository; optional ignored tests consume protected local
inputs for repeatable validation.
## Source provenance
Adapted portions originate in [appattest 0.1.1](https://crates.io/crates/appattest/0.1.1),
[upstream commit d1ffda7](https://github.com/PrismaPhonic/appattest/commit/d1ffda733f6e7f267aab79ae42b8ec7f60ceada1).
The package SHA-256 is `5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7`.
Both upstream MIT notices are retained in the module's
[LICENSE](../crates/buzz-push-gateway/src/app_attest/LICENSE).
The gateway owns its verification policy and tracks Apple protocol and underlying
cryptographic-library updates directly. There is no remaining appattest dependency.