//! Signed lands. `vcs key` makes this machine's Ed25519 key and registers its //! public half with the host as the signed-in account's; every land after is //! signed. A signature covers what the person made: the change, its base //! and content, and its description and author. The host checks it and keeps //! it with the version; `vcs verify` checks it again here. use std::path::PathBuf; use anyhow::{Result, anyhow}; use base64::Engine; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::Deserialize; use serde_json::json; use vcs_core::Id; use crate::app::App; use crate::out::{Fail, Out, Report}; use crate::paths; use crate::remote::{Remote, split_address}; fn b64() -> base64::engine::GeneralPurpose { base64::engine::general_purpose::STANDARD } fn path() -> PathBuf { paths::home().join("signing-key") } /// This machine's key, if `vcs key` made one. pub fn load() -> Option { let bytes: [u8; 32] = std::fs::read(path()).ok()?.try_into().ok()?; Some(SigningKey::from_bytes(&bytes)) } fn create() -> Result { if let Some(k) = load() { return Ok(k); } let key = SigningKey::from_bytes(&rand::random::<[u8; 32]>()); std::fs::create_dir_all(paths::home())?; let tmp = path().with_extension("tmp"); std::fs::write(&tmp, key.to_bytes())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; } std::fs::rename(&tmp, path())?; Ok(key) } /// A public key as hosts write it: "ed25519:". pub fn public_text(key: &VerifyingKey) -> String { format!("ed25519:{}", b64().encode(key.as_bytes())) } /// What a signature covers; Upstream's host (signing.ts) writes the same text. pub fn statement(project: &str, change: &str, base: &Id, tree: &Id, title: &str, body: &str, author: &str) -> String { format!("vcs signature 1\nproject {project}\nchange {change}\nbase {}\ntree {}\nauthor {author}\n\n{title}\n\n{body}", base.hex(), tree.hex()) } /// A land's signature with this machine's key, if it has one: (key, signature). pub fn sign(address: &str, change: &str, base: &Id, tree: &Id, title: &str, body: &str, author: &str) -> Option<(String, String)> { let key = load()?; let (_, owner, name) = split_address(address).ok()?; let sig = key.sign(statement(&format!("{owner}/{name}"), change, base, tree, title, body, author).as_bytes()); Some((public_text(&key.verifying_key()), b64().encode(sig.to_bytes()))) } /// Whether `sig` is `key`'s signature over `text`. pub fn check(key: &str, sig: &str, text: &str) -> bool { let parse = || -> Result<(VerifyingKey, Signature)> { let k: [u8; 32] = b64().decode(key.strip_prefix("ed25519:").ok_or_else(|| anyhow!("not an ed25519 key"))?)?.try_into().map_err(|_| anyhow!("bad key"))?; let s: [u8; 64] = b64().decode(sig)?.try_into().map_err(|_| anyhow!("bad signature"))?; Ok((VerifyingKey::from_bytes(&k)?, Signature::from_bytes(&s))) }; parse().is_ok_and(|(k, s)| k.verify(text.as_bytes(), &s).is_ok()) } /// `vcs key`: this machine's signing key (made if there isn't one), added to /// the account on this workspace's host. pub fn run(app: &App) -> Out { let key = create().map_err(|e| Fail::new("failed", format!("couldn't make a key: {e:#}")))?; let public = public_text(&key.verifying_key()); let mut r = Report::new(); r.line("key", public.clone()).set("key", public.clone()); if let Ok(s) = app.session() && s.project.hosted() { #[derive(Deserialize)] struct Added { handle: String, } let host = s.project.address.split('/').next().unwrap_or("the host").to_string(); match Remote::for_project(&s.project).post_host::("/keys", &json!({ "key": public })).map_err(Fail::from) { Ok(added) => { r.line("added", format!("to @{}'s keys on {host}", added.handle)); r.set("handle", added.handle); } Err(f) if f.code == "not_found" => { r.line("host", format!("{host} doesn't keep signing keys, so lands there aren't checked")); } Err(f) => return Err(f), } } r.next("your lands are signed from now on; vcs verify checks one"); Ok(r) } #[derive(Deserialize)] struct Signed { handle: String, key: String, sig: String, change: String, base: String, tree: String, } #[derive(Deserialize)] struct SignedVersion { n: u64, title: String, body: String, author: String, #[serde(default)] signed: Option, } /// `vcs verify [version]`: who signed the land that made a version of main, /// checked here: the signature against what it says was landed, and the key /// against the signer's keys on the host. pub fn verify(app: &App, which: Option) -> Out { let s = app.session()?; if !s.project.hosted() { return Err(Fail::new("bad_usage", "signatures are kept by a host; this project is on this machine only").state("nothing changed")); } let main = app.refresh(&s.project)?.map(|v| v.n).unwrap_or(0); let n = match which.as_deref() { None | Some("main") => main, Some(text) => text.parse::().ok().or(crate::tags::find(app, &s.project, text)?).ok_or_else(|| Fail::new("bad_usage", format!("{text} isn't a version or a tag")).state("nothing changed"))?, }; #[derive(Deserialize)] struct List { versions: Vec, } let remote = Remote::for_project(&s.project); let list: List = remote.get_json(&format!("/versions?since={}&limit=1", n.saturating_sub(1)))?; let v = list.versions.into_iter().find(|v| v.n == n).ok_or_else(|| Fail::new("not_found", format!("main has no version {n}")).state("nothing changed"))?; let mut r = Report::new(); let Some(signed) = v.signed else { r.line("unsigned", format!("main {n} \"{}\" wasn't signed", v.title)); r.set("version", n).set("signed", false); r.exit = 1; return Ok(r); }; let (_, owner, name) = split_address(&s.project.address)?; let base = Id::parse(&signed.base).ok_or_else(|| Fail::new("failed", "the host's record of the signature is damaged"))?; let tree = Id::parse(&signed.tree).ok_or_else(|| Fail::new("failed", "the host's record of the signature is damaged"))?; let text = statement(&format!("{owner}/{name}"), &signed.change, &base, &tree, &v.title, &v.body, &v.author); let good = check(&signed.key, &signed.sig, &text); #[derive(Deserialize)] struct Keys { keys: Vec, } let keys: Keys = remote.get_host(&format!("/keys/{}", signed.handle))?; let theirs = keys.keys.contains(&signed.key); r.set("version", n).set("handle", signed.handle.clone()).set("key", signed.key.clone()).set("signature_ok", good).set("key_on_account", theirs); if good && theirs { r.line("signed", format!("main {n} \"{}\" by @{} ({})", v.title, signed.handle, signed.key)); r.set("signed", true); } else { r.line("bad", if good { format!("main {n}'s key isn't one of @{}'s keys any more", signed.handle) } else { format!("main {n}'s signature doesn't match what it says was landed") }); r.set("signed", false); r.exit = 1; } Ok(r) }