OXYGENHUB

Coverage / crates/flow-core/src/alert.rs 100.0%

55 of 55 lines run, 6 of 11 functions · run #217 on main · run missed

1
//! Sealed alerts (oxygenhub-android#16): what a phone is told about a job,
2
//! encrypted to a key the phone made, so the push service that carries it
3
//! (Firebase Cloud Messaging) sees only ciphertext.
4
//!
5
//! The phone keeps an X25519 secret and gives the hub its public key. For
6
//! each alert the hub makes a fresh X25519 key, agrees a secret with the
7
//! phone's key, derives a ChaCha20-Poly1305 key with HKDF-SHA256 (salted
8
//! with both public keys) and encrypts once under it with a zero nonce:
9
//! the key is never used twice. A sealed alert is the fresh public key (32
10
//! bytes) followed by the ciphertext and its tag.
11
12
use curve25519_dalek::montgomery::MontgomeryPoint;
13
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305};
14
use ring::hkdf;
15
use ring::rand::{SecureRandom, SystemRandom};
16
17
/// What HKDF binds the key to, so a key for anything else never fits.
18
const INFO: &[u8] = b"oxygenhub alert v1";
19
20
/// A new X25519 secret, for a phone to keep.
215×
pub fn new_secret() -> [u8; 32] {
225×
    let mut s = [0u8; 32];
235×
    SystemRandom::new().fill(&mut s).expect("the system's random source");
245×
    s
255×
}
26
27
/// The public key of `secret`, for the hub.
288×
pub fn public_key(secret: &[u8; 32]) -> [u8; 32] {
298×
    MontgomeryPoint::mul_base_clamped(*secret).to_bytes()
308×
}
31
326×
fn key(shared: &MontgomeryPoint, ephemeral: &[u8; 32], recipient: &[u8; 32]) -> Option<LessSafeKey> {
33
    // An all-zero agreement means a low-order point was given: refuse it.
346×
    if shared.to_bytes() == [0u8; 32] {
351×
        return None;
365×
    }
375×
    let mut salt = [0u8; 64];
385×
    salt[..32].copy_from_slice(ephemeral);
395×
    salt[32..].copy_from_slice(recipient);
405×
    let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &salt).extract(shared.as_bytes());
415×
    let okm = prk.expand(&[INFO], &CHACHA20_POLY1305).ok()?;
425×
    let mut bytes = [0u8; 32];
435×
    okm.fill(&mut bytes).ok()?;
445×
    Some(LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, &bytes).ok()?))
456×
}
46
47
/// Seal `message` to the phone whose public key is `recipient`; `None`
48
/// when that key is unusable.
493×
pub fn seal(recipient: &[u8; 32], message: &[u8]) -> Option<Vec<u8>> {
503×
    let e = new_secret();
513×
    let ephemeral = public_key(&e);
523×
    let shared = MontgomeryPoint(*recipient).mul_clamped(e);
533×
    let key = key(&shared, &ephemeral, recipient)?;
542×
    let mut sealed = message.to_vec();
552×
    key.seal_in_place_append_tag(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut sealed).ok()?;
562×
    let mut out = ephemeral.to_vec();
572×
    out.extend(sealed);
582×
    Some(out)
593×
}
60
61
/// Open an alert sealed to the phone whose secret is `secret`; `None` when
62
/// it was not sealed to it or was changed on the way.
634×
pub fn open(secret: &[u8; 32], sealed: &[u8]) -> Option<Vec<u8>> {
644×
    if sealed.len() < 32 + CHACHA20_POLY1305.tag_len() {
651×
        return None;
663×
    }
673×
    let ephemeral: [u8; 32] = sealed[..32].try_into().ok()?;
683×
    let shared = MontgomeryPoint(ephemeral).mul_clamped(*secret);
693×
    let key = key(&shared, &ephemeral, &public_key(secret))?;
703×
    let mut body = sealed[32..].to_vec();
713×
    let plain = key.open_in_place(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut body).ok()?;
721×
    Some(plain.to_vec())
734×
}
74
75
#[cfg(test)]
76
mod tests {
77
    use super::*;
78
79
    #[test]
801×
    fn a_sealed_alert_opens_only_with_its_phones_key() {
811×
        let phone = new_secret();
821×
        let sealed = seal(&public_key(&phone), b"run #5: test passed").unwrap();
831×
        assert_eq!(open(&phone, &sealed).as_deref(), Some(&b"run #5: test passed"[..]));
84
        // Each alert has its own key: the same message seals differently.
851×
        assert_ne!(seal(&public_key(&phone), b"run #5: test passed").unwrap(), sealed);
861×
        assert!(open(&new_secret(), &sealed).is_none(), "another phone");
871×
        let mut changed = sealed.clone();
881×
        *changed.last_mut().unwrap() ^= 1;
891×
        assert!(open(&phone, &changed).is_none(), "changed on the way");
901×
        assert!(open(&phone, &sealed[..40]).is_none(), "cut short");
91
        // A low-order public key (all zeros) is refused, not sealed to.
921×
        assert!(seal(&[0u8; 32], b"x").is_none());
931×
    }
94
}