|
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 |
+ |
} |