OXYGENHUB

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) 060d459ff6c6

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key bb37421d4028
When
· 2026-10-27 19:38 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
060d459ff6c6ed4b93b5cb59600f38b5663a8d44332f28b99c0f7918f1e8936c
Depends on
0d663e91c598 2d025bbf9842 c3db750ed96e e0918b887850
Depended on by
nothing yet
In bookmarks
main
In tags
v0.5.1 v0.5.2 v0.5.3
Edits
6 atoms, 1 tree operation, 5 files

Browse the tree at this change

added crates/flow-core/src/alert.rs +94 −0
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 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Sealed alerts (`flow_core::alert`, oxygenhub-android#16):** what a phone is told about a job is encrypted to an X25519 key the phone made (a fresh key per alert, HKDF-SHA256, ChaCha20-Poly1305), so the push service carrying it sees only ciphertext. `seal` for the hub, `open` and `new_secret`/`public_key` for the phone, from crates already in the tree (curve25519-dalek, ring).
8 +
7 9
- **`flow runner register --ca` keeps the CA in the Flow home (#98):** it wrote the path as given, so `--ca hub-ca.pem` left `ca = "hub-ca.pem"` in runner.toml and a service started elsewhere could not find it. Register now copies the file to the home's `hub-ca.pem` and records that copy's absolute path, and a relative `ca` already in runner.toml is read against the home.
8 10
9 11
- **The cheap checks run as WASM (#96):** `layout`, `todos`, `size` and `summary` are one WASI module, `crates/flow-checks`, built by `scripts/checks-wasm.sh` and committed as `.flow/checks.wasm`, so they need no image or microVM and any runner with the WASM tier takes them (the phone, plex, windows-1); each runs in under 100 ms. The fmt job fails unless the committed module is what the source builds, and the build image has the wasm32-wasip1 target for that.
…
120 unchanged lines
modified Cargo.lock +2 −0
…
864 unchanged lines
865 865
version = "0.5.0"
866 866
dependencies = [
867 867
 "blake3",
868 +
 "curve25519-dalek",
868 869
 "globset",
869 870
 "hex",
870 871
 "kdl",
871 872
 "miette",
872 873
 "oxy-core",
874 +
 "ring",
873 875
 "serde",
874 876
 "serde_json",
875 877
 "thiserror 1.0.69",
…
2380 unchanged lines
modified crates/flow-core/Cargo.toml +3 −0
…
14 unchanged lines
15 15
kdl = "6"
16 16
miette = "7"
17 17
globset = "0.4"
18 +
# Sealed alerts (alert.rs): both already in the tree through oxy-core.
19 +
curve25519-dalek = "5"
20 +
ring = "0.17"
modified crates/flow-core/src/lib.rs +1 −0
…
3 unchanged lines
4 4
/// This library's version, for hosts that report what they are built on.
5 5
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
6 6
7 +
pub mod alert;
7 8
pub mod api;
8 9
pub mod canonical;
9 10
pub mod cron;
…
12 unchanged lines