nd / nd-core/src/identity.rs

raw file 4470 bytes
//! Local keypair identity — ownership without accounts.
//!
//! The cli keeps an ed25519 keypair at ~/.config/notdouble/identity.key.
//! Uploads and deletes are signed with it; the hub stores the public key
//! as the project's `owner`. Private key never leaves the machine.

use anyhow::{Context, Result};
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use std::path::Path;

#[derive(Debug, Clone)]
pub struct Identity {
    signing: SigningKey,
}

impl Identity {
    pub fn generate() -> Identity {
        let mut rng = rand_core::OsRng;
        Identity { signing: SigningKey::generate(&mut rng) }
    }

    /// base64 of the 32-byte public key — this is what the hub stores.
    pub fn public_b64(&self) -> String {
        b64(&self.signing.verifying_key().to_bytes())
    }

    /// Short display form (first 8 base64 chars).
    pub fn short(&self) -> String {
        self.public_b64().chars().take(8).collect()
    }

    /// base64 signature over `msg`.
    pub fn sign(&self, msg: &[u8]) -> String {
        b64(&self.signing.sign(msg).to_bytes())
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, b64(&self.signing.to_bytes()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
        }
        Ok(())
    }
}

pub fn load(path: &Path) -> Result<Identity> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("read {}", path.display()))?;
    from_seed_b64(raw.trim()).with_context(|| format!("bad identity file {}", path.display()))
}

/// Load the keypair, generating and saving a new one if missing.
pub fn load_or_create(path: &Path) -> Result<Identity> {
    if path.exists() {
        load(path)
    } else {
        let id = Identity::generate();
        id.save(path)?;
        Ok(id)
    }
}

pub fn from_seed_b64(s: &str) -> Result<Identity> {
    let bytes = unb64(s)?;
    let seed: [u8; 32] = bytes
        .try_into()
        .map_err(|_| anyhow::anyhow!("identity seed must be 32 bytes"))?;
    Ok(Identity { signing: SigningKey::from_bytes(&seed) })
}

/// Verify a base64 signature over `msg` made by the base64 public key.
pub fn verify(pub_b64: &str, msg: &[u8], sig_b64: &str) -> bool {
    let (Ok(pk), Ok(sig)) = (unb64(pub_b64), unb64(sig_b64)) else {
        return false;
    };
    let (Ok(pk), Ok(sig)) = (
        <[u8; 32]>::try_from(pk.as_slice()),
        <[u8; 64]>::try_from(sig.as_slice()),
    ) else {
        return false;
    };
    let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
        return false;
    };
    vk.verify(msg, &Signature::from_bytes(&sig)).is_ok()
}

/// Does this string decode to a plausible ed25519 public key?
pub fn pubkey_valid(s: &str) -> bool {
    unb64(s).map(|b| b.len() == 32).unwrap_or(false)
}

/// First 8 chars of a public key, for display.
pub fn short_key(pub_b64: &str) -> String {
    pub_b64.chars().take(8).collect()
}

fn b64(data: &[u8]) -> String {
    base64::engine::general_purpose::STANDARD.encode(data)
}

fn unb64(s: &str) -> Result<Vec<u8>> {
    Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_and_sign() {
        let id = Identity::generate();
        let seed = b64(&{
            // recover seed via from_seed path: serialize pub, then re-derive
            [0u8; 0]
        });
        let _ = seed;

        let pub_b64 = id.public_b64();
        assert_eq!(pub_b64.len(), 44); // 32 bytes -> 43 chars + '=' padding
        assert!(pubkey_valid(&pub_b64));

        let sig = id.sign(b"hello");
        assert!(verify(&pub_b64, b"hello", &sig));
        assert!(!verify(&pub_b64, b"tampered", &sig));

        // a different key cannot verify for this owner
        let other = Identity::generate();
        assert!(!verify(&other.public_b64(), b"hello", &sig));
    }

    #[test]
    fn seed_roundtrip() {
        let id = Identity::generate();
        let file = std::env::temp_dir().join(format!("nd-id-{}", std::process::id()));
        id.save(&file).unwrap();
        let back = load(&file).unwrap();
        assert_eq!(id.public_b64(), back.public_b64());
        std::fs::remove_file(&file).ok();
    }
}