OXYGENHUB
◆ v0.5.1 oxygenflow / crates / flow-core / src / alert.rs
4.0 KiB · Rust · last changed Sealed alerts: flow_core::alert encrypts what a phone is told to an X25519 key it made (fresh key per alert, HKDF-SHA256, ChaCha20-Poly1305), from crates already in the tree (refs oxygenhub-android#16) 8 days ago source blame history raw
060d459f Andy McCall ↶ 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.
21
pub fn new_secret() -> [u8; 32] {
22
    let mut s = [0u8; 32];
23
    SystemRandom::new().fill(&mut s).expect("the system's random source");
24
    s
25
}
26
27
/// The public key of `secret`, for the hub.
28
pub fn public_key(secret: &[u8; 32]) -> [u8; 32] {
29
    MontgomeryPoint::mul_base_clamped(*secret).to_bytes()
30
}
31
32
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.
34
    if shared.to_bytes() == [0u8; 32] {
35
        return None;
36
    }
37
    let mut salt = [0u8; 64];
38
    salt[..32].copy_from_slice(ephemeral);
39
    salt[32..].copy_from_slice(recipient);
40
    let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &salt).extract(shared.as_bytes());
41
    let okm = prk.expand(&[INFO], &CHACHA20_POLY1305).ok()?;
42
    let mut bytes = [0u8; 32];
43
    okm.fill(&mut bytes).ok()?;
44
    Some(LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, &bytes).ok()?))
45
}
46
47
/// Seal `message` to the phone whose public key is `recipient`; `None`
48
/// when that key is unusable.
49
pub fn seal(recipient: &[u8; 32], message: &[u8]) -> Option<Vec<u8>> {
50
    let e = new_secret();
51
    let ephemeral = public_key(&e);
52
    let shared = MontgomeryPoint(*recipient).mul_clamped(e);
53
    let key = key(&shared, &ephemeral, recipient)?;
54
    let mut sealed = message.to_vec();
55
    key.seal_in_place_append_tag(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut sealed).ok()?;
56
    let mut out = ephemeral.to_vec();
57
    out.extend(sealed);
58
    Some(out)
59
}
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.
63
pub fn open(secret: &[u8; 32], sealed: &[u8]) -> Option<Vec<u8>> {
64
    if sealed.len() < 32 + CHACHA20_POLY1305.tag_len() {
65
        return None;
66
    }
67
    let ephemeral: [u8; 32] = sealed[..32].try_into().ok()?;
68
    let shared = MontgomeryPoint(ephemeral).mul_clamped(*secret);
69
    let key = key(&shared, &ephemeral, &public_key(secret))?;
70
    let mut body = sealed[32..].to_vec();
71
    let plain = key.open_in_place(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut body).ok()?;
72
    Some(plain.to_vec())
73
}
74
75
#[cfg(test)]
76
mod tests {
77
    use super::*;
78
79
    #[test]
80
    fn a_sealed_alert_opens_only_with_its_phones_key() {
81
        let phone = new_secret();
82
        let sealed = seal(&public_key(&phone), b"run #5: test passed").unwrap();
83
        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.
85
        assert_ne!(seal(&public_key(&phone), b"run #5: test passed").unwrap(), sealed);
86
        assert!(open(&new_secret(), &sealed).is_none(), "another phone");
87
        let mut changed = sealed.clone();
88
        *changed.last_mut().unwrap() ^= 1;
89
        assert!(open(&phone, &changed).is_none(), "changed on the way");
90
        assert!(open(&phone, &sealed[..40]).is_none(), "cut short");
91
        // A low-order public key (all zeros) is refused, not sealed to.
92
        assert!(seal(&[0u8; 32], b"x").is_none());
93
    }
94
}