use std::path::{Path, PathBuf};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use super::model::PeerId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fingerprint(String);
impl Fingerprint {
pub fn of(public: &VerifyingKey) -> Self {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(public.as_bytes());
let hex: String = digest
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect();
let grouped: Vec<String> = hex
.as_bytes()
.chunks(4)
.map(|chunk| String::from_utf8_lossy(chunk).to_string())
.collect();
Self(grouped.join(" "))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_stored(value: String) -> Self {
Self(value)
}
}
impl std::fmt::Display for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub fn peer_id_of(public: &VerifyingKey) -> PeerId {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(public.as_bytes());
PeerId::from_derived(base32(&digest[..10]))
}
fn base32(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
let mut out = String::new();
let mut buffer: u16 = 0;
let mut bits = 0u8;
for byte in bytes {
buffer = (buffer << 8) | u16::from(*byte);
bits += 8;
while bits >= 5 {
let index = ((buffer >> (bits - 5)) & 0b1_1111) as usize;
out.push(ALPHABET[index] as char);
bits -= 5;
}
}
if bits > 0 {
let index = ((buffer << (5 - bits)) & 0b1_1111) as usize;
out.push(ALPHABET[index] as char);
}
out
}
pub struct MachineIdentity {
signing: SigningKey,
id: PeerId,
fingerprint: Fingerprint,
name: String,
}
impl std::fmt::Debug for MachineIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MachineIdentity")
.field("id", &self.id)
.field("name", &self.name)
.field("fingerprint", &self.fingerprint)
.finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize)]
struct StoredIdentity {
secret_key: String,
name: String,
}
impl MachineIdentity {
pub fn load_or_create(state_dir: &Path, default_name: &str) -> Result<Self, String> {
let path = Self::path_in(state_dir);
if let Some(existing) = Self::read(&path)? {
return Ok(existing);
}
std::fs::create_dir_all(state_dir)
.map_err(|e| format!("cannot create {}: {e}", state_dir.display()))?;
use rand::RngCore;
let mut seed = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut seed);
let signing = SigningKey::from_bytes(&seed);
let stored = StoredIdentity {
secret_key: base64_encode(&seed),
name: default_name.to_string(),
};
write_private(&path, &serde_json::to_vec_pretty(&stored).map_err(|e| e.to_string())?)?;
Ok(Self::from_parts(signing, stored.name))
}
fn read(path: &Path) -> Result<Option<Self>, String> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
let stored: StoredIdentity = serde_json::from_slice(&bytes)
.map_err(|e| format!("{} is not a machine identity: {e}", path.display()))?;
let seed = base64_decode(&stored.secret_key)
.map_err(|e| format!("{}: {e}", path.display()))?;
let seed: [u8; 32] = seed.try_into().map_err(|_| {
format!("{} does not hold a 32-byte key", path.display())
})?;
Ok(Some(Self::from_parts(SigningKey::from_bytes(&seed), stored.name)))
}
fn from_parts(signing: SigningKey, name: String) -> Self {
let public = signing.verifying_key();
Self {
id: peer_id_of(&public),
fingerprint: Fingerprint::of(&public),
signing,
name,
}
}
pub fn path_in(state_dir: &Path) -> PathBuf {
state_dir.join("machine-identity.json")
}
pub fn id(&self) -> &PeerId {
&self.id
}
pub fn fingerprint(&self) -> &Fingerprint {
&self.fingerprint
}
pub fn name(&self) -> &str {
&self.name
}
pub fn public_key(&self) -> VerifyingKey {
self.signing.verifying_key()
}
pub fn sign(&self, message: &[u8]) -> Signature {
self.signing.sign(message)
}
pub fn rename(&mut self, state_dir: &Path, name: &str) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("a machine name is not empty".to_string());
}
self.name = name.to_string();
let stored = StoredIdentity {
secret_key: base64_encode(self.signing.as_bytes()),
name: self.name.clone(),
};
write_private(
&Self::path_in(state_dir),
&serde_json::to_vec_pretty(&stored).map_err(|e| e.to_string())?,
)
}
}
pub fn verify(public: &VerifyingKey, message: &[u8], signature: &Signature) -> bool {
public.verify(message, signature).is_ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairingCode(String);
pub const PAIRING_WINDOW: std::time::Duration = std::time::Duration::from_secs(10 * 60);
impl PairingCode {
pub fn mint() -> Self {
use rand::Rng;
const WORDS: [&str; 32] = [
"amber", "birch", "cedar", "delta", "ember", "fern", "grove", "hazel", "iris",
"jade", "kite", "lark", "moon", "nova", "onyx", "pearl", "quartz", "reef", "sable",
"thorn", "umber", "vale", "willow", "xenon", "yarrow", "zephyr", "arrow", "brook",
"cliff", "dune", "elm", "flint",
];
let mut rng = rand::thread_rng();
let first = WORDS[rng.gen_range(0..WORDS.len())];
let number: u8 = rng.gen_range(10..100);
let second = WORDS[rng.gen_range(0..WORDS.len())];
Self(format!("{first}-{number}-{second}").to_uppercase())
}
pub fn parse(raw: &str) -> Result<Self, String> {
let cleaned: String = raw
.trim()
.to_uppercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
let parts: Vec<&str> = cleaned.split('-').filter(|p| !p.is_empty()).collect();
if parts.len() != 3 {
return Err(format!(
"'{raw}' is not a pairing code — they look like FERN-74-MOON"
));
}
Ok(Self(parts.join("-")))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn secret(&self) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"wsb-peer-pairing-v1");
hasher.update(self.0.as_bytes());
hasher.finalize().into()
}
}
impl std::fmt::Display for PairingCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairedPeer {
pub id: PeerId,
pub name: String,
pub public_key: String,
pub fingerprint: Fingerprint,
pub address: String,
pub paired_at: u64,
}
impl PairedPeer {
pub fn verifying_key(&self) -> Result<VerifyingKey, String> {
let bytes = base64_decode(&self.public_key)?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| format!("{} has a malformed public key", self.id))?;
VerifyingKey::from_bytes(&bytes)
.map_err(|e| format!("{} has an unusable public key: {e}", self.id))
}
}
pub fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub fn base64_decode(text: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(text.trim())
.map_err(|e| format!("not valid base64: {e}"))
}
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
let staged = path.with_extension(format!("tmp{}", std::process::id()));
let _ = std::fs::remove_file(&staged);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&staged)
.map_err(|e| format!("cannot create {}: {e}", staged.display()))?;
file.write_all(bytes)
.map_err(|e| format!("cannot write {}: {e}", staged.display()))?;
file.sync_all()
.map_err(|e| format!("cannot flush {}: {e}", staged.display()))?;
}
#[cfg(windows)]
{
std::fs::write(&staged, bytes)
.map_err(|e| format!("cannot write {}: {e}", staged.display()))?;
}
std::fs::rename(&staged, path)
.map_err(|e| format!("cannot publish {}: {e}", path.display()))
}
#[cfg(test)]
#[path = "identity_tests.rs"]
mod tests;