Skip to main content

okbot_peer/
seal.rs

1//! Sealing what passes through a relay (ADR 0014): the relay forwards bytes it cannot read.
2//!
3//! Each bot has an X25519 key (`state/peer.box`, its public half `x25519:<base64url>` in
4//! `state/peer.box.pub` and in the team manifest as `box_key`). A request through a relay is sealed
5//! for the target and the answer for the caller: a fresh X25519 key per message, HKDF-SHA256 over
6//! the shared secret (salt: both public keys, info: `okbot-relay-v1`), ChaCha20-Poly1305. The
7//! okp1 tokens still sign the plain bodies, so a relay can neither read nor alter them.
8
9use std::fmt;
10use std::path::Path;
11use std::str::FromStr;
12
13use base64::Engine as _;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
15use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey};
16use ring::hkdf::{HKDF_SHA256, Salt};
17use serde::{Deserialize, Serialize};
18use x25519_dalek::{PublicKey, StaticSecret};
19
20use crate::Error;
21
22/// The private key file in a bot's `state/`.
23pub const BOX_FILE: &str = "peer.box";
24/// Its public half, read by other bots of the machine.
25pub const BOX_PUBLIC_FILE: &str = "peer.box.pub";
26
27const PREFIX: &str = "x25519:";
28const INFO: &[u8] = b"okbot-relay-v1";
29
30/// A public X25519 key, written `x25519:<base64url>`.
31#[derive(Clone, Copy, PartialEq, Eq, Hash)]
32pub struct BoxPublic([u8; 32]);
33
34impl fmt::Display for BoxPublic {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "{PREFIX}{}", B64.encode(self.0))
37    }
38}
39
40impl fmt::Debug for BoxPublic {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        fmt::Display::fmt(self, f)
43    }
44}
45
46impl FromStr for BoxPublic {
47    type Err = Error;
48
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        let bad = || Error::Key(format!("`{s}` is not an `x25519:<base64url>` key"));
51        let bytes = B64
52            .decode(s.trim().strip_prefix(PREFIX).ok_or_else(bad)?)
53            .map_err(|_| bad())?;
54        Ok(Self(bytes.try_into().map_err(|_| bad())?))
55    }
56}
57
58impl Serialize for BoxPublic {
59    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
60        s.collect_str(self)
61    }
62}
63
64impl<'de> Deserialize<'de> for BoxPublic {
65    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
66        String::deserialize(d)?
67            .parse()
68            .map_err(serde::de::Error::custom)
69    }
70}
71
72/// A bot's private X25519 key.
73#[derive(Clone)]
74pub struct BoxKey(StaticSecret);
75
76impl fmt::Debug for BoxKey {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "BoxKey({})", self.public())
79    }
80}
81
82impl BoxKey {
83    /// A new random key.
84    pub fn generate() -> Self {
85        Self(StaticSecret::from(rand::random::<[u8; 32]>()))
86    }
87
88    /// Reads a bot's key from its `state/` directory (creating it on first use) and writes its
89    /// public half next to it.
90    pub fn for_bot(state_dir: &Path) -> Result<Self, Error> {
91        let path = state_dir.join(BOX_FILE);
92        let key = match std::fs::read(&path) {
93            Ok(bytes) => {
94                let bytes: [u8; 32] = bytes
95                    .try_into()
96                    .map_err(|_| Error::Key(format!("{} is not an X25519 key", path.display())))?;
97                Self(StaticSecret::from(bytes))
98            }
99            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
100                let key = Self::generate();
101                crate::key::write_new_private(&path, &key.0.to_bytes())?;
102                key
103            }
104            Err(e) => return Err(Error::Io(path.display().to_string(), e)),
105        };
106        let public = state_dir.join(BOX_PUBLIC_FILE);
107        let text = format!("{}\n", key.public());
108        if std::fs::read_to_string(&public).ok().as_deref() != Some(text.as_str()) {
109            std::fs::write(&public, text)
110                .map_err(|e| Error::Io(public.display().to_string(), e))?;
111        }
112        Ok(key)
113    }
114
115    /// The public half.
116    pub fn public(&self) -> BoxPublic {
117        BoxPublic(PublicKey::from(&self.0).to_bytes())
118    }
119}
120
121/// Reads the box key a bot directory publishes (`<dir>/state/peer.box.pub`).
122pub fn bot_box_key(bot_dir: &Path) -> Result<BoxPublic, Error> {
123    let path = bot_dir.join("state").join(BOX_PUBLIC_FILE);
124    std::fs::read_to_string(&path)
125        .map_err(|e| Error::Io(path.display().to_string(), e))?
126        .parse()
127}
128
129#[derive(Serialize, Deserialize)]
130struct Envelope {
131    v: u8,
132    epk: String,
133    nonce: String,
134    ct: String,
135}
136
137fn cipher(shared: &[u8], ephemeral: &[u8; 32], recipient: &[u8; 32]) -> LessSafeKey {
138    let mut salt = Vec::with_capacity(64);
139    salt.extend_from_slice(ephemeral);
140    salt.extend_from_slice(recipient);
141    let prk = Salt::new(HKDF_SHA256, &salt).extract(shared);
142    let okm = prk
143        .expand(&[INFO], &CHACHA20_POLY1305)
144        .expect("32 bytes is a valid HKDF-SHA256 output");
145    LessSafeKey::new(UnboundKey::from(okm))
146}
147
148/// Seals `plain` for the holder of `to`.
149pub fn seal(to: &BoxPublic, plain: &[u8]) -> Vec<u8> {
150    let ephemeral = StaticSecret::from(rand::random::<[u8; 32]>());
151    let epk = PublicKey::from(&ephemeral).to_bytes();
152    let shared = ephemeral.diffie_hellman(&PublicKey::from(to.0));
153    let key = cipher(shared.as_bytes(), &epk, &to.0);
154    let nonce: [u8; 12] = rand::random();
155    let mut data = plain.to_vec();
156    key.seal_in_place_append_tag(Nonce::assume_unique_for_key(nonce), Aad::empty(), &mut data)
157        .expect("ChaCha20-Poly1305 seals any length used here");
158    serde_json::to_vec(&Envelope {
159        v: 1,
160        epk: B64.encode(epk),
161        nonce: B64.encode(nonce),
162        ct: B64.encode(data),
163    })
164    .expect("envelopes serialize")
165}
166
167/// Opens an envelope sealed for `key`.
168pub fn open(key: &BoxKey, sealed: &[u8]) -> Result<Vec<u8>, Error> {
169    let bad = || Error::Token("a sealed message that cannot be opened".into());
170    let envelope: Envelope = serde_json::from_slice(sealed).map_err(|_| bad())?;
171    if envelope.v != 1 {
172        return Err(bad());
173    }
174    let epk: [u8; 32] = B64
175        .decode(&envelope.epk)
176        .ok()
177        .and_then(|b| b.try_into().ok())
178        .ok_or_else(bad)?;
179    let nonce: [u8; 12] = B64
180        .decode(&envelope.nonce)
181        .ok()
182        .and_then(|b| b.try_into().ok())
183        .ok_or_else(bad)?;
184    let mut data = B64.decode(&envelope.ct).map_err(|_| bad())?;
185    let shared = key.0.diffie_hellman(&PublicKey::from(epk));
186    let cipher = cipher(shared.as_bytes(), &epk, &key.public().0);
187    let plain = cipher
188        .open_in_place(Nonce::assume_unique_for_key(nonce), Aad::empty(), &mut data)
189        .map_err(|_| bad())?;
190    Ok(plain.to_vec())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn sealed_for_one_key_only() {
199        let alice = BoxKey::generate();
200        let mallory = BoxKey::generate();
201        let sealed = seal(&alice.public(), b"restart nginx");
202        assert!(!String::from_utf8_lossy(&sealed).contains("nginx"));
203        assert_eq!(open(&alice, &sealed).unwrap(), b"restart nginx");
204        assert!(open(&mallory, &sealed).is_err());
205        let mut tampered: serde_json::Value = serde_json::from_slice(&sealed).unwrap();
206        let ct = tampered["ct"].as_str().unwrap().to_string();
207        let flipped = if ct.starts_with('A') { "B" } else { "A" };
208        tampered["ct"] = format!("{flipped}{}", &ct[1..]).into();
209        assert!(open(&alice, &serde_json::to_vec(&tampered).unwrap()).is_err());
210        assert_ne!(
211            seal(&alice.public(), b"x"),
212            seal(&alice.public(), b"x"),
213            "fresh keys"
214        );
215    }
216
217    #[test]
218    fn keys_are_kept_and_published() {
219        let dir = tempfile::tempdir().unwrap();
220        let state = dir.path().join("state");
221        let first = BoxKey::for_bot(&state).unwrap();
222        assert_eq!(BoxKey::for_bot(&state).unwrap().public(), first.public());
223        assert_eq!(bot_box_key(dir.path()).unwrap(), first.public());
224        let text = first.public().to_string();
225        assert_eq!(text.parse::<BoxPublic>().unwrap(), first.public());
226        assert!("ed25519:abc".parse::<BoxPublic>().is_err());
227    }
228}