Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
airdrop
src
apple.rs
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
apple.rs
Blame
History
Raw
//! What Apple signed: validation records and AirDrop client certificates. //! //! Every Apple ID that can use AirDrop has two things from `aidc.apple.com`: a //! certificate (`CN=com.apple.idms.appleid.prd.<altDSID>`) and a *validation //! record*, a CMS `SignedData` over a property list that names the account //! (`altDsID`) and carries the SHA-256 of every email address and phone number //! verified for it. A receiver in Contacts Only mode trusts a sender if both //! check out and one of those hashes is in its address book. //! //! The checks are done here with RustCrypto and nothing else. Two things //! make that more than a convenience: //! //! - Apple still signs the records with `sha1WithRSAEncryption`. OpenSSL 3.5 on //! Fedora refuses SHA-1 signatures under its default policy, so a program //! that shells out to `openssl cms -verify` rejects every genuine record. //! - Apple's intermediates expire (the bundled AAI CA's `notAfter` was //! 2026-10-21) while the same key keeps issuing. Trust here is therefore by //! *signature* up to the pinned Apple Root CA: the certificates that anchor //! a chain (the root and the intermediates) are not checked against the //! clock, because they are pinned by us, not discovered on the air. Records //! and leaf certificates are checked against the clock. use std::collections::BTreeSet; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use cms::cert::CertificateChoices; use cms::content_info::ContentInfo; use cms::signed_data::{SignedData, SignerIdentifier, SignerInfo}; use const_oid::ObjectIdentifier; use der::asn1::OctetString; use der::{Decode, Encode}; use rsa::pkcs8::DecodePublicKey; use rsa::{Pkcs1v15Sign, RsaPublicKey}; use sha1::Sha1; use sha2::{Digest, Sha256, Sha384, Sha512}; use thiserror::Error; use x509_cert::Certificate; /// Apple Root CA, PEM. Pinned: public, no account needed. pub const ROOT_CA_PEM: &[u8] = include_bytes!("../certs/apple_root_ca.pem"); /// Apple's issuing intermediates, PEM. A sender presents its leaf (and maybe /// its intermediate), so these let a chain to the root be built regardless. pub const INTERMEDIATES_PEM: &[&[u8]] = &[ include_bytes!("../certs/apple-aai-ca.pem"), include_bytes!("../certs/apple-aai2-ca.pem"), ]; /// The subject prefix of an Apple-issued AirDrop client certificate. pub const CN_PREFIX: &str = "com.apple.idms.appleid.prd."; const OID_COMMON_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.3"); const OID_MESSAGE_DIGEST: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.4"); const OID_RSA_ENCRYPTION: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); const OID_SHA1_RSA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.5"); const OID_SHA256_RSA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"); const OID_SHA384_RSA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.12"); const OID_SHA512_RSA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.13"); const OID_SHA1: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.14.3.2.26"); const OID_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1"); const OID_SHA384: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.2"); const OID_SHA512: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.3"); /// How deep a chain from a leaf to the root may be. const MAX_CHAIN: usize = 6; #[derive(Debug, Error)] pub enum RecordError { #[error("not a CMS SignedData: {0}")] Malformed(String), #[error("the record has no signature")] Unsigned, #[error("the signature does not verify: {0}")] BadSignature(String), #[error("the signer does not chain to Apple Root CA")] UntrustedSigner, #[error("the signer's certificate is outside its validity period")] SignerExpired, #[error("the signed content is not a validation record: {0}")] Content(String), } /// What a validation record says, once Apple's signature over it checks out. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Record { /// `altDsID`: the Apple account. pub account: String, /// `ValidatedEmailHashes` and `ValidatedPhoneHashes`, lowercase hex. pub hashes: BTreeSet<String>, /// `ValidAsOf` + `SuggestValidDuration`, when both are present. pub expires: Option<SystemTime>, } impl Record { /// Past its `ValidAsOf + SuggestValidDuration`. A record that states no /// expiry never expires. pub fn is_expired_at(&self, now: SystemTime) -> bool { self.expires.is_some_and(|expires| expires < now) } } // ----------------------------------------------------------------- records /// Verify `record` (a DER `ContentInfo`) against the pinned Apple chain. pub fn verify_record(record: &[u8]) -> Result<Record, RecordError> { verify_record_at(record, SystemTime::now(), &Anchors::apple()) } /// [`verify_record`] with an explicit clock and trust anchors, for tests. pub fn verify_record_at( record: &[u8], now: SystemTime, anchors: &Anchors, ) -> Result<Record, RecordError> { let (signed, content) = parse_signed_data(record)?; let embedded: Vec<Certificate> = signed .certificates .iter() .flat_map(|set| set.0.iter()) .filter_map(|choice| match choice { CertificateChoices::Certificate(cert) => Some(cert.clone()), _ => None, }) .collect(); let signer_info = signed .signer_infos .0 .iter() .next() .ok_or(RecordError::Unsigned)?; let signer = find_signer(signer_info, &embedded).ok_or_else(|| { RecordError::BadSignature("the signer's certificate is not included".into()) })?; verify_signer_info(signer_info, signer, &content)?; if !anchors.chains_to_root(signer, &embedded, now, true)? { return Err(RecordError::UntrustedSigner); } record_from_plist(&content) } /// The signed property list inside a record, **without** checking the /// signature. Only for reading what our own record says (the BLE /// advertisement); never use it to decide whether to trust anyone. pub fn record_content(record: &[u8]) -> Result<Vec<u8>, RecordError> { parse_signed_data(record).map(|(_, content)| content) } /// The record's hashes, from content that has already been verified or comes /// from our own record. pub fn record_from_plist(content: &[u8]) -> Result<Record, RecordError> { let value = plist::Value::from_reader(std::io::Cursor::new(content)) .map_err(|e| RecordError::Content(e.to_string()))?; let dict = value .as_dictionary() .ok_or_else(|| RecordError::Content("not a dictionary".into()))?; let duration = match dict.get("SuggestValidDuration") { Some(plist::Value::Integer(i)) => i .as_signed() .map(|n| n as f64) .or(i.as_unsigned().map(|n| n as f64)), Some(plist::Value::Real(r)) => Some(*r), _ => None, }; let expires = match (dict.get("ValidAsOf").and_then(|v| v.as_date()), duration) { (Some(date), Some(seconds)) if seconds.is_finite() && seconds >= 0.0 => { SystemTime::from(date).checked_add(Duration::from_secs_f64(seconds)) } _ => None, }; let mut hashes = BTreeSet::new(); for key in ["ValidatedEmailHashes", "ValidatedPhoneHashes"] { if let Some(list) = dict.get(key).and_then(|v| v.as_array()) { hashes.extend( list.iter() .filter_map(|h| h.as_string()) .map(str::to_lowercase), ); } } Ok(Record { account: dict .get("altDsID") .and_then(|v| v.as_string()) .unwrap_or("") .to_owned(), hashes, expires, }) } fn parse_signed_data(record: &[u8]) -> Result<(SignedData, Vec<u8>), RecordError> { let bad = |e: der::Error| RecordError::Malformed(e.to_string()); let record = crate::ber::to_der(record).map_err(|e| RecordError::Malformed(e.to_string()))?; let info = ContentInfo::from_der(&record).map_err(bad)?; let signed: SignedData = info.content.decode_as().map_err(bad)?; let content = signed .encap_content_info .econtent .as_ref() .ok_or_else(|| RecordError::Malformed("no content".into()))? .decode_as::<OctetString>() .map_err(bad)? .into_bytes(); Ok((signed, content)) } fn find_signer<'a>(info: &SignerInfo, certs: &'a [Certificate]) -> Option<&'a Certificate> { certs.iter().find(|cert| match &info.sid { SignerIdentifier::IssuerAndSerialNumber(id) => { cert.tbs_certificate.issuer == id.issuer && cert.tbs_certificate.serial_number == id.serial_number } SignerIdentifier::SubjectKeyIdentifier(ski) => { cert.tbs_certificate.extensions.iter().flatten().any(|ext| { ext.extn_id == const_oid::db::rfc5912::ID_CE_SUBJECT_KEY_IDENTIFIER && subject_key_id(ext.extn_value.as_bytes()).as_deref() == Some(ski.0.as_bytes()) }) } }) } /// The bytes of a SubjectKeyIdentifier extension (an OCTET STRING in the /// extension's OCTET STRING). fn subject_key_id(extn: &[u8]) -> Option<Vec<u8>> { OctetString::from_der(extn) .ok() .map(OctetString::into_bytes) } fn verify_signer_info( info: &SignerInfo, signer: &Certificate, content: &[u8], ) -> Result<(), RecordError> { let digest_oid = info.digest_alg.oid; let signed_bytes: Vec<u8> = match &info.signed_attrs { Some(attrs) => { let wanted = attrs .iter() .find(|attr| attr.oid == OID_MESSAGE_DIGEST) .and_then(|attr| attr.values.iter().next()) .and_then(|value| value.decode_as::<OctetString>().ok()) .ok_or_else(|| RecordError::BadSignature("no messageDigest attribute".into()))?; let actual = digest(digest_oid, content).ok_or_else(|| { RecordError::BadSignature(format!("unsupported digest {digest_oid}")) })?; if wanted.as_bytes() != actual.as_slice() { return Err(RecordError::BadSignature("the content was altered".into())); } // The signature covers the attributes re-encoded as a SET OF. attrs .to_der() .map_err(|e| RecordError::BadSignature(e.to_string()))? } None => content.to_vec(), }; // `rsaEncryption` leaves the hash to digestAlgorithm; the others name it. let hash_oid = match info.signature_algorithm.oid { OID_RSA_ENCRYPTION => digest_oid, other => signature_hash(other).ok_or_else(|| { RecordError::BadSignature(format!("unsupported signature algorithm {other}")) })?, }; verify_rsa(signer, hash_oid, &signed_bytes, info.signature.as_bytes()) .map_err(RecordError::BadSignature) } // ------------------------------------------------------------ certificates /// Where a chain may end, and what may be in the middle of it. pub struct Anchors { roots: Vec<Certificate>, intermediates: Vec<Certificate>, } impl Anchors { /// Apple Root CA and the pinned Apple intermediates. pub fn apple() -> Self { Self { roots: certificates_from_pem(ROOT_CA_PEM), intermediates: INTERMEDIATES_PEM .iter() .flat_map(|pem| certificates_from_pem(pem)) .collect(), } } /// Custom roots and intermediates (tests). pub fn new(roots: Vec<Certificate>, intermediates: Vec<Certificate>) -> Self { Self { roots, intermediates, } } /// Whether `leaf` is signed, through `extra` (certificates the peer /// supplied) and the pinned intermediates, by one of the roots. /// /// `leaf` itself must be valid at `now` when `check_leaf` is set; CA /// certificates are pinned or signed by something pinned, so their dates /// are not consulted (see the module docs). fn chains_to_root( &self, leaf: &Certificate, extra: &[Certificate], now: SystemTime, check_leaf: bool, ) -> Result<bool, RecordError> { if check_leaf && !valid_at(leaf, now) { return Err(RecordError::SignerExpired); } let mut current = leaf.clone(); for _ in 0..MAX_CHAIN { // Signed directly by a pinned root? if self .roots .iter() .any(|root| issued_by(¤t, root).unwrap_or(false)) { return Ok(true); } // Otherwise by an intermediate, pinned or supplied, which is // followed in turn. A supplied intermediate is only as good as // the signature that ties it to the root, which the next // iteration checks. let next = self .intermediates .iter() .chain(extra.iter()) .find(|cand| cand != &¤t && issued_by(¤t, cand).unwrap_or(false)); match next { Some(next) => current = next.clone(), None => return Ok(false), } } Ok(false) } } /// The Apple account a DER client certificate belongs to, or `None` when it /// does not chain to Apple Root CA or is not an AirDrop identity. /// /// Never a name check: anyone can write Apple's name into a self-signed /// certificate. `chain` is whatever else the peer presented (its intermediate). pub fn certificate_account(der: &[u8], chain: &[&[u8]]) -> Option<String> { certificate_account_at(der, chain, SystemTime::now(), &Anchors::apple()) } pub fn certificate_account_at( der: &[u8], chain: &[&[u8]], now: SystemTime, anchors: &Anchors, ) -> Option<String> { let leaf = Certificate::from_der(der).ok()?; let extra: Vec<Certificate> = chain .iter() .filter_map(|c| Certificate::from_der(c).ok()) .collect(); if !anchors.chains_to_root(&leaf, &extra, now, true).ok()? { return None; } let cn = common_name(&leaf)?; cn.strip_prefix(CN_PREFIX).map(str::to_owned) } /// The last `commonName` of the subject. pub fn common_name(cert: &Certificate) -> Option<String> { cert.tbs_certificate .subject .0 .iter() .flat_map(|rdn| rdn.0.iter()) .filter(|atv| atv.oid == OID_COMMON_NAME) .filter_map(|atv| { atv.value .decode_as::<der::asn1::Utf8StringRef>() .map(|s| s.as_str().to_owned()) .or_else(|_| { atv.value .decode_as::<der::asn1::PrintableStringRef>() .map(|s| s.as_str().to_owned()) }) .ok() }) .next_back() } pub fn certificates_from_pem(pem: &[u8]) -> Vec<Certificate> { Certificate::load_pem_chain(pem).unwrap_or_default() } fn valid_at(cert: &Certificate, now: SystemTime) -> bool { let validity = &cert.tbs_certificate.validity; now >= validity.not_before.to_system_time() && now <= validity.not_after.to_system_time() } /// Whether `child`'s signature verifies under `issuer`'s key (names are /// compared too, so a wrong candidate is not tried against every signature). fn issued_by(child: &Certificate, issuer: &Certificate) -> Result<bool, String> { if child.tbs_certificate.issuer != issuer.tbs_certificate.subject { return Ok(false); } let hash = signature_hash(child.signature_algorithm.oid) .ok_or_else(|| format!("unsupported algorithm {}", child.signature_algorithm.oid))?; let tbs = child.tbs_certificate.to_der().map_err(|e| e.to_string())?; let signature = child .signature .as_bytes() .ok_or_else(|| "signature is not a whole number of bytes".to_string())?; Ok(verify_rsa(issuer, hash, &tbs, signature).is_ok()) } fn signature_hash(algorithm: ObjectIdentifier) -> Option<ObjectIdentifier> { match algorithm { OID_SHA1_RSA => Some(OID_SHA1), OID_SHA256_RSA => Some(OID_SHA256), OID_SHA384_RSA => Some(OID_SHA384), OID_SHA512_RSA => Some(OID_SHA512), _ => None, } } fn digest(hash: ObjectIdentifier, data: &[u8]) -> Option<Vec<u8>> { Some(match hash { OID_SHA1 => Sha1::digest(data).to_vec(), OID_SHA256 => Sha256::digest(data).to_vec(), OID_SHA384 => Sha384::digest(data).to_vec(), OID_SHA512 => Sha512::digest(data).to_vec(), _ => return None, }) } /// RSASSA-PKCS1-v1_5 of `message` under `signer`'s public key. fn verify_rsa( signer: &Certificate, hash: ObjectIdentifier, message: &[u8], signature: &[u8], ) -> Result<(), String> { let spki = signer .tbs_certificate .subject_public_key_info .to_der() .map_err(|e| e.to_string())?; let key = RsaPublicKey::from_public_key_der(&spki).map_err(|e| format!("not an RSA key: {e}"))?; let hashed = digest(hash, message).ok_or_else(|| format!("unsupported digest {hash}"))?; let scheme = match hash { OID_SHA1 => Pkcs1v15Sign::new::<Sha1>(), OID_SHA256 => Pkcs1v15Sign::new::<Sha256>(), OID_SHA384 => Pkcs1v15Sign::new::<Sha384>(), OID_SHA512 => Pkcs1v15Sign::new::<Sha512>(), _ => return Err(format!("unsupported digest {hash}")), }; key.verify(scheme, &hashed, signature) .map_err(|e| e.to_string()) } /// Seconds since the epoch, for log lines and expiry arithmetic. pub fn unix_secs(time: SystemTime) -> u64 { time.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } #[cfg(test)] mod tests { use super::*; #[test] fn the_pinned_certificates_parse_and_the_root_is_self_issued() { let roots = certificates_from_pem(ROOT_CA_PEM); assert_eq!(roots.len(), 1); assert!( issued_by(&roots[0], &roots[0]).unwrap(), "root signs itself (SHA-1)" ); let anchors = Anchors::apple(); assert_eq!(anchors.intermediates.len(), 2); for intermediate in &anchors.intermediates { assert!(issued_by(intermediate, &roots[0]).unwrap()); } } #[test] fn garbage_is_not_a_record() { assert!(matches!( verify_record(b"hello"), Err(RecordError::Malformed(_)) )); } #[test] fn a_certificate_that_is_not_apples_has_no_account() { assert_eq!(certificate_account(b"not a certificate", &[]), None); } } #[cfg(test)] mod pki_tests { use super::*; use crate::testpki::Pki; use der::Encode; fn now() -> SystemTime { SystemTime::now() } fn hashes() -> Vec<String> { vec![crate::contacts::hash_identifier("alice@example.com")] } #[test] fn a_record_signed_through_the_chain_verifies() { let pki = Pki::shared(); let record = pki.record("ACCT-1", &hashes(), now(), 3600); let info = verify_record_at(&record, now(), &pki.anchors()).unwrap(); assert_eq!(info.account, "ACCT-1"); assert_eq!(info.hashes.len(), 1); assert!(!info.is_expired_at(now())); assert!(info.is_expired_at(now() + Duration::from_secs(7200))); } #[test] fn a_tampered_record_does_not_verify() { let pki = Pki::shared(); let mut record = pki.record("ACCT-1", &hashes(), now(), 3600); // Change one byte of the signed content: find the account string. let at = record.windows(6).position(|w| w == b"ACCT-1").unwrap(); record[at + 5] = b'2'; assert!(matches!( verify_record_at(&record, now(), &pki.anchors()), Err(RecordError::BadSignature(_)) )); } #[test] fn a_record_from_another_root_is_untrusted() { let pki = Pki::shared(); let other = Pki::new(); let record = other.record("ACCT-1", &hashes(), now(), 3600); assert!(matches!( verify_record_at(&record, now(), &pki.anchors()), Err(RecordError::UntrustedSigner) )); } #[test] fn a_signer_outside_its_validity_is_refused() { let pki = Pki::shared(); let record = pki.record("ACCT-1", &hashes(), now(), 3600); let later = now() + Duration::from_secs(3 * 365 * 24 * 3600); assert!(matches!( verify_record_at(&record, later, &pki.anchors()), Err(RecordError::SignerExpired) )); } #[test] fn a_client_certificate_names_its_account_only_through_the_chain() { let pki = Pki::shared(); let (_, cert) = pki.client("ACCT-1"); let der = cert.to_der().unwrap(); assert_eq!( certificate_account_at(&der, &[], now(), &pki.anchors()).as_deref(), Some("ACCT-1") ); // The same name under another root proves nothing. let other = Pki::new(); let (_, forged) = other.client("ACCT-1"); assert_eq!( certificate_account_at(&forged.to_der().unwrap(), &[], now(), &pki.anchors()), None ); // Neither does Apple's real chain accept ours. assert_eq!(certificate_account(&der, &[]), None); } }