use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use parking_lot::Mutex;
use ring::rand::SecureRandom as _;
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair as _, UnparsedPublicKey};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::remote::link::LinkDeviceKey;
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
const KEYS_TRUSTED_FOR: i64 = 30 * 24 * 60 * 60;
const DIAL: &str = "koan-nearby-v1 dial";
const LISTEN: &str = "koan-nearby-v1 listen";
const SESSION: &str = "koan-nearby-v1 session";
const COMMAND: &str = "koan-nearby-v1 command";
const REPORT: &str = "koan-nearby-v1 report";
const SIGNS_REPORTS: &str = "reports.";
pub fn new_device_key() -> Option<String> {
let rng = ring::rand::SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).ok()?;
Some(B64.encode(pkcs8.as_ref()))
}
fn keypair_from(kept: &str) -> Option<Ed25519KeyPair> {
let pkcs8 = B64.decode(kept).ok()?;
Ed25519KeyPair::from_pkcs8(&pkcs8).ok()
}
fn keypair() -> Option<Ed25519KeyPair> {
let cfg = Config::cached();
if !cfg.remote.enabled || cfg.remote.api_key.is_empty() {
return None;
}
if let Some(pair) = keypair_from(&cfg.remote.device_key) {
return Some(pair);
}
let made = new_device_key()?;
if let Err(e) = Config::persist(|c| c.remote.device_key = made.clone()) {
log::warn!("proof: could not keep a device key: {e}");
return None;
}
keypair_from(&made)
}
pub fn public_key() -> Option<String> {
keypair().map(|pair| B64.encode(pair.public_key().as_ref()))
}
pub fn nonce() -> Option<String> {
let mut bytes = [0u8; 32];
ring::rand::SystemRandom::new().fill(&mut bytes).ok()?;
Some(format!("{SIGNS_REPORTS}{}", B64.encode(bytes)))
}
pub fn signs_reports(listen_nonce: &str, dial_nonce: &str) -> bool {
listen_nonce.starts_with(SIGNS_REPORTS) && dial_nonce.starts_with(SIGNS_REPORTS)
}
fn message(label: &str, fields: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
for field in std::iter::once(label.as_bytes()).chain(fields.iter().copied()) {
out.extend_from_slice(&(field.len() as u32).to_be_bytes());
out.extend_from_slice(field);
}
out
}
fn dial_message(listener: &str, dialer: &str, listen_nonce: &str, dial_nonce: &str) -> Vec<u8> {
message(
DIAL,
&[
listener.as_bytes(),
dialer.as_bytes(),
listen_nonce.as_bytes(),
dial_nonce.as_bytes(),
],
)
}
fn listen_message(
dialer: &str,
listener: &str,
dial_nonce: &str,
listen_nonce: &str,
verified: bool,
) -> Vec<u8> {
message(
LISTEN,
&[
dialer.as_bytes(),
listener.as_bytes(),
dial_nonce.as_bytes(),
listen_nonce.as_bytes(),
&[verified as u8],
],
)
}
fn sign(pair: &Ed25519KeyPair, msg: &[u8]) -> String {
B64.encode(pair.sign(msg).as_ref())
}
pub fn sign_dial(
listener: &str,
dialer: &str,
listen_nonce: &str,
dial_nonce: &str,
) -> Option<String> {
let pair = keypair()?;
Some(sign(
&pair,
&dial_message(listener, dialer, listen_nonce, dial_nonce),
))
}
pub fn sign_listen(
dialer: &str,
listener: &str,
dial_nonce: &str,
listen_nonce: &str,
verified: bool,
) -> Option<String> {
let pair = keypair()?;
Some(sign(
&pair,
&listen_message(dialer, listener, dial_nonce, listen_nonce, verified),
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Peer {
Own,
Shared(String),
}
#[derive(Debug, Clone)]
pub struct Proven {
pub peer: Peer,
key: Vec<u8>,
}
fn verify_with(keys: &[LinkDeviceKey], id: &str, msg: &[u8], sig: &str) -> Option<Proven> {
let sig = B64.decode(sig).ok()?;
keys.iter().filter(|k| k.id == id).find_map(|k| {
let key = B64.decode(&k.key).ok()?;
UnparsedPublicKey::new(&ED25519, &key)
.verify(msg, &sig)
.ok()?;
Some(Proven {
peer: match &k.owner {
None => Peer::Own,
Some(owner) => Peer::Shared(owner.clone()),
},
key,
})
})
}
pub fn verify_dial(
listener: &str,
dialer: &str,
listen_nonce: &str,
dial_nonce: &str,
sig: &str,
) -> Option<Proven> {
verify_with(
&kept(),
dialer,
&dial_message(listener, dialer, listen_nonce, dial_nonce),
sig,
)
}
pub fn verify_listen(
dialer: &str,
listener: &str,
dial_nonce: &str,
listen_nonce: &str,
verified: bool,
sig: &str,
) -> Option<Proven> {
verify_with(
&kept(),
listener,
&listen_message(dialer, listener, dial_nonce, listen_nonce, verified),
sig,
)
}
pub struct Session {
transcript: [u8; 32],
label: &'static str,
seq: u64,
}
impl Session {
pub fn new(listener: &str, dialer: &str, listen_nonce: &str, dial_nonce: &str) -> Self {
Self::of(COMMAND, listener, dialer, listen_nonce, dial_nonce)
}
pub fn reports(listener: &str, dialer: &str, listen_nonce: &str, dial_nonce: &str) -> Self {
Self::of(REPORT, listener, dialer, listen_nonce, dial_nonce)
}
fn of(
label: &'static str,
listener: &str,
dialer: &str,
listen_nonce: &str,
dial_nonce: &str,
) -> Self {
let digest = ring::digest::digest(
&ring::digest::SHA256,
&message(
SESSION,
&[
listener.as_bytes(),
dialer.as_bytes(),
listen_nonce.as_bytes(),
dial_nonce.as_bytes(),
],
),
);
let mut transcript = [0u8; 32];
transcript.copy_from_slice(digest.as_ref());
Self {
transcript,
label,
seq: 0,
}
}
fn signed_message(&self, seq: u64, text: &str) -> Vec<u8> {
message(
self.label,
&[&self.transcript, &seq.to_be_bytes(), text.as_bytes()],
)
}
pub fn sign(&mut self, text: &str) -> Option<(u64, String)> {
let pair = keypair()?;
self.sign_with(&pair, text)
}
fn sign_with(&mut self, pair: &Ed25519KeyPair, text: &str) -> Option<(u64, String)> {
self.seq += 1;
Some((self.seq, sign(pair, &self.signed_message(self.seq, text))))
}
pub fn accept(&mut self, by: &Proven, seq: u64, sig: &str, text: &str) -> bool {
if seq <= self.seq {
return false;
}
let Ok(sig) = B64.decode(sig) else {
return false;
};
let ok = UnparsedPublicKey::new(&ED25519, &by.key)
.verify(&self.signed_message(seq, text), &sig)
.is_ok();
if ok {
self.seq = seq;
}
ok
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Kept {
account: Option<String>,
at: i64,
keys: Vec<LinkDeviceKey>,
}
pub fn account_of(cfg: &Config) -> Option<String> {
let server = crate::remote::link::library_fingerprint(cfg)?;
Some(format!("{server}/{}", cfg.remote.username))
}
static KEPT: Mutex<Option<Kept>> = Mutex::new(None);
fn kept_path() -> std::path::PathBuf {
crate::config::config_dir().join("device-keys.json")
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}
pub fn keep(keys: Vec<LinkDeviceKey>, account: Option<String>) {
let kept = Kept {
account,
at: now(),
keys,
};
if let Ok(json) = serde_json::to_string(&kept) {
let _ = std::fs::write(kept_path(), json);
}
*KEPT.lock() = Some(kept);
}
fn kept() -> Vec<LinkDeviceKey> {
let mut held = KEPT.lock();
if held.is_none() {
*held = std::fs::read_to_string(kept_path())
.ok()
.and_then(|text| serde_json::from_str(&text).ok());
}
let Some(kept) = held.as_ref() else {
return Vec::new();
};
let account = account_of(&Config::cached());
trusted(kept, account.as_deref(), now())
}
fn trusted(kept: &Kept, account: Option<&str>, now: i64) -> Vec<LinkDeviceKey> {
if account.is_none() || kept.account.as_deref() != account || now - kept.at > KEYS_TRUSTED_FOR {
return Vec::new();
}
kept.keys.clone()
}
pub fn forget() {
*KEPT.lock() = Some(Kept::default());
let _ = std::fs::remove_file(kept_path());
}
#[cfg(test)]
mod tests {
use super::*;
fn pair() -> (Ed25519KeyPair, String) {
let made = new_device_key().unwrap();
let pair = keypair_from(&made).unwrap();
let public = B64.encode(pair.public_key().as_ref());
(pair, public)
}
fn key(id: &str, public: &str, owner: Option<&str>) -> LinkDeviceKey {
LinkDeviceKey {
id: id.into(),
key: public.into(),
owner: owner.map(Into::into),
}
}
#[test]
fn a_dialler_proves_itself_with_its_own_key_only() {
let (phone, phone_pub) = pair();
let (thief, thief_pub) = pair();
let keys = vec![key("phone", &phone_pub, None)];
let msg = dial_message("mac", "phone", "nl", "nd");
let proven = verify_with(&keys, "phone", &msg, &sign(&phone, &msg)).unwrap();
assert_eq!(proven.peer, Peer::Own);
assert!(verify_with(&keys, "phone", &msg, &sign(&thief, &msg)).is_none());
assert!(
verify_with(
&[key("x", &thief_pub, None)],
"phone",
&msg,
&sign(&thief, &msg)
)
.is_none()
);
assert!(verify_with(&keys, "phone", &msg, "AAAA").is_none());
assert!(verify_with(&keys, "phone", &msg, "not base64!").is_none());
}
#[test]
fn a_recorded_handshake_fails_against_a_fresh_nonce() {
let (phone, phone_pub) = pair();
let keys = vec![key("phone", &phone_pub, None)];
let recorded = sign(&phone, &dial_message("mac", "phone", "old", "nd"));
let fresh = dial_message("mac", "phone", "new", "nd");
assert!(verify_with(&keys, "phone", &fresh, &recorded).is_none());
let elsewhere = dial_message("tv", "phone", "old", "nd");
assert!(verify_with(&keys, "phone", &elsewhere, &recorded).is_none());
}
#[test]
fn one_ends_signature_cannot_pass_as_the_others() {
let (mac, mac_pub) = pair();
let keys = vec![key("mac", &mac_pub, None)];
let listened = sign(&mac, &listen_message("phone", "mac", "nd", "nl", true));
let as_dial = dial_message("phone", "mac", "nd", "nl");
assert!(verify_with(&keys, "mac", &as_dial, &listened).is_none());
}
#[test]
fn a_key_with_an_owner_never_proves_an_own_device() {
let (own, own_pub) = pair();
let (theirs, theirs_pub) = pair();
let keys = vec![
key("phone", &own_pub, None),
key("phone", &theirs_pub, Some("kim")),
];
let msg = dial_message("mac", "phone", "nl", "nd");
assert_eq!(
verify_with(&keys, "phone", &msg, &sign(&theirs, &msg))
.unwrap()
.peer,
Peer::Shared("kim".into())
);
assert_eq!(
verify_with(&keys, "phone", &msg, &sign(&own, &msg))
.unwrap()
.peer,
Peer::Own
);
}
#[test]
fn keys_from_the_last_accounts_link_prove_nothing_for_the_next() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
crate::config::set_config_dir(dir.path());
let sign_in = |user: &str| {
Config::persist(|c| {
c.remote.enabled = true;
c.remote.url = "http://koan.test".into();
c.remote.username = user.into();
c.remote.api_key = "k".into();
})
.unwrap();
};
sign_in("jo");
let jo = account_of(&Config::cached());
sign_in("kim");
keep(vec![key("phone", "K", None)], jo);
assert!(kept().is_empty(), "jo's link, kim signed in");
keep(vec![key("phone", "K", None)], account_of(&Config::cached()));
assert_eq!(kept().len(), 1, "kim's own link");
forget();
assert!(kept().is_empty());
}
#[test]
fn signed_commands_are_bound_to_their_session_and_order() {
let (phone, phone_pub) = pair();
let keys = vec![key("phone", &phone_pub, None)];
let msg = dial_message("mac", "phone", "nl", "nd");
let proven = verify_with(&keys, "phone", &msg, &sign(&phone, &msg)).unwrap();
let mut sending = Session::new("mac", "phone", "nl", "nd");
let mut receiving = Session::new("mac", "phone", "nl", "nd");
let (s1, sig1) = sending.sign_with(&phone, r#"{"type":"pause"}"#).unwrap();
let (s2, sig2) = sending.sign_with(&phone, r#"{"type":"next"}"#).unwrap();
assert!(
!receiving.accept(&proven, s1, &sig1, r#"{"type":"sync"}"#),
"altered"
);
assert!(receiving.accept(&proven, s1, &sig1, r#"{"type":"pause"}"#));
assert!(
!receiving.accept(&proven, s1, &sig1, r#"{"type":"pause"}"#),
"replayed"
);
assert!(receiving.accept(&proven, s2, &sig2, r#"{"type":"next"}"#));
assert!(
!receiving.accept(&proven, s1, &sig1, r#"{"type":"pause"}"#),
"out of order"
);
let mut other = Session::new("mac", "phone", "nl2", "nd");
let (s, sig) = other.sign_with(&phone, r#"{"type":"pause"}"#).unwrap();
let mut fresh = Session::new("mac", "phone", "nl", "nd");
assert!(!fresh.accept(&proven, s, &sig, r#"{"type":"pause"}"#));
}
#[test]
fn a_signed_command_is_never_taken_for_a_report() {
let (mac, mac_pub) = pair();
let keys = vec![key("mac", &mac_pub, None)];
let msg = listen_message("phone", "mac", "nd", "nl", true);
let proven = verify_with(&keys, "mac", &msg, &sign(&mac, &msg)).unwrap();
let text = r#"{"type":"state"}"#;
let (seq, sig) = Session::new("mac", "phone", "nl", "nd")
.sign_with(&mac, text)
.unwrap();
assert!(!Session::reports("mac", "phone", "nl", "nd").accept(&proven, seq, &sig, text));
let (seq, sig) = Session::reports("mac", "phone", "nl", "nd")
.sign_with(&mac, text)
.unwrap();
assert!(Session::reports("mac", "phone", "nl", "nd").accept(&proven, seq, &sig, text));
}
#[test]
fn reports_are_signed_only_when_both_nonces_say_so() {
let (a, b) = (nonce().unwrap(), nonce().unwrap());
assert!(signs_reports(&a, &b));
let older = B64.encode([7u8; 32]);
assert!(!signs_reports(&a, &older));
assert!(!signs_reports(&older, &b));
}
#[test]
fn kept_keys_hold_for_their_account_and_a_month() {
let cfg = |url: &str, user: &str| {
let mut c = Config::default();
c.remote.enabled = true;
c.remote.url = url.into();
c.remote.username = user.into();
c.remote.api_key = "k".into();
account_of(&c)
};
let jo = cfg("http://koan.test", "jo");
let kept = Kept {
account: jo.clone(),
at: 1_000_000,
keys: vec![key("phone", "K", None)],
};
assert_eq!(trusted(&kept, jo.as_deref(), 1_000_000 + 60).len(), 1);
assert_eq!(
trusted(&kept, cfg("http://KOAN.test/", "jo").as_deref(), 1_000_000).len(),
1,
"the same server, spelt differently"
);
assert!(
trusted(&kept, cfg("http://koan.test", "Jo").as_deref(), 1_000_000).is_empty(),
"usernames are matched exactly, as the server matches them"
);
let kim = cfg("http://koan.test", "kim");
assert!(
trusted(&kept, kim.as_deref(), 1_000_000).is_empty(),
"another account"
);
let elsewhere = cfg("http://other.test", "jo");
assert!(
trusted(&kept, elsewhere.as_deref(), 1_000_000).is_empty(),
"another server"
);
assert!(trusted(&kept, None, 1_000_000).is_empty(), "signed out");
assert!(
trusted(&kept, jo.as_deref(), 1_000_000 + KEYS_TRUSTED_FOR + 1).is_empty(),
"too old"
);
}
}