1use 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
22pub const BOX_FILE: &str = "peer.box";
24pub const BOX_PUBLIC_FILE: &str = "peer.box.pub";
26
27const PREFIX: &str = "x25519:";
28const INFO: &[u8] = b"okbot-relay-v1";
29
30#[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#[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 pub fn generate() -> Self {
85 Self(StaticSecret::from(rand::random::<[u8; 32]>()))
86 }
87
88 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 pub fn public(&self) -> BoxPublic {
117 BoxPublic(PublicKey::from(&self.0).to_bytes())
118 }
119}
120
121pub 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
148pub 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
167pub 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}