//! 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();
}
}