//! Sealed alerts (oxygenhub-android#16): what a phone is told about a job, //! encrypted to a key the phone made, so the push service that carries it //! (Firebase Cloud Messaging) sees only ciphertext. //! //! The phone keeps an X25519 secret and gives the hub its public key. For //! each alert the hub makes a fresh X25519 key, agrees a secret with the //! phone's key, derives a ChaCha20-Poly1305 key with HKDF-SHA256 (salted //! with both public keys) and encrypts once under it with a zero nonce: //! the key is never used twice. A sealed alert is the fresh public key (32 //! bytes) followed by the ciphertext and its tag. use curve25519_dalek::montgomery::MontgomeryPoint; use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305}; use ring::hkdf; use ring::rand::{SecureRandom, SystemRandom}; /// What HKDF binds the key to, so a key for anything else never fits. const INFO: &[u8] = b"oxygenhub alert v1"; /// A new X25519 secret, for a phone to keep. pub fn new_secret() -> [u8; 32] { let mut s = [0u8; 32]; SystemRandom::new().fill(&mut s).expect("the system's random source"); s } /// The public key of `secret`, for the hub. pub fn public_key(secret: &[u8; 32]) -> [u8; 32] { MontgomeryPoint::mul_base_clamped(*secret).to_bytes() } fn key(shared: &MontgomeryPoint, ephemeral: &[u8; 32], recipient: &[u8; 32]) -> Option { // An all-zero agreement means a low-order point was given: refuse it. if shared.to_bytes() == [0u8; 32] { return None; } let mut salt = [0u8; 64]; salt[..32].copy_from_slice(ephemeral); salt[32..].copy_from_slice(recipient); let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &salt).extract(shared.as_bytes()); let okm = prk.expand(&[INFO], &CHACHA20_POLY1305).ok()?; let mut bytes = [0u8; 32]; okm.fill(&mut bytes).ok()?; Some(LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, &bytes).ok()?)) } /// Seal `message` to the phone whose public key is `recipient`; `None` /// when that key is unusable. pub fn seal(recipient: &[u8; 32], message: &[u8]) -> Option> { let e = new_secret(); let ephemeral = public_key(&e); let shared = MontgomeryPoint(*recipient).mul_clamped(e); let key = key(&shared, &ephemeral, recipient)?; let mut sealed = message.to_vec(); key.seal_in_place_append_tag(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut sealed).ok()?; let mut out = ephemeral.to_vec(); out.extend(sealed); Some(out) } /// Open an alert sealed to the phone whose secret is `secret`; `None` when /// it was not sealed to it or was changed on the way. pub fn open(secret: &[u8; 32], sealed: &[u8]) -> Option> { if sealed.len() < 32 + CHACHA20_POLY1305.tag_len() { return None; } let ephemeral: [u8; 32] = sealed[..32].try_into().ok()?; let shared = MontgomeryPoint(ephemeral).mul_clamped(*secret); let key = key(&shared, &ephemeral, &public_key(secret))?; let mut body = sealed[32..].to_vec(); let plain = key.open_in_place(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut body).ok()?; Some(plain.to_vec()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_sealed_alert_opens_only_with_its_phones_key() { let phone = new_secret(); let sealed = seal(&public_key(&phone), b"run #5: test passed").unwrap(); assert_eq!(open(&phone, &sealed).as_deref(), Some(&b"run #5: test passed"[..])); // Each alert has its own key: the same message seals differently. assert_ne!(seal(&public_key(&phone), b"run #5: test passed").unwrap(), sealed); assert!(open(&new_secret(), &sealed).is_none(), "another phone"); let mut changed = sealed.clone(); *changed.last_mut().unwrap() ^= 1; assert!(open(&phone, &changed).is_none(), "changed on the way"); assert!(open(&phone, &sealed[..40]).is_none(), "cut short"); // A low-order public key (all zeros) is refused, not sealed to. assert!(seal(&[0u8; 32], b"x").is_none()); } }