diff --git a/Cargo.lock b/Cargo.lock index eb72c37fc618d978e8ea54d1b1887c8087f5d4c1..7ca8611708bd432e3cf116aad793573aa8a14cc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3672,6 +3672,7 @@ dependencies = [ "accesskit_consumer", "accesskit_winit", "arboard", + "base64", "block2 0.5.1", "canvas", "draw", diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 1555712c1a07eed345c3d4c9a4bf4f917b2eb815..2b5dbbdf259d33d846c15142addafe2f2ecade1f 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -45,6 +45,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] } rustls-native-certs = "0.8" webpki-root-certs = "1.0" ring = "0.17" +# Reads minisign.pub, the release key the updates are checked against. +base64 = { version = "0.23.1", default-features = false, features = ["std"] } # The browser: see arc/platforms.md. [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/snowbound/examples/release_sign.rs b/crates/snowbound/examples/release_sign.rs index d987598223b50cd6766403f4b7162c02a19425c4..1372fd7676e3e2ae174d5491483b141d0e6bb26c 100644 --- a/crates/snowbound/examples/release_sign.rs +++ b/crates/snowbound/examples/release_sign.rs @@ -1,16 +1,26 @@ -//! Signs releases with the ed25519 key whose public half the app embeds -//! (`release-key.pub`): `release_sign new KEY` makes a key, readable only by its owner; -//! `release_sign KEY FILE...` prints each file's signature, in hex, one per line. +//! Signs releases with the ed25519 key whose public half the app embeds (`minisign.pub`): +//! `release_sign new KEY` makes a key, readable only by its owner, and prints its +//! `minisign.pub`; `release_sign KEY FILE...` prints each file's signature, in hex, one per line. +use base64::Engine; +use base64::engine::general_purpose::STANDARD; use ring::signature::{Ed25519KeyPair, KeyPair}; use std::io::Write; -const PUBLIC: &str = include_str!("../release-key.pub"); +const PUBLIC: &str = include_str!("../../../minisign.pub"); fn hex(bytes: &[u8]) -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() } +/// `public` as a minisign public key, its key id the key's first 8 bytes. +fn minisign(public: &[u8]) -> String { + let id = &public[..8]; + let shown: String = id.iter().rev().map(|byte| format!("{byte:02X}")).collect(); + let key = STANDARD.encode([b"Ed", id, public].concat()); + format!("untrusted comment: minisign public key {shown}\n{key}\n") +} + fn main() -> Result<(), Box> { let args: Vec = std::env::args().skip(1).collect(); match args.as_slice() { @@ -26,13 +36,13 @@ fn main() -> Result<(), Box> { std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600); file.open(key)?.write_all(document.as_ref())?; let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).map_err(|_| "Bad key")?; - println!("{}", hex(pair.public_key().as_ref())); + print!("{}", minisign(pair.public_key().as_ref())); } [key, files @ ..] if !files.is_empty() => { let pair = Ed25519KeyPair::from_pkcs8(&std::fs::read(key)?) .map_err(|_| format!("{key} is not an ed25519 key"))?; - if hex(pair.public_key().as_ref()) != PUBLIC.trim() { - return Err(format!("{key} is not the key release-key.pub names").into()); + if minisign(pair.public_key().as_ref()) != PUBLIC { + return Err(format!("{key} is not the key minisign.pub names").into()); } for file in files { println!("{}", hex(pair.sign(&std::fs::read(file)?).as_ref())); diff --git a/crates/snowbound/release-key.pub b/crates/snowbound/release-key.pub deleted file mode 100644 index 2dc512fec0d0197c27ca90a3964a803cecb2fece..0000000000000000000000000000000000000000 --- a/crates/snowbound/release-key.pub +++ /dev/null @@ -1 +0,0 @@ -5af6766e8e2204ee52f329af4f5233be3b06419c1df0f8c7d82112b31d346b09 diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index d8dd004a7755e78a4818987db9e4666d7f344326..c9c088c200f3ce16478a10299d4fe4304e585818 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -28,8 +28,8 @@ use ureq::tls::{Certificate, RootCerts, TlsConfig}; /// Where the builds are published. const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; -/// The release key's public half, in hex. -const KEY: &str = include_str!("../release-key.pub"); +/// The release key's public half, as `minisign -V` reads it. +const KEY: &str = include_str!("../../../minisign.pub"); /// The argument `relaunch` starts the old executable with to finish an update. pub const FINISH: &str = "--finish-update"; @@ -190,13 +190,13 @@ pub fn summary(changes: &[Change]) -> Option { } } +/// What the signed `build.json` says of an archive. The `signature` it also gives, the release +/// key's of the archive's bytes, is for older apps, which check it too. #[derive(Clone, Debug, Deserialize)] struct Archive { file: String, size: u64, sha256: String, - /// The release key's signature of the archive's bytes. - signature: String, } fn unhex(text: &str) -> Option> { @@ -221,6 +221,15 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { #[cfg(target_arch = "wasm32")] const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; +/// `KEY`'s ed25519 public key, after minisign's algorithm and key id. +#[cfg(not(target_arch = "wasm32"))] +fn release_key() -> Vec { + use base64::Engine; + let line = KEY.lines().nth(1).expect("minisign.pub holds a key"); + let key = base64::engine::general_purpose::STANDARD.decode(line); + key.expect("minisign.pub's key is base64")[10..].to_vec() +} + #[cfg(not(target_arch = "wasm32"))] fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { let signature = unhex(signature).ok_or("The signature isn’t hex")?; @@ -278,7 +287,7 @@ fn archive( Ok((archive, changes)) } -fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> { +fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> { if bytes.len() as u64 != archive.size { return Err(format!( "{} is {} bytes, not {}", @@ -294,7 +303,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); } } - verify(key, bytes, &archive.signature) + Ok(()) } /// What an update replaces: the app bundle on macOS, the executable elsewhere. Development @@ -455,7 +464,7 @@ fn check( &format!("{}{}", version.folder(), archive.file), archive.size, )?; - check_archive(key, &archive, &bytes).map_err(unverified)?; + check_archive(&archive, &bytes).map_err(unverified)?; Ok(match stage(&bytes, &folder, &version) { Ok(item) => Status::Ready(version, item, changes), Err(error) => { @@ -532,15 +541,15 @@ impl Updates { automatic, ..Shared::default() })); - let key = unhex(KEY).expect("release-key.pub holds a key in hex"); #[cfg(target_arch = "wasm32")] let thread = { - let _ = (key, proxy); + let _ = proxy; std::thread::current() }; #[cfg(not(target_arch = "wasm32"))] let thread = { let shared = Arc::clone(&shared); + let key = release_key(); std::thread::Builder::new() .name("updates".into()) .spawn(move || { @@ -882,7 +891,6 @@ mod tests { "file": file, "size": bytes.len(), "sha256": hex(digest.as_ref()), - "signature": hex(pair.sign(bytes).as_ref()), }}, })) .unwrap(); @@ -892,6 +900,7 @@ mod tests { #[test] fn signatures_and_hashes_are_checked() { + assert_eq!(release_key().len(), 32); let pair = generate(); let key = pair.public_key().as_ref(); let tenth = version("2026-09-29-r10"); @@ -899,7 +908,7 @@ mod tests { let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); - check_archive(key, &found, &bytes).unwrap(); + check_archive(&found, &bytes).unwrap(); let mut tampered = build.clone(); let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); @@ -931,25 +940,15 @@ mod tests { assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); assert!( - check_archive(key, &found, b"an archivf") + check_archive(&found, b"an archivf") .unwrap_err() .contains("SHA-256") ); assert!( - check_archive(key, &found, b"an archive!") + check_archive(&found, b"an archive!") .unwrap_err() .contains("bytes") ); - // Right size and hash, but signed by another key. - let forged = Archive { - signature: hex(generate().sign(&bytes).as_ref()), - ..found - }; - assert!( - check_archive(key, &forged, &bytes) - .unwrap_err() - .contains("signature") - ); } fn change(kind: Kind, title: &str) -> Change { @@ -1236,7 +1235,7 @@ mod tests { let old = version("2000-01-01-r1"); let status = check( &download, - &unhex(KEY).unwrap(), + &release_key(), Some(&old), Some(&install), &|_| {}, diff --git a/minisign.pub b/minisign.pub new file mode 100644 index 0000000000000000000000000000000000000000..666d85c4ffb83fa8c13d2f8e713baf20313ed415 --- /dev/null +++ b/minisign.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key EE04228E6E76F65A +RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ diff --git a/readme.md b/readme.md index c439b1b5b371ac7bae4c0f945fd4fd76c73f51e1..7333eb7efb48c0d53a7cea8613dec5176daf19e3 100644 --- a/readme.md +++ b/readme.md @@ -12,6 +12,7 @@ pen and drawing tools, recording audio and video, revision history, multi-machine live collaboration, and much more.

Download: macOS: Silicon Intel OS X 10.6+ • Linux: x86_64 aarch64 • Windows: x64 Arm

+

Each download has a .minisig beside it: minisign -Vm FILE -P RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ