Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 1 | //! Signed lands. `vcs key` makes this machine's Ed25519 key and registers its |
| 2 | //! public half with the host as the signed-in account's; every land after is |
| 3 | //! signed. A signature covers what the person made: the change, its base |
| 4 | //! and content, and its description and author. The host checks it and keeps |
| 5 |
| 6 |
| 7 | use std::path::PathBuf; |
| 8 |
| 9 | use anyhow::{Result, anyhow}; |
| 10 | use base64::Engine; |
| 11 | use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; |
| 12 | use serde::Deserialize; |
| 13 | use serde_json::json; |
| 14 | use vcs_core::Id; |
| 15 |
| 16 | use crate::app::App; |
| 17 | use crate::out::{Fail, Out, Report}; |
| 18 | use crate::paths; |
| 19 | use crate::remote::{Remote, split_address}; |
| 20 |
| 21 | fn b64() -> base64::engine::GeneralPurpose { |
| 22 | base64::engine::general_purpose::STANDARD |
| 23 | } |
| 24 |
| 25 | fn path() -> PathBuf { |
| 26 | paths::home().join("signing-key") |
| 27 | } |
| 28 |
| 29 | /// This machine's key, if `vcs key` made one. |
| 30 | pub fn load() -> Option<SigningKey> { |
| 31 | let bytes: [u8; 32] = std::fs::read(path()).ok()?.try_into().ok()?; |
| 32 | Some(SigningKey::from_bytes(&bytes)) |
| 33 | } |
| 34 |
| 35 | fn create() -> Result<SigningKey> { |
| 36 | if let Some(k) = load() { |
| 37 | return Ok(k); |
| 38 | } |
| 39 | let key = SigningKey::from_bytes(&rand::random::<[u8; 32]>()); |
| 40 | std::fs::create_dir_all(paths::home())?; |
| 41 | let tmp = path().with_extension("tmp"); |
| 42 | std::fs::write(&tmp, key.to_bytes())?; |
| 43 | #[cfg(unix)] |
| 44 | { |
| 45 | use std::os::unix::fs::PermissionsExt; |
| 46 | std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; |
| 47 | } |
| 48 | std::fs::rename(&tmp, path())?; |
| 49 | Ok(key) |
| 50 | } |
| 51 |
| 52 | /// A public key as hosts write it: "ed25519:<base64>". |
| 53 | pub fn public_text(key: &VerifyingKey) -> String { |
| 54 | format!("ed25519:{}", b64().encode(key.as_bytes())) |
| 55 | } |
| 56 |
| 57 | /// What a signature covers; Upstream's host (signing.ts) writes the same text. |
| 58 | pub fn statement(project: &str, change: &str, base: &Id, tree: &Id, title: &str, body: &str, author: &str) -> String { |
| 59 | format!("vcs signature 1\nproject {project}\nchange {change}\nbase {}\ntree {}\nauthor {author}\n\n{title}\n\n{body}", base.hex(), tree.hex()) |
| 60 | } |
| 61 |
| 62 | /// A land's signature with this machine's key, if it has one: (key, signature). |
| 63 | pub fn sign(address: &str, change: &str, base: &Id, tree: &Id, title: &str, body: &str, author: &str) -> Option<(String, String)> { |
| 64 | let key = load()?; |
| 65 | let (_, owner, name) = split_address(address).ok()?; |
| 66 | let sig = key.sign(statement(&format!("{owner}/{name}"), change, base, tree, title, body, author).as_bytes()); |
| 67 | Some((public_text(&key.verifying_key()), b64().encode(sig.to_bytes()))) |
| 68 | } |
| 69 |
| 70 | /// Whether `sig` is `key`'s signature over `text`. |
| 71 | pub fn check(key: &str, sig: &str, text: &str) -> bool { |
| 72 | let parse = || -> Result<(VerifyingKey, Signature)> { |
| 73 | 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"))?; |
| 74 | let s: [u8; 64] = b64().decode(sig)?.try_into().map_err(|_| anyhow!("bad signature"))?; |
| 75 | Ok((VerifyingKey::from_bytes(&k)?, Signature::from_bytes(&s))) |
| 76 | }; |
| 77 | parse().is_ok_and(|(k, s)| k.verify(text.as_bytes(), &s).is_ok()) |
| 78 | } |
| 79 |
| 80 | /// `vcs key`: this machine's signing key (made if there isn't one), added to |
| 81 | /// the account on this workspace's host. |
| 82 | pub fn run(app: &App) -> Out { |
| 83 | let key = create().map_err(|e| Fail::new("failed", format!("couldn't make a key: {e:#}")))?; |
| 84 | let public = public_text(&key.verifying_key()); |
| 85 | let mut r = Report::new(); |
| 86 | r.line("key", public.clone()).set("key", public.clone()); |
| 87 | // Sealed folders: what others compare with the machines vcs access lists for you. |
| 88 | if let Ok(m) = crate::seal::machine() { |
| 89 | let fp = crate::seal::fingerprint(&crate::seal::public_text(&x25519_dalek::PublicKey::from(&m))); |
| 90 | r.line("machine", format!("{fp} (this machine's key for sealed folders)")).set("machine", fp); |
| 91 | } |
| 92 | if let Ok(s) = app.session() |
| 93 | && s.project.hosted() |
| 94 | { |
| 95 | #[derive(Deserialize)] |
| 96 | struct Added { |
| 97 | handle: String, |
| 98 | } |
| 99 | let host = s.project.address.split('/').next().unwrap_or("the host").to_string(); |
| 100 | match Remote::for_project(&s.project).post_host::<Added>("/keys", &json!({ "key": public })).map_err(Fail::from) { |
| 101 | Ok(added) => { |
| 102 | r.line("added", format!("to @{}'s keys on {host}", added.handle)); |
| 103 | r.set("handle", added.handle); |
| 104 | } |
| 105 | Err(f) if f.code == "not_found" => { |
| 106 | r.line("host", format!("{host} doesn't keep signing keys, so lands there aren't checked")); |
| 107 | } |
| 108 | Err(f) => return Err(f), |
| 109 | } |
| 110 | } |
| 111 | r.next("your lands are signed from now on; vcs verify <version> checks one"); |
| 112 | Ok(r) |
| 113 | } |
| 114 |
| 115 | #[derive(Deserialize)] |
| 116 | struct Signed { |
| 117 | handle: String, |
| 118 | key: String, |
| 119 | sig: String, |
| 120 | change: String, |
| 121 | base: String, |
| 122 | tree: String, |
| 123 | } |
| 124 |
| 125 | #[derive(Deserialize)] |
| 126 | struct SignedVersion { |
| 127 | n: u64, |
| 128 | title: String, |
| 129 | body: String, |
| 130 | author: String, |
| 131 | #[serde(default)] |
| 132 | signed: Option<Signed>, |
| 133 | } |
| 134 |
| 135 | /// `vcs verify [version]`: who signed the land that made a version of main, |
| 136 | /// checked here: the signature against what it says was landed, and the key |
| 137 | /// against the signer's keys on the host. |
| 138 | pub fn verify(app: &App, which: Option<String>) -> Out { |
| 139 | let s = app.session()?; |
| 140 | if !s.project.hosted() { |
| 141 | return Err(Fail::new("bad_usage", "signatures are kept by a host; this project is on this machine only").state("nothing changed")); |
| 142 | } |
| 143 | let main = app.refresh(&s.project)?.map(|v| v.n).unwrap_or(0); |
| 144 | let n = match which.as_deref() { |
| 145 | None | Some("main") => main, |
| 146 | Some(text) => text.parse::<u64>().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"))?, |
| 147 | }; |
| 148 | #[derive(Deserialize)] |
| 149 | struct List { |
| 150 | versions: Vec<SignedVersion>, |
| 151 | } |
| 152 | let remote = Remote::for_project(&s.project); |
| 153 | let list: List = remote.get_json(&format!("/versions?since={}&limit=1", n.saturating_sub(1)))?; |
| 154 | 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"))?; |
| 155 | let mut r = Report::new(); |
| 156 | let Some(signed) = v.signed else { |
| 157 | r.line("unsigned", format!("main {n} \"{}\" wasn't signed", v.title)); |
| 158 | r.set("version", n).set("signed", false); |
| 159 | r.exit = 1; |
| 160 | return Ok(r); |
| 161 | }; |
| 162 | let (_, owner, name) = split_address(&s.project.address)?; |
| 163 | let base = Id::parse(&signed.base).ok_or_else(|| Fail::new("failed", "the host's record of the signature is damaged"))?; |
| 164 | let tree = Id::parse(&signed.tree).ok_or_else(|| Fail::new("failed", "the host's record of the signature is damaged"))?; |
| 165 | let text = statement(&format!("{owner}/{name}"), &signed.change, &base, &tree, &v.title, &v.body, &v.author); |
| 166 | let good = check(&signed.key, &signed.sig, &text); |
| 167 | #[derive(Deserialize)] |
| 168 | struct Keys { |
| 169 | keys: Vec<String>, |
| 170 | } |
| 171 | let keys: Keys = remote.get_host(&format!("/keys/{}", signed.handle))?; |
| 172 | let theirs = keys.keys.contains(&signed.key); |
| 173 | r.set("version", n).set("handle", signed.handle.clone()).set("key", signed.key.clone()).set("signature_ok", good).set("key_on_account", theirs); |
| 174 | if good && theirs { |
| 175 | r.line("signed", format!("main {n} \"{}\" by @{} ({})", v.title, signed.handle, signed.key)); |
| 176 | r.set("signed", true); |
| 177 | } else { |
| 178 | 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") }); |
| 179 | r.set("signed", false); |
| 180 | r.exit = 1; |
| 181 | } |
| 182 | Ok(r) |
| 183 | } |
| 184 |