mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(push): verify extended App Attest proofs with a focused owned module (#8329)
## Summary Apple SDK 27.1 device captures include authenticator extension data that the current verifier rejects. Replace the general appattest dependency with a private gateway module that verifies the complete original bytes, frames enrollment and assertions separately, and accepts well-formed optional extensions without SDK-specific length or flag allowlists. The module retains established signature, P-256, CBOR, and certificate-chain libraries. App identity, exact enrollment environment, credential/key binding, nonce, challenge, and increasing-counter checks remain enforced. Bounded parsing rejects ambiguous duplicate keys, truncation, trailing data, and oversized inputs. Fixture helpers are test-only. Adapted code retains both upstream MIT notices and source provenance. Category/version properties remain authenticated observations under the chosen compatibility-release policy. This does not implement Apple's additional distribution-category/version enforcement; introducing those restrictions requires a separately defined deployment policy. The [verification contract](https://github.com/block/buzz/blob/app-attest-owned-verifier/docs/app-attest-compatibility.md) records this and other limits. Related: https://github.com/block/buzz/pull/8020 addresses assertion extension handling. This change also covers enrollment extensions, credential-key parsing, and certificate validation. ### Validation - Gateway suites: 77 default tests and 79 personal-development tests passed, plus startup policy and Clippy. - Added signed enrollment/certificate, generated CBOR mutation, maximum-counter, and PostgreSQL race/replay coverage. All 12 isolated Postgres tests passed. Apple's historical fixture is tested structurally, not accepted as a current enrollment. Positive synthetic leaf certificates are generated at test time. - Protected SDK 26.5/27.1 enrollments and assertions pass the verifier, including the same key across SDK upgrades; development enrollment rejects under production policy. Captures are personal-development identities, not supported production-identity evidence. - Full local CI passed Rust tests and reached 6,828 passing desktop JS tests before an unchanged 10 ms focus-timer test failed; its five-test file passed in isolation. The pre-push native suite had 3,421 passes and an unchanged 300 ms child-PID test failure, also passing in isolation. Prior ACP timing flakes and these unrelated hook exceptions do not waive required CI. - Windows CI exposed a missing system OpenSSL installation for the test fixture generator. The test dependency now builds bundled OpenSSL; all 30 focused App Attest tests pass locally. [Current-head CI](https://github.com/block/buzz/actions/runs/38636062347) passed, including Windows Rust, PostgreSQL, and desktop integration tests. - [Prior-head Codex review](https://github.com/block/buzz/pull/8329#issuecomment-6177515598) found no major issues. Carl subsequently identified a dropped credential-length workaround; fixed in [27f9f26](27f9f2624f), with 34 focused/protected tests and Clippy passing. [Current-head Codex review](https://github.com/block/buzz/pull/8329#issuecomment-6178361143) found no major issues; physical iOS 26 testing was explicitly waived by Tom on October 14, 2026. No iOS 26 device test was performed. The SDK comparison used the same iOS 27.0.1 device, so it demonstrates SDK-linked variation without establishing OS-version causation. - Isolated candidate HTTP/device validation passed with separate authority state and no APNs delivery: SDK 26.5 enrollment/delegation, SDK 27.1 delegation with the exact same key and installation, and fresh SDK 27.1 enrollment/delegation. Assertions changed from 37 to 102 bytes across the upgrade; accepted counter increased from 1 to 3 and delegation generation from 1 to 2. All three runs rejected tampering and replay (401) and consumed challenges (404). Existing Buzz app builds were preserved. No production rollout is included. - Existing-installation validation passed against the current-head Dev gateway: a guarded SDK 27.1 diagnostic reused the stored key and installation, advanced the assertion counter from 88 to 89 and delegation from 89 to 90, and published the existing active relay lease at generation 9 (previously 8). Independent database reads confirmed the changes and preserved key, epoch, and expiry. The original app build was restored in place with current data and keys intact. --------- 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"
|
||||
|
||||
52
Cargo.lock
generated
52
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"
|
||||
@@ -6927,6 +6929,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.116"
|
||||
@@ -6935,6 +6946,7 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
@@ -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 = { version = "0.10", features = ["vendored"] }
|
||||
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,
|
||||
let verified = proof::enroll(
|
||||
&cbor,
|
||||
client_data,
|
||||
&self.app_id,
|
||||
key_id_b64,
|
||||
&self.apple_root_cert_pem,
|
||||
matches_development(self.environment),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
.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,
|
||||
if challenge != stored_challenge {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
let verified = proof::assert(
|
||||
&cbor,
|
||||
client_data,
|
||||
&self.app_id,
|
||||
public_key,
|
||||
previous_counter,
|
||||
stored_challenge,
|
||||
)
|
||||
.map_err(|_| AppAttestError::Invalid)?;
|
||||
Ok(VerifiedAssertion { 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);
|
||||
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"
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
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,
|
||||
}
|
||||
_ => return Err(AppAttestError::Invalid),
|
||||
}
|
||||
}
|
||||
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");
|
||||
@@ -259,14 +198,32 @@ mod tests {
|
||||
app_id: String,
|
||||
challenge: String,
|
||||
aaguid: String,
|
||||
#[serde(skip)]
|
||||
attestation_b64: String,
|
||||
#[serde(skip)]
|
||||
key_id_b64: String,
|
||||
root_cert_pem: String,
|
||||
}
|
||||
|
||||
fn fixture(json: &str) -> Fixture {
|
||||
let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON");
|
||||
let mut fixture: Fixture =
|
||||
serde_json::from_str(json).expect("valid App Attest fixture JSON");
|
||||
assert!(!fixture.description.is_empty());
|
||||
// Sign a fresh leaf at test time instead of checking in a certificate
|
||||
// whose one-year validity will eventually break positive tests.
|
||||
let generated = super::testing::build_test_attestation_with_auth_data(
|
||||
&fixture.challenge,
|
||||
&fixture.app_id,
|
||||
|auth| {
|
||||
if fixture.aaguid == "appattestdevelop" {
|
||||
auth[37..53].copy_from_slice(b"appattestdevelop");
|
||||
} else {
|
||||
assert_eq!(fixture.aaguid, "appattest");
|
||||
}
|
||||
},
|
||||
);
|
||||
fixture.attestation_b64 = STANDARD.encode(generated.cbor);
|
||||
fixture.key_id_b64 = generated.key_id;
|
||||
fixture
|
||||
}
|
||||
|
||||
@@ -278,28 +235,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,19 +284,19 @@ mod tests {
|
||||
fn wrong_root_is_rejected() {
|
||||
let good = fixture(GOOD_FIXTURE_JSON);
|
||||
let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON);
|
||||
assert!(verify_dependency(
|
||||
&wrong_root,
|
||||
&wrong_root.app_id,
|
||||
&wrong_root.challenge,
|
||||
&wrong_root.key_id_b64,
|
||||
good.root_cert_pem.as_bytes(),
|
||||
assert!(verify_proof(
|
||||
&good,
|
||||
&good.app_id,
|
||||
&good.challenge,
|
||||
&good.key_id_b64,
|
||||
wrong_root.root_cert_pem.as_bytes(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(verifier(&wrong_root.app_id, good.root_cert_pem.as_bytes())
|
||||
assert!(verifier(&good.app_id, wrong_root.root_cert_pem.as_bytes())
|
||||
.verify_attestation(
|
||||
&wrong_root.attestation_b64,
|
||||
&wrong_root.key_id_b64,
|
||||
wrong_root.challenge.as_bytes(),
|
||||
&good.attestation_b64,
|
||||
&good.key_id_b64,
|
||||
good.challenge.as_bytes()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
@@ -345,14 +306,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 +329,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 +353,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 +377,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 +491,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 +510,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 +530,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 +538,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 +574,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.
|
||||
139
crates/buzz-push-gateway/src/app_attest/authenticator.rs
Normal file
139
crates/buzz-push-gateway/src/app_attest/authenticator.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! 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> {
|
||||
// Frame by operation and parsed CBOR, not AT/ED flags: Apple's published
|
||||
// enrollment sample includes extensions with ED clear.
|
||||
if tail.is_empty() {
|
||||
return Ok(ClientProperties::default());
|
||||
}
|
||||
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);
|
||||
}
|
||||
// App Attest credential IDs are SHA-256 hashes, always 32 bytes. Preserve
|
||||
// appattest 0.1.1's workaround for reported iOS 26.x nonstandard length
|
||||
// fields. The original length bytes remain authenticated by the nonce;
|
||||
// the credential, supplied key ID, COSE key and certificate must agree.
|
||||
const END: usize = 55 + 32;
|
||||
let end = END;
|
||||
if bytes.get(55..end).ok_or(Error::AuthenticatorDataTooShort)? != key_id {
|
||||
return Err(Error::InvalidCredentialID);
|
||||
}
|
||||
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,
|
||||
))
|
||||
}
|
||||
321
crates/buzz-push-gateway/src/app_attest/cbor.rs
Normal file
321
crates/buzz-push-gateway/src/app_attest/cbor.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(512))]
|
||||
#[test]
|
||||
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..=MAX_BYTES)) {
|
||||
let _ = complete(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual resource characterization, not a timing-sensitive CI gate"]
|
||||
fn characterize_capacity_work() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
let mut at_items = vec![0x9f];
|
||||
at_items.extend(std::iter::repeat_n(0, MAX_ITEMS - 1));
|
||||
at_items.push(0xff);
|
||||
let mut at_bytes = vec![0x59, 0xff, 0xfd];
|
||||
at_bytes.resize(MAX_BYTES, 0);
|
||||
let mut at_depth = vec![0x81; MAX_DEPTH];
|
||||
at_depth.push(0);
|
||||
let mut map = minicbor::Encoder::new(Vec::new());
|
||||
map.map(((MAX_ITEMS - 1) / 2) as u64).unwrap();
|
||||
for index in 0..(MAX_ITEMS - 1) / 2 {
|
||||
map.str(&format!("shared-prefix-{index:08}"))
|
||||
.unwrap()
|
||||
.u8(0)
|
||||
.unwrap();
|
||||
}
|
||||
for (name, bytes) in [
|
||||
("map", map.into_writer()),
|
||||
("items", at_items),
|
||||
("bytes", at_bytes),
|
||||
("depth", at_depth),
|
||||
] {
|
||||
let start = Instant::now();
|
||||
for _ in 0..1000 {
|
||||
black_box(complete(black_box(&bytes)).unwrap());
|
||||
}
|
||||
eprintln!(
|
||||
"{name}: {} input bytes, {} ns/parse (1000 iterations)",
|
||||
bytes.len(),
|
||||
start.elapsed().as_nanos() / 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
189
crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs
Normal file
189
crates/buzz-push-gateway/src/app_attest/compatibility_tests.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! 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_preserves_fixed_hash_credential_framing() {
|
||||
// Upstream reports nonstandard length fields on iOS 26.x. These are
|
||||
// synthetic compatibility vectors, not captures from that OS version.
|
||||
for length in [0_u16, 31, 32, 33, 0x89f4, u16::MAX] {
|
||||
verify(|auth| auth[53..55].copy_from_slice(&length.to_be_bytes())).unwrap();
|
||||
assert!(verify(|auth| {
|
||||
auth[53..55].copy_from_slice(&length.to_be_bytes());
|
||||
auth[55] ^= 1;
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
// Actual missing/extra credential bytes must not shift the COSE boundary.
|
||||
assert!(verify(|auth| {
|
||||
auth.remove(55);
|
||||
})
|
||||
.is_err());
|
||||
assert!(verify(|auth| auth.insert(55, 0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_enrollment_rejects_incomplete_or_inconsistent_credential_data() {
|
||||
assert!(verify(|auth| auth.truncate(87)).is_err()); // Old fixture omitted COSE.
|
||||
assert!(verify(|auth| auth[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")
|
||||
});
|
||||
// Even the compatibility length field must remain nonce-authenticated.
|
||||
for offset in [None, Some(53), Some(54)] {
|
||||
let mut envelope: ciborium::Value = ciborium::from_reader(fixture.cbor.as_slice()).unwrap();
|
||||
if let ciborium::Value::Map(fields) = &mut envelope {
|
||||
for (key, value) in fields {
|
||||
if key == &ciborium::Value::Text("authData".into()) {
|
||||
let ciborium::Value::Bytes(auth) = value else {
|
||||
panic!("authData")
|
||||
};
|
||||
let index = offset.unwrap_or(auth.len() - 1);
|
||||
auth[index] ^= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
ciborium::into_writer(&envelope, &mut changed).unwrap();
|
||||
assert!(matches!(
|
||||
enroll(&changed, &fixture.key_id),
|
||||
Err(Error::InvalidNonce)
|
||||
));
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_historical_fixture_has_structural_extensions_with_ed_clear() {
|
||||
// Apple's public example is historical, not evidence of current chain validity.
|
||||
// https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide
|
||||
let bytes = STANDARD
|
||||
.decode(include_str!("../../tests/fixtures/apple-structural-attestation.b64").trim())
|
||||
.unwrap();
|
||||
let envelope = super::cbor::complete(&bytes).unwrap();
|
||||
let auth = envelope.field("authData").unwrap().bytes().unwrap();
|
||||
assert_eq!(auth.len(), 226);
|
||||
assert_eq!(auth[32], 0x40);
|
||||
let super::cbor::Value::Array(chain) = envelope.field("attStmt").unwrap().field("x5c").unwrap()
|
||||
else {
|
||||
panic!("certificate chain")
|
||||
};
|
||||
let leaf = chain[0].bytes().unwrap();
|
||||
let public_key = super::certificate::public_key(leaf).unwrap();
|
||||
let key_id = super::certificate::key_id(&public_key);
|
||||
let parsed = super::authenticator::enrollment(
|
||||
auth,
|
||||
"1234567890.com.example.myapp",
|
||||
&key_id,
|
||||
&public_key,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parsed.counter, 0);
|
||||
assert_eq!(parsed.flags, 0x40);
|
||||
}
|
||||
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());
|
||||
}
|
||||
465
crates/buzz-push-gateway/src/app_attest_assertion_tests.rs
Normal file
465
crates/buzz-push-gateway/src/app_attest_assertion_tests.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
mod properties {
|
||||
use super::*;
|
||||
use ciborium::Value;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn optional_value() -> impl Strategy<Value = Value> {
|
||||
prop_oneof![
|
||||
any::<i64>().prop_map(|v| Value::Integer(v.into())),
|
||||
any::<bool>().prop_map(Value::Bool),
|
||||
proptest::collection::vec(any::<u8>(), 0..16).prop_map(Value::Bytes),
|
||||
"[a-z]{0,12}".prop_map(Value::Text),
|
||||
]
|
||||
.prop_recursive(4, 32, 4, |inner| {
|
||||
prop_oneof![
|
||||
proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
|
||||
proptest::collection::btree_map("[a-z]{1,6}", inner.clone(), 0..4).prop_map(|m| {
|
||||
Value::Map(m.into_iter().map(|(k, v)| (Value::Text(k), v)).collect())
|
||||
}),
|
||||
inner.prop_map(|v| Value::Tag(1000, Box::new(v))),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
#[test]
|
||||
fn signed_optional_values_preserve_byte_authentication(v in optional_value(), selector in any::<usize>()) {
|
||||
let mut tail = Vec::new();
|
||||
ciborium::into_writer(&Value::Map(vec![(Value::Text("future".into()), v)]), &mut tail).unwrap();
|
||||
prop_assert!(tail.len() < 800);
|
||||
let auth = auth_data(&tail);
|
||||
let signed = signed_assertion(&auth);
|
||||
prop_assert!(verify(&signed).is_ok());
|
||||
// Mutate an authenticated extension byte while retaining its original signature.
|
||||
let mut envelope: Value = ciborium::from_reader(signed.as_slice()).unwrap();
|
||||
let Value::Map(fields) = &mut envelope else { unreachable!() };
|
||||
for (key, value) in fields {
|
||||
if key.as_text() == Some("authenticatorData") {
|
||||
let Value::Bytes(bytes) = value else { unreachable!() };
|
||||
bytes[37 + selector % tail.len()] ^= 1;
|
||||
}
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
ciborium::into_writer(&envelope, &mut changed).unwrap();
|
||||
prop_assert!(verify(&changed).is_err());
|
||||
prop_assert!(verify(&signed[..selector % signed.len()]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_counter_is_accepted_once_and_cannot_wrap() {
|
||||
let key = signing_key().verifying_key().to_encoded_point(false);
|
||||
let mut auth = auth_data(b"\xa0");
|
||||
auth[33..37].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
let signed = signed_assertion(&auth);
|
||||
assert_eq!(
|
||||
super::super::proof::assert(&signed, TRANSCRIPT, APP_ID, key.as_ref(), u32::MAX - 1)
|
||||
.unwrap()
|
||||
.counter,
|
||||
u32::MAX
|
||||
);
|
||||
assert!(
|
||||
super::super::proof::assert(&signed, TRANSCRIPT, APP_ID, key.as_ref(), u32::MAX)
|
||||
.is_err()
|
||||
);
|
||||
auth[33..37].copy_from_slice(&0u32.to_be_bytes());
|
||||
assert!(super::super::proof::assert(
|
||||
&signed_assertion(&auth),
|
||||
TRANSCRIPT,
|
||||
APP_ID,
|
||||
key.as_ref(),
|
||||
u32::MAX
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1277,6 +1277,86 @@ mod postgres_tests {
|
||||
drop_schema(&schema).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires PostgreSQL"]
|
||||
async fn app_attest_challenge_and_counter_fences_survive_races_and_exhaustion() {
|
||||
let (pool, schema) = full_schema(4).await;
|
||||
let store = PostgresAuthorityStore::new(pool.clone());
|
||||
let now = Utc::now().timestamp();
|
||||
let challenge = Challenge {
|
||||
id: Uuid::new_v4(),
|
||||
value: [7; 32],
|
||||
created_at: now,
|
||||
expires_at: now + 300,
|
||||
};
|
||||
store.put_challenge(challenge.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
store.consume_challenge(challenge.id, [8; 32], now).await,
|
||||
Err(AuthorityError::Rejected)
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.consume_challenge(Uuid::new_v4(), challenge.value, now)
|
||||
.await,
|
||||
Err(AuthorityError::Rejected)
|
||||
);
|
||||
store
|
||||
.consume_challenge(challenge.id, challenge.value, now)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.consume_challenge(challenge.id, challenge.value, now)
|
||||
.await,
|
||||
Err(AuthorityError::Rejected)
|
||||
);
|
||||
let expired = Challenge {
|
||||
id: Uuid::new_v4(),
|
||||
expires_at: now - 1,
|
||||
..challenge
|
||||
};
|
||||
store.put_challenge(expired.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.consume_challenge(expired.id, expired.value, now)
|
||||
.await,
|
||||
Err(AuthorityError::Rejected)
|
||||
);
|
||||
|
||||
install_authority(&pool).await;
|
||||
let id = Uuid::from_u128(1);
|
||||
let (a, b) = tokio::join!(
|
||||
store.advance_assertion_counter(id, 0, 1),
|
||||
store.advance_assertion_counter(id, 0, 2)
|
||||
);
|
||||
assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1);
|
||||
let previous = store.installation(id, now).await.unwrap().assertion_counter;
|
||||
assert!(previous == 1 || previous == 2);
|
||||
store
|
||||
.advance_assertion_counter(id, previous, u32::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.advance_assertion_counter(id, u32::MAX, 0).await,
|
||||
Err(AuthorityError::Rejected)
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.advance_assertion_counter(id, u32::MAX, u32::MAX)
|
||||
.await,
|
||||
Err(AuthorityError::Rejected)
|
||||
);
|
||||
assert_eq!(
|
||||
store.installation(id, now).await.unwrap().assertion_counter,
|
||||
u32::MAX
|
||||
);
|
||||
assert!(store.installation(id, now + 31 * 86400).await.is_err());
|
||||
store.revoke_installation(id, 1, 2).await.unwrap();
|
||||
assert!(store.installation(id, now).await.is_err());
|
||||
pool.close().await;
|
||||
drop_schema(&schema).await;
|
||||
}
|
||||
|
||||
async fn drop_schema(schema: &str) {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"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"
|
||||
"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,7 @@
|
||||
{
|
||||
"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"
|
||||
"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,7 @@
|
||||
{
|
||||
"description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.",
|
||||
"description": "Unrelated root: must reject the fresh synthetic enrollment signed by the test CA.",
|
||||
"app_id": "TEAMID.xyz.buzz.mobile",
|
||||
"challenge": "buzz-app-attest-strict-verifier-fixture",
|
||||
"aaguid": "appattest",
|
||||
"attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==",
|
||||
"key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=",
|
||||
"root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\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-----
|
||||
1
crates/buzz-push-gateway/tests/fixtures/apple-structural-attestation.b64
vendored
Normal file
1
crates/buzz-push-gateway/tests/fixtures/apple-structural-attestation.b64
vendored
Normal file
File diff suppressed because one or more lines are too long
114
docs/app-attest-compatibility.md
Normal file
114
docs/app-attest-compatibility.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
|
||||
Credential IDs use the fixed 32-byte SHA-256 size, preserving appattest 0.1.1's
|
||||
workaround for reported iOS 26.x nonstandard credential-length fields. The two
|
||||
length bytes remain authenticated; the credential ID must match the supplied
|
||||
key ID and the certificate key hash, and the COSE key at offset 87 must match
|
||||
the certificate. Signed synthetic tests cover nonstandard lengths, actual
|
||||
missing/extra credential bytes, key mismatch, and unsigned length mutations.
|
||||
No enrollment captured on iOS 26.x has yet validated this reported OS variant.
|
||||
SDK 26.5 tests on iOS 27.0.1 do not substitute for that evidence.
|
||||
|
||||
Every original authenticator byte participates in the certificate nonce or
|
||||
assertion signature. Verification does not truncate or re-encode that byte
|
||||
string. Assertion signatures and enrollment certificate nonces are checked
|
||||
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.
|
||||
|
||||
## Resource characterization
|
||||
|
||||
A manual debug-build run on arm64 macOS (1,000 parses per case) measured about
|
||||
1.61 ms for a 49,131-byte map near the 4,096-node limit, 247 microseconds for a
|
||||
4,097-byte array at that node limit, 0.93 microseconds for a 65,536-byte byte
|
||||
string, and 3.8 microseconds for depth 32. The test process peaked at 10.4 MB
|
||||
resident memory; that includes the test harness and is not a parser-allocation
|
||||
measurement. These are local observations, not latency guarantees or production
|
||||
benchmarks. The manual `characterize_capacity_work` test reproduces the cases
|
||||
without imposing a timing-sensitive CI assertion.
|
||||
|
||||
Node and depth limits bound traversal and key-comparison work; byte limits bound
|
||||
copied payloads. Container capacity grows only after successfully decoding each
|
||||
item, never from an untrusted declared length. The gateway's smaller transport
|
||||
caps apply before these parser limits. Future valid objects can exceed service
|
||||
capacity and receive the distinct internal capacity diagnostic; raising limits
|
||||
requires review and repeating this characterization.
|
||||
|
||||
Generated tests cover 256 nested optional-value/signature-mutation cases and
|
||||
512 arbitrary byte inputs per run. They complement the explicit boundary and
|
||||
encoding-equivalence corpus. Apple's public historical attestation fixture also
|
||||
exercises a complete credential key and extension tail with ED clear; its
|
||||
certificate is not treated as a currently valid enrollment.
|
||||
Reference in New Issue
Block a user