Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
airdrop
src
contacts.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
contacts.rs
Blame
History
Raw
//! Contacts Only: is an AirDrop sender someone this machine knows? //! //! Apple's "Contacts Only" is not a name check. Every sender that proposes a //! transfer presents two things (measured from real transfers, 2026-09-18): //! //! - an Apple-issued client certificate, on `/Ask` and `/Upload` but never on //! `/Discover`, subject `CN=com.apple.idms.appleid.prd.<altDsID>`; //! - an Apple ID validation record (see [`crate::apple`]): CMS signed by //! Apple, carrying the SHA-256 of every phone number and email address //! verified for that Apple ID, and the same altDsID. //! //! Three checks, and all three matter: the record's signature verifies to //! Apple's root (otherwise anyone can assert any identifiers); the //! certificate chains to Apple *and* names the record's account (otherwise a //! record harvested off the air by a passive prober could be replayed by //! someone without the matching key; records are handed to any prober that //! asks); and a hashed identifier in the record is in our address book. //! `SenderComputerName` is never trusted: anybody can call their laptop //! anything. use std::collections::BTreeSet; use std::time::SystemTime; use sha2::{Digest, Sha256}; use crate::apple::{self, Record}; /// Who may see this machine, and so who may send to it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Visibility { #[default] Everyone, Contacts, } impl Visibility { pub fn as_str(self) -> &'static str { match self { Visibility::Everyone => "everyone", Visibility::Contacts => "contacts", } } pub fn parse(text: &str) -> Option<Visibility> { match text.trim().to_ascii_lowercase().as_str() { "everyone" => Some(Visibility::Everyone), "contacts" => Some(Visibility::Contacts), _ => None, } } } /// A policy outcome. The receiver turns it into an HTTP answer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Outcome { Accept, NoRecord, BadRecord, NoCertificate, Unbound, Unknown, } impl Outcome { pub fn why(self) -> &'static str { match self { Outcome::Accept => "sender is a known contact", Outcome::NoRecord => "sender sent no Apple ID validation record", Outcome::BadRecord => "the validation record is not signed by Apple, or has expired", Outcome::NoCertificate => "sender presented no client certificate", Outcome::Unbound => { "the validation record does not belong to the presented certificate" } Outcome::Unknown => "sender is not in the contacts list", } } } /// Normalise a phone number or email the way AirDrop does before hashing: /// email lowercased, phone digits only (`+1 (234) 567-8901` → `12345678901`). /// Getting this wrong is a silent non-match, which looks exactly like "not a /// contact". `None` for blanks. /// /// A number written without a `+` (a national format) has no country in it; /// `default_country_code` (digits, e.g. `"33"`) is prefixed, and a single /// leading trunk `0` is dropped first, as in `06 12 34 56 78` → `33612345678`. pub fn normalize(entry: &str, default_country_code: Option<&str>) -> Option<String> { let entry = entry.trim(); if entry.is_empty() { return None; } if entry.contains('@') { return Some(entry.to_lowercase()); } let international = entry.starts_with('+') || entry.starts_with("00"); let mut digits: String = entry.chars().filter(char::is_ascii_digit).collect(); if digits.is_empty() { return None; } if let Some(rest) = digits .strip_prefix("00") .filter(|_| entry.starts_with("00")) { digits = rest.to_owned(); } else if !international && let Some(cc) = default_country_code { digits = format!("{cc}{}", digits.strip_prefix('0').unwrap_or(&digits)); } Some(digits) } /// Lowercase hex SHA-256 of a normalised identifier. pub fn hash_identifier(normalized: &str) -> String { hex::encode(Sha256::digest(normalized.as_bytes())) } /// The hashed identifiers of everyone we accept from. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ContactBook { hashes: BTreeSet<String>, } impl ContactBook { pub fn new() -> ContactBook { ContactBook::default() } /// Add one email address or phone number as the contact has it. pub fn add(&mut self, entry: &str, default_country_code: Option<&str>) { if let Some(n) = normalize(entry, default_country_code) { self.hashes.insert(hash_identifier(&n)); } } pub fn from_entries<'a>( entries: impl IntoIterator<Item = &'a str>, default_country_code: Option<&str>, ) -> ContactBook { let mut book = ContactBook::new(); for e in entries { book.add(e, default_country_code); } book } /// Adopt hashes that are already computed (the form a saved book has). pub fn from_hashes(hashes: impl IntoIterator<Item = String>) -> ContactBook { ContactBook { hashes: hashes.into_iter().map(|h| h.to_lowercase()).collect(), } } pub fn hashes(&self) -> &BTreeSet<String> { &self.hashes } pub fn len(&self) -> usize { self.hashes.len() } pub fn is_empty(&self) -> bool { self.hashes.is_empty() } fn knows(&self, record: &Record) -> bool { !self.is_empty() && record.hashes.iter().any(|h| self.hashes.contains(h)) } } /// Should we accept a transfer from this sender? `peer_chain` is what the /// sender presented in TLS, leaf first (empty in Everyone mode). pub fn decide( record: Option<&[u8]>, peer_chain: &[Vec<u8>], book: &ContactBook, now: SystemTime, ) -> Outcome { decide_with(&apple::Anchors::apple(), record, peer_chain, book, now) } /// [`decide`] against explicit trust anchors (tests). pub fn decide_with( anchors: &apple::Anchors, record: Option<&[u8]>, peer_chain: &[Vec<u8>], book: &ContactBook, now: SystemTime, ) -> Outcome { let Some(record) = record.filter(|r| !r.is_empty()) else { return Outcome::NoRecord; }; let info = match apple::verify_record_at(record, now, anchors) { Ok(info) if !info.is_expired_at(now) => info, _ => return Outcome::BadRecord, }; let Some(leaf) = peer_chain.first().filter(|c| !c.is_empty()) else { return Outcome::NoCertificate; }; let rest: Vec<&[u8]> = peer_chain[1..].iter().map(Vec::as_slice).collect(); match apple::certificate_account_at(leaf, &rest, now, anchors) { Some(account) if account == info.account => {} _ => return Outcome::Unbound, } if book.knows(&info) { Outcome::Accept } else { Outcome::Unknown } } /// May this sender even see us? DELIBERATELY WEAKER THAN [`decide`], and /// usable only for discovery. /// /// A sender presents its certificate on `/Ask` and never on `/Discover`, so /// here nothing binds the record to the connection: a replay of a contact's /// record is answered. That must never gate a transfer; it only keeps this /// machine out of an honest stranger's share sheet, as Apple's mode does. /// /// FAILS OPEN: a missing, malformed or expired record is accepted, because /// "we could not tell who this is" must not become "hide from a device we /// have never measured". The `/Ask` gate refuses the transfer either way. pub fn decide_discovery(record: Option<&[u8]>, book: &ContactBook, now: SystemTime) -> Outcome { decide_discovery_with(&apple::Anchors::apple(), record, book, now) } /// [`decide_discovery`] against explicit trust anchors (tests). pub fn decide_discovery_with( anchors: &apple::Anchors, record: Option<&[u8]>, book: &ContactBook, now: SystemTime, ) -> Outcome { let Some(record) = record.filter(|r| !r.is_empty()) else { return Outcome::Accept; }; match apple::verify_record_at(record, now, anchors) { Ok(info) if !info.is_expired_at(now) => { if book.knows(&info) { Outcome::Accept } else { Outcome::Unknown } } _ => Outcome::Accept, } } #[cfg(test)] mod tests { use super::*; #[test] fn identifiers_are_normalised_as_airdrop_does() { assert_eq!( normalize("+1 (234) 567-8901", None).as_deref(), Some("12345678901") ); assert_eq!( normalize(" Alice@Example.COM ", None).as_deref(), Some("alice@example.com") ); assert_eq!( normalize("0033 6 12 34 56 78", None).as_deref(), Some("33612345678") ); assert_eq!( normalize("06 12 34 56 78", Some("33")).as_deref(), Some("33612345678") ); assert_eq!( normalize("+33 6 12 34 56 78", Some("1")).as_deref(), Some("33612345678") ); assert_eq!(normalize(" ", None), None); assert_eq!(normalize("n/a", None), None); } #[test] fn the_hash_is_sha256_of_the_normalised_text() { // sha256("alice@example.com") assert_eq!( hash_identifier("alice@example.com"), hex::encode(Sha256::digest(b"alice@example.com")) ); let book = ContactBook::from_entries(["Alice@Example.com", "+33 6 12 34 56 78"], None); assert_eq!(book.len(), 2); assert!( book.hashes() .contains(&hash_identifier("alice@example.com")) ); } #[test] fn no_record_is_refused_but_still_discoverable() { let book = ContactBook::from_entries(["a@b.c"], None); assert_eq!( decide(None, &[], &book, SystemTime::now()), Outcome::NoRecord ); assert_eq!( decide_discovery(None, &book, SystemTime::now()), Outcome::Accept ); // A record that is not signed by Apple is refused for a transfer and // does not hide us from discovery. assert_eq!( decide(Some(b"junk"), &[], &book, SystemTime::now()), Outcome::BadRecord ); assert_eq!( decide_discovery(Some(b"junk"), &book, SystemTime::now()), Outcome::Accept ); } } #[cfg(test)] mod policy_tests { use super::*; use crate::testpki::Pki; use der::Encode; use std::time::Duration; fn now() -> SystemTime { SystemTime::now() } fn chain_of(cert: &x509_cert::Certificate) -> Vec<Vec<u8>> { vec![cert.to_der().unwrap()] } #[test] fn a_known_contact_with_a_bound_certificate_is_accepted() { let pki = Pki::shared(); let book = ContactBook::from_entries(["Alice@example.com"], None); let record = pki.record( "ACCT-1", &[hash_identifier("alice@example.com")], now(), 3600, ); let (_, cert) = pki.client("ACCT-1"); let anchors = pki.anchors(); assert_eq!( decide_with(&anchors, Some(&record), &chain_of(&cert), &book, now()), Outcome::Accept ); // A stranger's identifiers. let stranger = ContactBook::from_entries(["bob@example.com"], None); assert_eq!( decide_with(&anchors, Some(&record), &chain_of(&cert), &stranger, now()), Outcome::Unknown ); assert_eq!( decide_with( &anchors, Some(&record), &chain_of(&cert), &ContactBook::new(), now() ), Outcome::Unknown ); // No certificate: a replayed record with nothing behind it. assert_eq!( decide_with(&anchors, Some(&record), &[], &book, now()), Outcome::NoCertificate ); } #[test] fn a_replayed_record_is_not_bound_to_someone_elses_certificate() { let pki = Pki::shared(); let book = ContactBook::from_entries(["alice@example.com"], None); let record = pki.record( "ACCT-1", &[hash_identifier("alice@example.com")], now(), 3600, ); // The replayer holds a genuine certificate of its own account. let (_, theirs) = pki.client("ACCT-2"); assert_eq!( decide_with( &pki.anchors(), Some(&record), &chain_of(&theirs), &book, now() ), Outcome::Unbound ); // And a self-signed one is no account at all. let own = crate::identity::Identity::self_signed("x").unwrap(); let chain = vec![own.leaf().as_ref().to_vec()]; assert_eq!( decide_with(&pki.anchors(), Some(&record), &chain, &book, now()), Outcome::Unbound ); } #[test] fn an_expired_record_is_bad_and_discovery_follows_the_record() { let pki = Pki::shared(); let book = ContactBook::from_entries(["alice@example.com"], None); let old = pki.record( "ACCT-1", &[hash_identifier("alice@example.com")], now() - Duration::from_secs(7200), 3600, ); let (_, cert) = pki.client("ACCT-1"); assert_eq!( decide_with(&pki.anchors(), Some(&old), &chain_of(&cert), &book, now()), Outcome::BadRecord ); let fresh = pki.record( "ACCT-1", &[hash_identifier("alice@example.com")], now(), 3600, ); assert_eq!( decide_discovery_with(&pki.anchors(), Some(&fresh), &book, now()), Outcome::Accept ); let other = ContactBook::from_entries(["bob@example.com"], None); assert_eq!( decide_discovery_with(&pki.anchors(), Some(&fresh), &other, now()), Outcome::Unknown ); } }