mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
fix(push): verify App Attest proofs with a focused owned module
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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
42
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
crates/buzz-push-gateway/src/app_attest/LICENSE
Normal file
22
crates/buzz-push-gateway/src/app_attest/LICENSE
Normal 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.
|
||||
133
crates/buzz-push-gateway/src/app_attest/authenticator.rs
Normal file
133
crates/buzz-push-gateway/src/app_attest/authenticator.rs
Normal 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,
|
||||
))
|
||||
}
|
||||
277
crates/buzz-push-gateway/src/app_attest/cbor.rs
Normal file
277
crates/buzz-push-gateway/src/app_attest/cbor.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
118
crates/buzz-push-gateway/src/app_attest/certificate.rs
Normal file
118
crates/buzz-push-gateway/src/app_attest/certificate.rs
Normal 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()
|
||||
}
|
||||
137
crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs
Normal file
137
crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs
Normal 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());
|
||||
}
|
||||
51
crates/buzz-push-gateway/src/app_attest/error.rs
Normal file
51
crates/buzz-push-gateway/src/app_attest/error.rs
Normal 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
|
||||
}
|
||||
}
|
||||
110
crates/buzz-push-gateway/src/app_attest/proof.rs
Normal file
110
crates/buzz-push-gateway/src/app_attest/proof.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
39
crates/buzz-push-gateway/src/app_attest/properties.rs
Normal file
39
crates/buzz-push-gateway/src/app_attest/properties.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
394
crates/buzz-push-gateway/src/app_attest/testing.rs
Normal file
394
crates/buzz-push-gateway/src/app_attest/testing.rs
Normal 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());
|
||||
}
|
||||
388
crates/buzz-push-gateway/src/app_attest_assertion_tests.rs
Normal file
388
crates/buzz-push-gateway/src/app_attest_assertion_tests.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
5
crates/buzz-push-gateway/tests/fixtures/appattest-test-ca-key.pem
vendored
Normal file
5
crates/buzz-push-gateway/tests/fixtures/appattest-test-ca-key.pem
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIFM/DSPwekMvsqNpx/wpynoxNHL6driuUlXKdzaxVuIEoAoGCCqGSM49
|
||||
AwEHoUQDQgAEV0CVN6maPcws9sWaatGchX2DflqRurhHbfvhikXGo45iYjGAacTg
|
||||
ba3nAoLz5a2XUu1JR4ZlINmXX577u/gxCA==
|
||||
-----END EC PRIVATE KEY-----
|
||||
81
docs/app-attest-compatibility.md
Normal file
81
docs/app-attest-compatibility.md
Normal 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.
|
||||
Reference in New Issue
Block a user