wsb 0.258.6441

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! A machine's identity, and how two machines come to trust each other.
//!
//! Every daemon has one long-lived ed25519 keypair, generated the first time
//! it starts. That key IS the machine as far as peers are concerned: a peer
//! relationship is between two public keys, and a machine that presents a
//! different key is a different machine however it is addressed.
//!
//! # Why this key is not passphrase-encrypted
//!
//! It authenticates a daemon that starts at boot and reconnects at three in
//! the morning, so there is nobody to ask for a passphrase. It is protected
//! the way an ssh host key is — a private file in a private directory, mode
//! 0600 — and it authorises exactly one thing: being recognised as this
//! machine by peers that already chose to trust it. It signs nothing that
//! outlives the connection, and it opens no store.
//!
//! # Pairing
//!
//! Trust is established by a person, once, out of band:
//!
//! ```text
//! desktop$ wsb peer pair
//! Pairing code: FERN-74-MOON
//!
//! laptop$  wsb peer pair FERN-74-MOON --at desktop.local:7420
//! ```
//!
//! The code is a short-lived shared secret. It proves the two daemons are
//! being introduced by the same person rather than by whoever reached the
//! port, and it is used ONCE, to authenticate the exchange of the two public
//! keys. After that the keys are pinned and the code is worthless: reconnecting
//! never needs another one, and a peer whose key changes is refused rather
//! than re-trusted.

use std::path::{Path, PathBuf};

use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};

use super::model::PeerId;

/// A public key, as it is shown to a person.
///
/// Groups of four hex characters. Read aloud when two people confirm a
/// pairing, and printed by `wsb peer list` so a changed identity is visible
/// rather than merely refused.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fingerprint(String);

impl Fingerprint {
    /// SHA-256 of the public key, first 16 bytes, grouped.
    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
    }

    /// Rebuild one that was stored. Presentation only — nothing trusts a
    /// fingerprint, because the pinned public key is what is actually
    /// compared.
    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)
    }
}

/// The id a public key derives to.
///
/// Deterministic: two machines that hold the same public key call it the same
/// id, without asking anyone. Lowercase base32 of the key's digest, which is
/// readable, sortable and safe in a path.
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]))
}

/// Crockford-style base32 without padding, lowercase, and without the letters
/// that are read wrong out loud (i, l, o, u).
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
}

/// This machine's identity: the keypair, and everything derived from it.
pub struct MachineIdentity {
    signing: SigningKey,
    id: PeerId,
    fingerprint: Fingerprint,
    /// What a person calls this machine. Presentation only — two machines may
    /// share a name and remain different peers.
    name: String,
}

impl std::fmt::Debug for MachineIdentity {
    /// Never prints the secret half.
    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()
    }
}

/// The identity file's on-disk form.
#[derive(Serialize, Deserialize)]
struct StoredIdentity {
    /// Base64 of the 32 secret bytes.
    secret_key: String,
    name: String,
}

impl MachineIdentity {
    /// Load this machine's identity, generating one the first time.
    ///
    /// The generation is not a side effect to be surprised by: a daemon
    /// without an identity cannot be a peer at all, and there is nothing for a
    /// person to decide about it.
    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))
    }

    /// Read one, or `None` when the file is not there.
    ///
    /// A file that exists and is not an identity is an error rather than a
    /// reason to generate a new one: a new key would silently break every
    /// peer relationship this machine has, and the operator would be told
    /// nothing.
    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()
    }

    /// Sign a handshake transcript. The only thing this key ever signs.
    pub fn sign(&self, message: &[u8]) -> Signature {
        self.signing.sign(message)
    }

    /// Rename this machine. Presentation only; the identity is the key.
    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())?,
        )
    }
}

/// Verify a signature against a peer's pinned public key.
pub fn verify(public: &VerifyingKey, message: &[u8], signature: &Signature) -> bool {
    public.verify(message, signature).is_ok()
}

/// A short-lived secret that introduces two machines.
///
/// Three groups, from an alphabet with no letters that are misheard. Not a
/// password: it authenticates one exchange, once, and is then worthless. It is
/// short because a person reads it off one screen and types it on another, and
/// it is short-LIVED because that is what keeps it safe to be short.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairingCode(String);

/// How long a pairing offer stands. Long enough to walk to the other machine,
/// short enough that a code left on a screen is not a standing invitation.
pub const PAIRING_WINDOW: std::time::Duration = std::time::Duration::from_secs(10 * 60);

impl PairingCode {
    /// Mint one.
    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())
    }

    /// Parse one a person typed, forgiving the things people do to codes:
    /// lowercase, extra spaces, missing dashes.
    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
    }

    /// The key the pairing exchange is authenticated with.
    ///
    /// The code alone is short enough to guess if an attacker could try it
    /// against the port repeatedly, so it is never compared directly: both
    /// sides derive this and prove possession by signing the transcript with
    /// it. A wrong code produces a wrong key and the handshake fails with
    /// nothing learned.
    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)
    }
}

/// What is known about a peer, once paired.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairedPeer {
    pub id: PeerId,
    pub name: String,
    /// Base64 of the 32-byte ed25519 public key. Pinned: a peer presenting a
    /// different key is refused, never re-trusted silently.
    pub public_key: String,
    pub fingerprint: Fingerprint,
    /// Where it was last reachable — presentation and a starting point for
    /// reconnection, never an identity.
    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))
    }
}

/// Base64, for the wire and for the identity file. Public because the
/// daemon encodes file contents with it and two spellings of base64 would be
/// two encodings.
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}"))
}

/// Write a file only this user can read, replacing whatever was there.
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;