use std::io::{Error, ErrorKind, Result, Write};
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
pub const NONCE: usize = 32;
pub const PROOF: usize = 32;
pub const ROUTER: &[u8] = b"zap router";
pub const CLIENT: &[u8] = b"zap client";
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Pairing {
pub port: u16,
pub key: [u8; 32],
}
impl Pairing {
pub fn code(&self) -> String {
format!("ws://127.0.0.1:{}/#{}", self.port, hex(&self.key))
}
pub fn parse(code: &str) -> Result<Pairing> {
let bad = || Error::new(ErrorKind::InvalidData, "zapd: not a pairing code");
let rest = code
.trim()
.strip_prefix("ws://127.0.0.1:")
.ok_or_else(bad)?;
let (port, key) = rest.split_once("/#").ok_or_else(bad)?;
let port: u16 = port.parse().map_err(|_| bad())?;
if port == 0 {
return Err(bad());
}
Ok(Pairing {
port,
key: unhex(key).ok_or_else(bad)?,
})
}
pub fn proof(&self, label: &[u8], nc: &[u8], ns: &[u8]) -> [u8; PROOF] {
self.mac(label, nc, ns).finalize().into_bytes().into()
}
pub fn verify(&self, label: &[u8], nc: &[u8], ns: &[u8], proof: &[u8]) -> bool {
self.mac(label, nc, ns).verify_slice(proof).is_ok()
}
fn mac(&self, label: &[u8], nc: &[u8], ns: &[u8]) -> Hmac<Sha256> {
let mut m = <Hmac<Sha256> as KeyInit>::new_from_slice(&self.key).expect("any key length");
m.update(label);
m.update(nc);
m.update(ns);
m
}
}
pub fn free_port() -> Result<u16> {
for _ in 0..100 {
let r = random()?;
let p = 20000 + (u16::from_le_bytes([r[0], r[1]]) % 10000);
if std::net::TcpListener::bind(("127.0.0.1", p)).is_ok() {
return Ok(p);
}
}
Err(Error::new(
ErrorKind::AddrInUse,
"zapd: no free door port in 20000-29999",
))
}
pub fn path() -> PathBuf {
let base = directories::BaseDirs::new()
.map(|d| d.state_dir().unwrap_or(d.data_local_dir()).to_path_buf());
base.unwrap_or_else(|| PathBuf::from("."))
.join("zap")
.join("pair")
}
pub fn load() -> Result<Pairing> {
let p = path();
let dir = p.parent().expect("pair path has a parent");
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
private(dir, true)?;
match read(&p) {
Err(e) if e.kind() == ErrorKind::NotFound => {
let _ = publish(
&p,
&Pairing {
port: free_port()?,
key: random()?,
},
false,
);
read(&p)
}
r => r,
}
}
pub fn reset() -> Result<Pairing> {
load()?;
publish(
&path(),
&Pairing {
port: free_port()?,
key: random()?,
},
true,
)?;
load()
}
fn read(p: &Path) -> Result<Pairing> {
private(p, false)?;
Pairing::parse(&std::fs::read_to_string(p)?)
}
fn private(p: &Path, dir: bool) -> Result<()> {
let m = std::fs::symlink_metadata(p)?;
let me = unsafe { libc::getuid() };
let kind_ok = if dir {
m.file_type().is_dir()
} else {
m.file_type().is_file()
};
if !kind_ok || m.uid() != me || m.mode() & 0o077 != 0 {
return Err(Error::new(
ErrorKind::PermissionDenied,
format!(
"zapd: {} must be a {} owned by uid {me} with no group or other access",
p.display(),
if dir { "directory" } else { "file" }
),
));
}
Ok(())
}
fn random() -> Result<[u8; 32]> {
let mut k = [0u8; 32];
getrandom::fill(&mut k).map_err(|e| Error::other(format!("zapd: no randomness: {e}")))?;
Ok(k)
}
pub fn nonce() -> Result<[u8; NONCE]> {
random()
}
fn publish(p: &Path, pairing: &Pairing, replace: bool) -> Result<()> {
let dir = p.parent().expect("pair path has a parent");
let tmp = dir.join(format!(".pair.{}", std::process::id()));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(format!("{}\n", pairing.code()).as_bytes())?;
f.sync_all()?;
let moved = if replace {
std::fs::rename(&tmp, p)
} else {
std::fs::hard_link(&tmp, p)
};
let _ = std::fs::remove_file(&tmp);
moved
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Option<[u8; 32]> {
if s.len() != 64
|| !s
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
{
return None;
}
let mut k = [0u8; 32];
for (i, k) in k.iter_mut().enumerate() {
*k = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(k)
}
#[cfg(test)]
mod tests {
use super::*;
fn pairing() -> Pairing {
Pairing {
port: 21000,
key: [9; 32],
}
}
#[test]
fn code_roundtrips() {
let p = pairing();
assert_eq!(
p.code(),
format!("ws://127.0.0.1:21000/#{}", "09".repeat(32))
);
assert_eq!(Pairing::parse(&p.code()).unwrap(), p);
assert_eq!(Pairing::parse(&format!(" {}\n", p.code())).unwrap(), p);
}
#[test]
fn code_is_loopback_only() {
let key = "09".repeat(32);
for bad in [
format!("ws://evil.example:21000/#{key}"),
format!("ws://localhost:21000/#{key}"),
format!("wss://127.0.0.1:21000/#{key}"),
format!("ws://127.0.0.1:0/#{key}"),
format!("ws://127.0.0.1:21000/#{}", "0A".repeat(32)),
"ws://127.0.0.1:21000/#short".to_string(),
"ws://127.0.0.1:21000/".to_string(),
] {
assert!(Pairing::parse(&bad).is_err(), "{bad}");
}
}
#[test]
fn proofs_are_labelled() {
let p = pairing();
let (nc, ns) = ([1u8; 32], [2u8; 32]);
let r = p.proof(ROUTER, &nc, &ns);
assert!(p.verify(ROUTER, &nc, &ns, &r));
assert!(!p.verify(CLIENT, &nc, &ns, &r));
let other = Pairing {
port: 21000,
key: [8; 32],
};
assert!(!other.verify(ROUTER, &nc, &ns, &r));
}
#[test]
fn a_new_door_port_is_free_and_below_ephemeral() {
let p = free_port().unwrap();
assert!((20000..30000).contains(&p));
assert!(std::net::TcpListener::bind(("127.0.0.1", p)).is_ok());
}
#[test]
fn refuses_a_readable_or_planted_token() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("zapd-private-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let tok = dir.join("pair");
let first = Pairing {
port: 21000,
key: [3; 32],
};
publish(&tok, &first, false).unwrap();
assert_eq!(read(&tok).unwrap(), first);
assert!(publish(
&tok,
&Pairing {
port: 21000,
key: [4; 32]
},
false
)
.is_err());
std::fs::set_permissions(&tok, std::fs::Permissions::from_mode(0o640)).unwrap();
assert_eq!(read(&tok).unwrap_err().kind(), ErrorKind::PermissionDenied);
std::fs::set_permissions(&tok, std::fs::Permissions::from_mode(0o600)).unwrap();
let link = dir.join("link");
std::os::unix::fs::symlink(&tok, &link).unwrap();
assert_eq!(read(&link).unwrap_err().kind(), ErrorKind::PermissionDenied);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
private(&dir, true).unwrap_err().kind(),
ErrorKind::PermissionDenied
);
std::fs::remove_dir_all(&dir).unwrap();
}
}