Skip to main content

koan_core/remote/
proof.rs

1//! Devices proving on the local network that they are the account's own,
2//! without the server in the room.
3//!
4//! Each device signed in with an API key holds an Ed25519 keypair, kept with
5//! its credentials in `config.local.toml` and made afresh at each sign-in. Its
6//! public key goes up the link (`deviceKey`), and the server sends every
7//! device the account's keys, and those of devices shared with it
8//! (`LinkCommand::DeviceKeys`), which are kept here so that the check works
9//! with the server out of reach.
10//!
11//! A nearby connection proves both ends: the listening end's `Hello` carries
12//! a nonce, the dialling end answers with its own and a signature over both,
13//! and the listening end signs back. Both signatures name both devices, both
14//! nonces and which end made them, so a recorded handshake fails against a
15//! fresh nonce and one end's signature cannot be passed off as the other's.
16//! Nearby connections are not encrypted, so every command after a proven
17//! handshake is signed too, over the handshake and a sequence number: one
18//! slipped into the stream, replayed, or taken from another session is
19//! refused. So are the listening end's reports back (acks, state, levels),
20//! when both ends sign them: each says so by the mark on its nonce, which
21//! both signatures cover, so the mark cannot be stripped to have reports
22//! taken unsigned without the handshake failing.
23//!
24//! A peer that proves nothing, because it is older, signed out, or on another
25//! server, is classed as it always was.
26
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use base64::Engine as _;
30use parking_lot::Mutex;
31use ring::rand::SecureRandom as _;
32use ring::signature::{ED25519, Ed25519KeyPair, KeyPair as _, UnparsedPublicKey};
33use serde::{Deserialize, Serialize};
34
35use crate::config::Config;
36use crate::remote::link::LinkDeviceKey;
37
38const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
39
40/// A kept key list older than this is not trusted: it may hold a device
41/// revoked while this one was away from the server.
42const KEYS_TRUSTED_FOR: i64 = 30 * 24 * 60 * 60;
43
44const DIAL: &str = "koan-nearby-v1 dial";
45const LISTEN: &str = "koan-nearby-v1 listen";
46const SESSION: &str = "koan-nearby-v1 session";
47const COMMAND: &str = "koan-nearby-v1 command";
48const REPORT: &str = "koan-nearby-v1 report";
49
50/// Begins the nonce of a device that signs its reports and checks the other
51/// end's. A device that predates it reads the nonce as any other.
52const SIGNS_REPORTS: &str = "reports.";
53
54/// A fresh keypair, as kept in `remote.device_key`: base64 of its PKCS#8.
55pub fn new_device_key() -> Option<String> {
56    let rng = ring::rand::SystemRandom::new();
57    let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).ok()?;
58    Some(B64.encode(pkcs8.as_ref()))
59}
60
61fn keypair_from(kept: &str) -> Option<Ed25519KeyPair> {
62    let pkcs8 = B64.decode(kept).ok()?;
63    Ed25519KeyPair::from_pkcs8(&pkcs8).ok()
64}
65
66/// This device's keypair, while it is signed in with an API key. One signed
67/// in before keys existed is given one here, kept as a sign-in would.
68fn keypair() -> Option<Ed25519KeyPair> {
69    let cfg = Config::cached();
70    if !cfg.remote.enabled || cfg.remote.api_key.is_empty() {
71        return None;
72    }
73    if let Some(pair) = keypair_from(&cfg.remote.device_key) {
74        return Some(pair);
75    }
76    let made = new_device_key()?;
77    if let Err(e) = Config::persist(|c| c.remote.device_key = made.clone()) {
78        log::warn!("proof: could not keep a device key: {e}");
79        return None;
80    }
81    keypair_from(&made)
82}
83
84/// The public key the link registers, base64.
85pub fn public_key() -> Option<String> {
86    keypair().map(|pair| B64.encode(pair.public_key().as_ref()))
87}
88
89/// A fresh nonce: base64 of 32 random bytes, marked as this device's
90/// (`SIGNS_REPORTS`). `None` if the system cannot give random bytes: a nonce
91/// that is not fresh would let a recorded handshake, and the commands signed
92/// after it, be replayed, so without one nothing is proven.
93pub fn nonce() -> Option<String> {
94    let mut bytes = [0u8; 32];
95    ring::rand::SystemRandom::new().fill(&mut bytes).ok()?;
96    Some(format!("{SIGNS_REPORTS}{}", B64.encode(bytes)))
97}
98
99/// Whether both ends of the handshake over these nonces sign reports.
100pub fn signs_reports(listen_nonce: &str, dial_nonce: &str) -> bool {
101    listen_nonce.starts_with(SIGNS_REPORTS) && dial_nonce.starts_with(SIGNS_REPORTS)
102}
103
104/// `label` and each field, each preceded by its length, so no two lists of
105/// fields read the same.
106fn message(label: &str, fields: &[&[u8]]) -> Vec<u8> {
107    let mut out = Vec::new();
108    for field in std::iter::once(label.as_bytes()).chain(fields.iter().copied()) {
109        out.extend_from_slice(&(field.len() as u32).to_be_bytes());
110        out.extend_from_slice(field);
111    }
112    out
113}
114
115/// What the dialling end signs: it, the device it dialled, and both nonces.
116fn dial_message(listener: &str, dialer: &str, listen_nonce: &str, dial_nonce: &str) -> Vec<u8> {
117    message(
118        DIAL,
119        &[
120            listener.as_bytes(),
121            dialer.as_bytes(),
122            listen_nonce.as_bytes(),
123            dial_nonce.as_bytes(),
124        ],
125    )
126}
127
128/// What the listening end signs: the same, from its side, and whether it
129/// took the dialler for the account's or a shared device.
130fn listen_message(
131    dialer: &str,
132    listener: &str,
133    dial_nonce: &str,
134    listen_nonce: &str,
135    verified: bool,
136) -> Vec<u8> {
137    message(
138        LISTEN,
139        &[
140            dialer.as_bytes(),
141            listener.as_bytes(),
142            dial_nonce.as_bytes(),
143            listen_nonce.as_bytes(),
144            &[verified as u8],
145        ],
146    )
147}
148
149fn sign(pair: &Ed25519KeyPair, msg: &[u8]) -> String {
150    B64.encode(pair.sign(msg).as_ref())
151}
152
153/// The dialling end's signature, or `None` with no keypair.
154pub fn sign_dial(
155    listener: &str,
156    dialer: &str,
157    listen_nonce: &str,
158    dial_nonce: &str,
159) -> Option<String> {
160    let pair = keypair()?;
161    Some(sign(
162        &pair,
163        &dial_message(listener, dialer, listen_nonce, dial_nonce),
164    ))
165}
166
167/// The listening end's signature, or `None` with no keypair.
168pub fn sign_listen(
169    dialer: &str,
170    listener: &str,
171    dial_nonce: &str,
172    listen_nonce: &str,
173    verified: bool,
174) -> Option<String> {
175    let pair = keypair()?;
176    Some(sign(
177        &pair,
178        &listen_message(dialer, listener, dial_nonce, listen_nonce, verified),
179    ))
180}
181
182/// Who a peer proved to be.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum Peer {
185    /// One of this account's devices.
186    Own,
187    /// A device another account shares with this one.
188    Shared(String),
189}
190
191/// A peer that proved itself: who, and the key it proved with, which its
192/// signed commands are checked against.
193#[derive(Debug, Clone)]
194pub struct Proven {
195    pub peer: Peer,
196    key: Vec<u8>,
197}
198
199/// The peer claiming `id` that signed `msg` with `sig`, by the keys `keys`
200/// lists for it. A device id is the client's own choice, so another account
201/// can list one of this account's ids as its own: every key for the id is
202/// tried, and one with an owner proves a shared device, never this account's.
203fn verify_with(keys: &[LinkDeviceKey], id: &str, msg: &[u8], sig: &str) -> Option<Proven> {
204    let sig = B64.decode(sig).ok()?;
205    keys.iter().filter(|k| k.id == id).find_map(|k| {
206        let key = B64.decode(&k.key).ok()?;
207        UnparsedPublicKey::new(&ED25519, &key)
208            .verify(msg, &sig)
209            .ok()?;
210        Some(Proven {
211            peer: match &k.owner {
212                None => Peer::Own,
213                Some(owner) => Peer::Shared(owner.clone()),
214            },
215            key,
216        })
217    })
218}
219
220/// Whether the dialler `dialer` proved itself to this listener.
221pub fn verify_dial(
222    listener: &str,
223    dialer: &str,
224    listen_nonce: &str,
225    dial_nonce: &str,
226    sig: &str,
227) -> Option<Proven> {
228    verify_with(
229        &kept(),
230        dialer,
231        &dial_message(listener, dialer, listen_nonce, dial_nonce),
232        sig,
233    )
234}
235
236/// Whether the listener `listener` proved itself to this dialler.
237pub fn verify_listen(
238    dialer: &str,
239    listener: &str,
240    dial_nonce: &str,
241    listen_nonce: &str,
242    verified: bool,
243    sig: &str,
244) -> Option<Proven> {
245    verify_with(
246        &kept(),
247        listener,
248        &listen_message(dialer, listener, dial_nonce, listen_nonce, verified),
249        sig,
250    )
251}
252
253/// The signed half of a proven connection, one way: what binds each message
254/// (a command, or a report) to this connection and puts it in order.
255pub struct Session {
256    transcript: [u8; 32],
257    /// What the messages are, so one kind is never taken for the other.
258    label: &'static str,
259    /// The sender's: the last sequence number signed. The receiver's: the
260    /// last accepted.
261    seq: u64,
262}
263
264impl Session {
265    /// The session for the dialler's commands, as both ends compute it from
266    /// the handshake.
267    pub fn new(listener: &str, dialer: &str, listen_nonce: &str, dial_nonce: &str) -> Self {
268        Self::of(COMMAND, listener, dialer, listen_nonce, dial_nonce)
269    }
270
271    /// The session for the listener's reports.
272    pub fn reports(listener: &str, dialer: &str, listen_nonce: &str, dial_nonce: &str) -> Self {
273        Self::of(REPORT, listener, dialer, listen_nonce, dial_nonce)
274    }
275
276    fn of(
277        label: &'static str,
278        listener: &str,
279        dialer: &str,
280        listen_nonce: &str,
281        dial_nonce: &str,
282    ) -> Self {
283        let digest = ring::digest::digest(
284            &ring::digest::SHA256,
285            &message(
286                SESSION,
287                &[
288                    listener.as_bytes(),
289                    dialer.as_bytes(),
290                    listen_nonce.as_bytes(),
291                    dial_nonce.as_bytes(),
292                ],
293            ),
294        );
295        let mut transcript = [0u8; 32];
296        transcript.copy_from_slice(digest.as_ref());
297        Self {
298            transcript,
299            label,
300            seq: 0,
301        }
302    }
303
304    fn signed_message(&self, seq: u64, text: &str) -> Vec<u8> {
305        message(
306            self.label,
307            &[&self.transcript, &seq.to_be_bytes(), text.as_bytes()],
308        )
309    }
310
311    /// Sign `text` (a command's or report's JSON) as the next in this
312    /// session. `None` with no keypair.
313    pub fn sign(&mut self, text: &str) -> Option<(u64, String)> {
314        let pair = keypair()?;
315        self.sign_with(&pair, text)
316    }
317
318    fn sign_with(&mut self, pair: &Ed25519KeyPair, text: &str) -> Option<(u64, String)> {
319        self.seq += 1;
320        Some((self.seq, sign(pair, &self.signed_message(self.seq, text))))
321    }
322
323    /// Whether `text` was signed by `by` for this session, later in it than
324    /// anything accepted so far. Accepting it moves the session on.
325    pub fn accept(&mut self, by: &Proven, seq: u64, sig: &str, text: &str) -> bool {
326        if seq <= self.seq {
327            return false;
328        }
329        let Ok(sig) = B64.decode(sig) else {
330            return false;
331        };
332        let ok = UnparsedPublicKey::new(&ED25519, &by.key)
333            .verify(&self.signed_message(seq, text), &sig)
334            .is_ok();
335        if ok {
336            self.seq = seq;
337        }
338        ok
339    }
340}
341
342// --- The kept key list ------------------------------------------------------
343
344/// The account's device keys as the server last sent them, for the account
345/// they were sent to, and when.
346#[derive(Debug, Clone, Default, Serialize, Deserialize)]
347struct Kept {
348    /// The server and account they are for (`account_of`): keys from one
349    /// prove nothing for another, a second account signed in to the same
350    /// server on this device included.
351    account: Option<String>,
352    at: i64,
353    keys: Vec<LinkDeviceKey>,
354}
355
356/// Which account on which server `cfg` signs in to, as kept keys are tagged
357/// with: the server's `library_fingerprint` and the username, compared as the
358/// server compares it, exactly.
359pub fn account_of(cfg: &Config) -> Option<String> {
360    let server = crate::remote::link::library_fingerprint(cfg)?;
361    Some(format!("{server}/{}", cfg.remote.username))
362}
363
364static KEPT: Mutex<Option<Kept>> = Mutex::new(None);
365
366fn kept_path() -> std::path::PathBuf {
367    crate::config::config_dir().join("device-keys.json")
368}
369
370fn now() -> i64 {
371    SystemTime::now()
372        .duration_since(UNIX_EPOCH)
373        .map(|d| d.as_secs() as i64)
374        .unwrap_or_default()
375}
376
377/// Keep the key list the server just sent to the link signed in as
378/// `account`, replacing the last whole: a key revoked is a key no longer
379/// listed. Tagged with the link's account, not whoever is signed in now, so a
380/// list that reaches a link still open for the last account is never taken
381/// for the next one's.
382pub fn keep(keys: Vec<LinkDeviceKey>, account: Option<String>) {
383    let kept = Kept {
384        account,
385        at: now(),
386        keys,
387    };
388    if let Ok(json) = serde_json::to_string(&kept) {
389        let _ = std::fs::write(kept_path(), json);
390    }
391    *KEPT.lock() = Some(kept);
392}
393
394/// The keys to check a peer against: the last list kept for the server this
395/// device is signed in to, while it is recent enough to trust.
396fn kept() -> Vec<LinkDeviceKey> {
397    let mut held = KEPT.lock();
398    if held.is_none() {
399        *held = std::fs::read_to_string(kept_path())
400            .ok()
401            .and_then(|text| serde_json::from_str(&text).ok());
402    }
403    let Some(kept) = held.as_ref() else {
404        return Vec::new();
405    };
406    let account = account_of(&Config::cached());
407    trusted(kept, account.as_deref(), now())
408}
409
410fn trusted(kept: &Kept, account: Option<&str>, now: i64) -> Vec<LinkDeviceKey> {
411    if account.is_none() || kept.account.as_deref() != account || now - kept.at > KEYS_TRUSTED_FOR {
412        return Vec::new();
413    }
414    kept.keys.clone()
415}
416
417/// Forget the kept keys: at sign-in and sign-out, so another account signed
418/// in here never checks peers against the last one's devices.
419pub fn forget() {
420    *KEPT.lock() = Some(Kept::default());
421    let _ = std::fs::remove_file(kept_path());
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn pair() -> (Ed25519KeyPair, String) {
429        let made = new_device_key().unwrap();
430        let pair = keypair_from(&made).unwrap();
431        let public = B64.encode(pair.public_key().as_ref());
432        (pair, public)
433    }
434
435    fn key(id: &str, public: &str, owner: Option<&str>) -> LinkDeviceKey {
436        LinkDeviceKey {
437            id: id.into(),
438            key: public.into(),
439            owner: owner.map(Into::into),
440        }
441    }
442
443    #[test]
444    fn a_dialler_proves_itself_with_its_own_key_only() {
445        let (phone, phone_pub) = pair();
446        let (thief, thief_pub) = pair();
447        let keys = vec![key("phone", &phone_pub, None)];
448        let msg = dial_message("mac", "phone", "nl", "nd");
449        let proven = verify_with(&keys, "phone", &msg, &sign(&phone, &msg)).unwrap();
450        assert_eq!(proven.peer, Peer::Own);
451        // Another key, an id not listed, a damaged signature.
452        assert!(verify_with(&keys, "phone", &msg, &sign(&thief, &msg)).is_none());
453        assert!(
454            verify_with(
455                &[key("x", &thief_pub, None)],
456                "phone",
457                &msg,
458                &sign(&thief, &msg)
459            )
460            .is_none()
461        );
462        assert!(verify_with(&keys, "phone", &msg, "AAAA").is_none());
463        assert!(verify_with(&keys, "phone", &msg, "not base64!").is_none());
464    }
465
466    #[test]
467    fn a_recorded_handshake_fails_against_a_fresh_nonce() {
468        let (phone, phone_pub) = pair();
469        let keys = vec![key("phone", &phone_pub, None)];
470        let recorded = sign(&phone, &dial_message("mac", "phone", "old", "nd"));
471        let fresh = dial_message("mac", "phone", "new", "nd");
472        assert!(verify_with(&keys, "phone", &fresh, &recorded).is_none());
473        // Nor is it good for another listener.
474        let elsewhere = dial_message("tv", "phone", "old", "nd");
475        assert!(verify_with(&keys, "phone", &elsewhere, &recorded).is_none());
476    }
477
478    #[test]
479    fn one_ends_signature_cannot_pass_as_the_others() {
480        let (mac, mac_pub) = pair();
481        let keys = vec![key("mac", &mac_pub, None)];
482        // The Mac, listening, signed this for a phone dialling it; replayed
483        // to the phone listening, with the Mac's id as the dialler, it fails.
484        let listened = sign(&mac, &listen_message("phone", "mac", "nd", "nl", true));
485        let as_dial = dial_message("phone", "mac", "nd", "nl");
486        assert!(verify_with(&keys, "mac", &as_dial, &listened).is_none());
487    }
488
489    /// Another account can list one of this account's device ids as its own
490    /// and share it. Its key proves a shared device, never this account's.
491    #[test]
492    fn a_key_with_an_owner_never_proves_an_own_device() {
493        let (own, own_pub) = pair();
494        let (theirs, theirs_pub) = pair();
495        let keys = vec![
496            key("phone", &own_pub, None),
497            key("phone", &theirs_pub, Some("kim")),
498        ];
499        let msg = dial_message("mac", "phone", "nl", "nd");
500        assert_eq!(
501            verify_with(&keys, "phone", &msg, &sign(&theirs, &msg))
502                .unwrap()
503                .peer,
504            Peer::Shared("kim".into())
505        );
506        assert_eq!(
507            verify_with(&keys, "phone", &msg, &sign(&own, &msg))
508                .unwrap()
509                .peer,
510            Peer::Own
511        );
512    }
513
514    /// jo signs out and kim signs in, and a key list reaches the link still
515    /// open as jo: kept as jo's, it proves nothing for kim.
516    #[test]
517    fn keys_from_the_last_accounts_link_prove_nothing_for_the_next() {
518        let _guard = crate::config::tests::PERSIST_LOCK
519            .lock()
520            .unwrap_or_else(|e| e.into_inner());
521        let dir = tempfile::tempdir().unwrap();
522        crate::config::set_config_dir(dir.path());
523        let sign_in = |user: &str| {
524            Config::persist(|c| {
525                c.remote.enabled = true;
526                c.remote.url = "http://koan.test".into();
527                c.remote.username = user.into();
528                c.remote.api_key = "k".into();
529            })
530            .unwrap();
531        };
532        sign_in("jo");
533        let jo = account_of(&Config::cached());
534        sign_in("kim");
535        keep(vec![key("phone", "K", None)], jo);
536        assert!(kept().is_empty(), "jo's link, kim signed in");
537        keep(vec![key("phone", "K", None)], account_of(&Config::cached()));
538        assert_eq!(kept().len(), 1, "kim's own link");
539        forget();
540        assert!(kept().is_empty());
541    }
542
543    #[test]
544    fn signed_commands_are_bound_to_their_session_and_order() {
545        let (phone, phone_pub) = pair();
546        let keys = vec![key("phone", &phone_pub, None)];
547        let msg = dial_message("mac", "phone", "nl", "nd");
548        let proven = verify_with(&keys, "phone", &msg, &sign(&phone, &msg)).unwrap();
549
550        let mut sending = Session::new("mac", "phone", "nl", "nd");
551        let mut receiving = Session::new("mac", "phone", "nl", "nd");
552        let (s1, sig1) = sending.sign_with(&phone, r#"{"type":"pause"}"#).unwrap();
553        let (s2, sig2) = sending.sign_with(&phone, r#"{"type":"next"}"#).unwrap();
554
555        assert!(
556            !receiving.accept(&proven, s1, &sig1, r#"{"type":"sync"}"#),
557            "altered"
558        );
559        assert!(receiving.accept(&proven, s1, &sig1, r#"{"type":"pause"}"#));
560        assert!(
561            !receiving.accept(&proven, s1, &sig1, r#"{"type":"pause"}"#),
562            "replayed"
563        );
564        assert!(receiving.accept(&proven, s2, &sig2, r#"{"type":"next"}"#));
565        assert!(
566            !receiving.accept(&proven, s1, &sig1, r#"{"type":"pause"}"#),
567            "out of order"
568        );
569
570        // Signed for another session: refused.
571        let mut other = Session::new("mac", "phone", "nl2", "nd");
572        let (s, sig) = other.sign_with(&phone, r#"{"type":"pause"}"#).unwrap();
573        let mut fresh = Session::new("mac", "phone", "nl", "nd");
574        assert!(!fresh.accept(&proven, s, &sig, r#"{"type":"pause"}"#));
575    }
576
577    #[test]
578    fn a_signed_command_is_never_taken_for_a_report() {
579        let (mac, mac_pub) = pair();
580        let keys = vec![key("mac", &mac_pub, None)];
581        let msg = listen_message("phone", "mac", "nd", "nl", true);
582        let proven = verify_with(&keys, "mac", &msg, &sign(&mac, &msg)).unwrap();
583        let text = r#"{"type":"state"}"#;
584        let (seq, sig) = Session::new("mac", "phone", "nl", "nd")
585            .sign_with(&mac, text)
586            .unwrap();
587        assert!(!Session::reports("mac", "phone", "nl", "nd").accept(&proven, seq, &sig, text));
588        let (seq, sig) = Session::reports("mac", "phone", "nl", "nd")
589            .sign_with(&mac, text)
590            .unwrap();
591        assert!(Session::reports("mac", "phone", "nl", "nd").accept(&proven, seq, &sig, text));
592    }
593
594    #[test]
595    fn reports_are_signed_only_when_both_nonces_say_so() {
596        let (a, b) = (nonce().unwrap(), nonce().unwrap());
597        assert!(signs_reports(&a, &b));
598        // An older device's nonce is bare base64.
599        let older = B64.encode([7u8; 32]);
600        assert!(!signs_reports(&a, &older));
601        assert!(!signs_reports(&older, &b));
602    }
603
604    #[test]
605    fn kept_keys_hold_for_their_account_and_a_month() {
606        let cfg = |url: &str, user: &str| {
607            let mut c = Config::default();
608            c.remote.enabled = true;
609            c.remote.url = url.into();
610            c.remote.username = user.into();
611            c.remote.api_key = "k".into();
612            account_of(&c)
613        };
614        let jo = cfg("http://koan.test", "jo");
615        let kept = Kept {
616            account: jo.clone(),
617            at: 1_000_000,
618            keys: vec![key("phone", "K", None)],
619        };
620        assert_eq!(trusted(&kept, jo.as_deref(), 1_000_000 + 60).len(), 1);
621        assert_eq!(
622            trusted(&kept, cfg("http://KOAN.test/", "jo").as_deref(), 1_000_000).len(),
623            1,
624            "the same server, spelt differently"
625        );
626        assert!(
627            trusted(&kept, cfg("http://koan.test", "Jo").as_deref(), 1_000_000).is_empty(),
628            "usernames are matched exactly, as the server matches them"
629        );
630        let kim = cfg("http://koan.test", "kim");
631        assert!(
632            trusted(&kept, kim.as_deref(), 1_000_000).is_empty(),
633            "another account"
634        );
635        let elsewhere = cfg("http://other.test", "jo");
636        assert!(
637            trusted(&kept, elsewhere.as_deref(), 1_000_000).is_empty(),
638            "another server"
639        );
640        assert!(trusted(&kept, None, 1_000_000).is_empty(), "signed out");
641        assert!(
642            trusted(&kept, jo.as_deref(), 1_000_000 + KEYS_TRUSTED_FOR + 1).is_empty(),
643            "too old"
644        );
645    }
646}