| author | |
| committer | |
| log | 0a185b6b6e414ab455bb8e02d0137bc7b0129f5b |
| tree | b300f2266d850775b7a8b5999182c244a13755b9 |
| parent | 9b22f115f0b0570cfc78ce223c6a325b1714939b |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Every published file gets a .minisig beside it, in the build folder and
in latest/, signed by the existing ed25519 release key in minisign's
prehashed format. minisign.pub at the root replaces
crates/snowbound/release-key.pub as the one public key, compiled into the
app and quoted in the readme's verify command.
The updater now trusts the archive's size and SHA-256 from the signed
build.json alone; release.py still writes each archive's raw signature
for older apps, which check it.
Assisted-by: claude-opus-5.510 files changed, 145 insertions(+), 44 deletions(-)
Cargo.lock+1| ... | @@ -3672,6 +3672,7 @@ dependencies = [ | ... | @@ -3672,6 +3672,7 @@ dependencies = [ |
| 3672 | "accesskit_consumer", | 3672 | "accesskit_consumer", |
| 3673 | "accesskit_winit", | 3673 | "accesskit_winit", |
| 3674 | "arboard", | 3674 | "arboard", |
| 3675 | "base64", | ||
| 3675 | "block2 0.5.1", | 3676 | "block2 0.5.1", |
| 3676 | "canvas", | 3677 | "canvas", |
| 3677 | "draw", | 3678 | "draw", |
crates/snowbound/Cargo.toml+2| ... | @@ -45,6 +45,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] } | ... | @@ -45,6 +45,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] } |
| 45 | rustls-native-certs = "0.8" | 45 | rustls-native-certs = "0.8" |
| 46 | webpki-root-certs = "1.0" | 46 | webpki-root-certs = "1.0" |
| 47 | ring = "0.17" | 47 | ring = "0.17" |
| 48 | # Reads minisign.pub, the release key the updates are checked against. | ||
| 49 | base64 = { version = "0.23.1", default-features = false, features = ["std"] } | ||
| 48 | 50 | ||
| 49 | # The browser: see arc/platforms.md. | 51 | # The browser: see arc/platforms.md. |
| 50 | [target.'cfg(target_arch = "wasm32")'.dependencies] | 52 | [target.'cfg(target_arch = "wasm32")'.dependencies] |
crates/snowbound/examples/release_sign.rs+17-7| ... | @@ -1,16 +1,26 @@ | ... | @@ -1,16 +1,26 @@ |
| 1 | //! Signs releases with the ed25519 key whose public half the app embeds | 1 | //! Signs releases with the ed25519 key whose public half the app embeds (`minisign.pub`): |
| 2 | //! (`release-key.pub`): `release_sign new KEY` makes a key, readable only by its owner; | 2 | //! `release_sign new KEY` makes a key, readable only by its owner, and prints its |
| 3 | //! `release_sign KEY FILE...` prints each file's signature, in hex, one per line. | 3 | //! `minisign.pub`; `release_sign KEY FILE...` prints each file's signature, in hex, one per line. |
| 4 | 4 | ||
| 5 | use base64::Engine; | ||
| 6 | use base64::engine::general_purpose::STANDARD; | ||
| 5 | use ring::signature::{Ed25519KeyPair, KeyPair}; | 7 | use ring::signature::{Ed25519KeyPair, KeyPair}; |
| 6 | use std::io::Write; | 8 | use std::io::Write; |
| 7 | 9 | ||
| 8 | const PUBLIC: &str = include_str!("../release-key.pub"); | 10 | const PUBLIC: &str = include_str!("../../../minisign.pub"); |
| 9 | 11 | ||
| 10 | fn hex(bytes: &[u8]) -> String { | 12 | fn hex(bytes: &[u8]) -> String { |
| 11 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() | 13 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() |
| 12 | } | 14 | } |
| 13 | 15 | ||
| 16 | /// `public` as a minisign public key, its key id the key's first 8 bytes. | ||
| 17 | fn minisign(public: &[u8]) -> String { | ||
| 18 | let id = &public[..8]; | ||
| 19 | let shown: String = id.iter().rev().map(|byte| format!("{byte:02X}")).collect(); | ||
| 20 | let key = STANDARD.encode([b"Ed", id, public].concat()); | ||
| 21 | format!("untrusted comment: minisign public key {shown}\n{key}\n") | ||
| 22 | } | ||
| 23 | |||
| 14 | fn main() -> Result<(), Box<dyn std::error::Error>> { | 24 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 15 | let args: Vec<String> = std::env::args().skip(1).collect(); | 25 | let args: Vec<String> = std::env::args().skip(1).collect(); |
| 16 | match args.as_slice() { | 26 | match args.as_slice() { |
| ... | @@ -26,13 +36,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -26,13 +36,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 26 | std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600); | 36 | std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600); |
| 27 | file.open(key)?.write_all(document.as_ref())?; | 37 | file.open(key)?.write_all(document.as_ref())?; |
| 28 | let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).map_err(|_| "Bad key")?; | 38 | let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).map_err(|_| "Bad key")?; |
| 29 | println!("{}", hex(pair.public_key().as_ref())); | 39 | print!("{}", minisign(pair.public_key().as_ref())); |
| 30 | } | 40 | } |
| 31 | [key, files @ ..] if !files.is_empty() => { | 41 | [key, files @ ..] if !files.is_empty() => { |
| 32 | let pair = Ed25519KeyPair::from_pkcs8(&std::fs::read(key)?) | 42 | let pair = Ed25519KeyPair::from_pkcs8(&std::fs::read(key)?) |
| 33 | .map_err(|_| format!("{key} is not an ed25519 key"))?; | 43 | .map_err(|_| format!("{key} is not an ed25519 key"))?; |
| 34 | if hex(pair.public_key().as_ref()) != PUBLIC.trim() { | 44 | if minisign(pair.public_key().as_ref()) != PUBLIC { |
| 35 | return Err(format!("{key} is not the key release-key.pub names").into()); | 45 | return Err(format!("{key} is not the key minisign.pub names").into()); |
| 36 | } | 46 | } |
| 37 | for file in files { | 47 | for file in files { |
| 38 | println!("{}", hex(pair.sign(&std::fs::read(file)?).as_ref())); | 48 | println!("{}", hex(pair.sign(&std::fs::read(file)?).as_ref())); |
crates/snowbound/release-key.pub deleted-1| ... | @@ -1 +0,0 @@ | ||
| 1 | 5af6766e8e2204ee52f329af4f5233be3b06419c1df0f8c7d82112b31d346b09 | ||
crates/snowbound/src/update.rs+23-24| ... | @@ -28,8 +28,8 @@ use ureq::tls::{Certificate, RootCerts, TlsConfig}; | ... | @@ -28,8 +28,8 @@ use ureq::tls::{Certificate, RootCerts, TlsConfig}; |
| 28 | /// Where the builds are published. | 28 | /// Where the builds are published. |
| 29 | const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; | 29 | const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; |
| 30 | 30 | ||
| 31 | /// The release key's public half, in hex. | 31 | /// The release key's public half, as `minisign -V` reads it. |
| 32 | const KEY: &str = include_str!("../release-key.pub"); | 32 | const KEY: &str = include_str!("../../../minisign.pub"); |
| 33 | 33 | ||
| 34 | /// The argument `relaunch` starts the old executable with to finish an update. | 34 | /// The argument `relaunch` starts the old executable with to finish an update. |
| 35 | pub const FINISH: &str = "--finish-update"; | 35 | pub const FINISH: &str = "--finish-update"; |
| ... | @@ -190,13 +190,13 @@ pub fn summary(changes: &[Change]) -> Option<String> { | ... | @@ -190,13 +190,13 @@ pub fn summary(changes: &[Change]) -> Option<String> { |
| 190 | } | 190 | } |
| 191 | } | 191 | } |
| 192 | 192 | ||
| 193 | /// What the signed `build.json` says of an archive. The `signature` it also gives, the release | ||
| 194 | /// key's of the archive's bytes, is for older apps, which check it too. | ||
| 193 | #[derive(Clone, Debug, Deserialize)] | 195 | #[derive(Clone, Debug, Deserialize)] |
| 194 | struct Archive { | 196 | struct Archive { |
| 195 | file: String, | 197 | file: String, |
| 196 | size: u64, | 198 | size: u64, |
| 197 | sha256: String, | 199 | sha256: String, |
| 198 | /// The release key's signature of the archive's bytes. | ||
| 199 | signature: String, | ||
| 200 | } | 200 | } |
| 201 | 201 | ||
| 202 | fn unhex(text: &str) -> Option<Vec<u8>> { | 202 | fn unhex(text: &str) -> Option<Vec<u8>> { |
| ... | @@ -221,6 +221,15 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { | ... | @@ -221,6 +221,15 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { |
| 221 | #[cfg(target_arch = "wasm32")] | 221 | #[cfg(target_arch = "wasm32")] |
| 222 | const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; | 222 | const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; |
| 223 | 223 | ||
| 224 | /// `KEY`'s ed25519 public key, after minisign's algorithm and key id. | ||
| 225 | #[cfg(not(target_arch = "wasm32"))] | ||
| 226 | fn release_key() -> Vec<u8> { | ||
| 227 | use base64::Engine; | ||
| 228 | let line = KEY.lines().nth(1).expect("minisign.pub holds a key"); | ||
| 229 | let key = base64::engine::general_purpose::STANDARD.decode(line); | ||
| 230 | key.expect("minisign.pub's key is base64")[10..].to_vec() | ||
| 231 | } | ||
| 232 | |||
| 224 | #[cfg(not(target_arch = "wasm32"))] | 233 | #[cfg(not(target_arch = "wasm32"))] |
| 225 | fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { | 234 | fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { |
| 226 | let signature = unhex(signature).ok_or("The signature isn’t hex")?; | 235 | let signature = unhex(signature).ok_or("The signature isn’t hex")?; |
| ... | @@ -278,7 +287,7 @@ fn archive( | ... | @@ -278,7 +287,7 @@ fn archive( |
| 278 | Ok((archive, changes)) | 287 | Ok((archive, changes)) |
| 279 | } | 288 | } |
| 280 | 289 | ||
| 281 | fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> { | 290 | fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> { |
| 282 | if bytes.len() as u64 != archive.size { | 291 | if bytes.len() as u64 != archive.size { |
| 283 | return Err(format!( | 292 | return Err(format!( |
| 284 | "{} is {} bytes, not {}", | 293 | "{} is {} bytes, not {}", |
| ... | @@ -294,7 +303,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri | ... | @@ -294,7 +303,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri |
| 294 | return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); | 303 | return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); |
| 295 | } | 304 | } |
| 296 | } | 305 | } |
| 297 | verify(key, bytes, &archive.signature) | 306 | Ok(()) |
| 298 | } | 307 | } |
| 299 | 308 | ||
| 300 | /// What an update replaces: the app bundle on macOS, the executable elsewhere. Development | 309 | /// What an update replaces: the app bundle on macOS, the executable elsewhere. Development |
| ... | @@ -455,7 +464,7 @@ fn check( | ... | @@ -455,7 +464,7 @@ fn check( |
| 455 | &format!("{}{}", version.folder(), archive.file), | 464 | &format!("{}{}", version.folder(), archive.file), |
| 456 | archive.size, | 465 | archive.size, |
| 457 | )?; | 466 | )?; |
| 458 | check_archive(key, &archive, &bytes).map_err(unverified)?; | 467 | check_archive(&archive, &bytes).map_err(unverified)?; |
| 459 | Ok(match stage(&bytes, &folder, &version) { | 468 | Ok(match stage(&bytes, &folder, &version) { |
| 460 | Ok(item) => Status::Ready(version, item, changes), | 469 | Ok(item) => Status::Ready(version, item, changes), |
| 461 | Err(error) => { | 470 | Err(error) => { |
| ... | @@ -532,15 +541,15 @@ impl Updates { | ... | @@ -532,15 +541,15 @@ impl Updates { |
| 532 | automatic, | 541 | automatic, |
| 533 | ..Shared::default() | 542 | ..Shared::default() |
| 534 | })); | 543 | })); |
| 535 | let key = unhex(KEY).expect("release-key.pub holds a key in hex"); | ||
| 536 | #[cfg(target_arch = "wasm32")] | 544 | #[cfg(target_arch = "wasm32")] |
| 537 | let thread = { | 545 | let thread = { |
| 538 | let _ = (key, proxy); | 546 | let _ = proxy; |
| 539 | std::thread::current() | 547 | std::thread::current() |
| 540 | }; | 548 | }; |
| 541 | #[cfg(not(target_arch = "wasm32"))] | 549 | #[cfg(not(target_arch = "wasm32"))] |
| 542 | let thread = { | 550 | let thread = { |
| 543 | let shared = Arc::clone(&shared); | 551 | let shared = Arc::clone(&shared); |
| 552 | let key = release_key(); | ||
| 544 | std::thread::Builder::new() | 553 | std::thread::Builder::new() |
| 545 | .name("updates".into()) | 554 | .name("updates".into()) |
| 546 | .spawn(move || { | 555 | .spawn(move || { |
| ... | @@ -882,7 +891,6 @@ mod tests { | ... | @@ -882,7 +891,6 @@ mod tests { |
| 882 | "file": file, | 891 | "file": file, |
| 883 | "size": bytes.len(), | 892 | "size": bytes.len(), |
| 884 | "sha256": hex(digest.as_ref()), | 893 | "sha256": hex(digest.as_ref()), |
| 885 | "signature": hex(pair.sign(bytes).as_ref()), | ||
| 886 | }}, | 894 | }}, |
| 887 | })) | 895 | })) |
| 888 | .unwrap(); | 896 | .unwrap(); |
| ... | @@ -892,6 +900,7 @@ mod tests { | ... | @@ -892,6 +900,7 @@ mod tests { |
| 892 | 900 | ||
| 893 | #[test] | 901 | #[test] |
| 894 | fn signatures_and_hashes_are_checked() { | 902 | fn signatures_and_hashes_are_checked() { |
| 903 | assert_eq!(release_key().len(), 32); | ||
| 895 | let pair = generate(); | 904 | let pair = generate(); |
| 896 | let key = pair.public_key().as_ref(); | 905 | let key = pair.public_key().as_ref(); |
| 897 | let tenth = version("2026-09-29-r10"); | 906 | let tenth = version("2026-09-29-r10"); |
| ... | @@ -899,7 +908,7 @@ mod tests { | ... | @@ -899,7 +908,7 @@ mod tests { |
| 899 | let (build, signature) = | 908 | let (build, signature) = |
| 900 | publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); | 909 | publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); |
| 901 | let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); | 910 | let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); |
| 902 | check_archive(key, &found, &bytes).unwrap(); | 911 | check_archive(&found, &bytes).unwrap(); |
| 903 | 912 | ||
| 904 | let mut tampered = build.clone(); | 913 | let mut tampered = build.clone(); |
| 905 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); | 914 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); |
| ... | @@ -931,25 +940,15 @@ mod tests { | ... | @@ -931,25 +940,15 @@ mod tests { |
| 931 | assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); | 940 | assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); |
| 932 | 941 | ||
| 933 | assert!( | 942 | assert!( |
| 934 | check_archive(key, &found, b"an archivf") | 943 | check_archive(&found, b"an archivf") |
| 935 | .unwrap_err() | 944 | .unwrap_err() |
| 936 | .contains("SHA-256") | 945 | .contains("SHA-256") |
| 937 | ); | 946 | ); |
| 938 | assert!( | 947 | assert!( |
| 939 | check_archive(key, &found, b"an archive!") | 948 | check_archive(&found, b"an archive!") |
| 940 | .unwrap_err() | 949 | .unwrap_err() |
| 941 | .contains("bytes") | 950 | .contains("bytes") |
| 942 | ); | 951 | ); |
| 943 | // Right size and hash, but signed by another key. | ||
| 944 | let forged = Archive { | ||
| 945 | signature: hex(generate().sign(&bytes).as_ref()), | ||
| 946 | ..found | ||
| 947 | }; | ||
| 948 | assert!( | ||
| 949 | check_archive(key, &forged, &bytes) | ||
| 950 | .unwrap_err() | ||
| 951 | .contains("signature") | ||
| 952 | ); | ||
| 953 | } | 952 | } |
| 954 | 953 | ||
| 955 | fn change(kind: Kind, title: &str) -> Change { | 954 | fn change(kind: Kind, title: &str) -> Change { |
| ... | @@ -1236,7 +1235,7 @@ mod tests { | ... | @@ -1236,7 +1235,7 @@ mod tests { |
| 1236 | let old = version("2000-01-01-r1"); | 1235 | let old = version("2000-01-01-r1"); |
| 1237 | let status = check( | 1236 | let status = check( |
| 1238 | &download, | 1237 | &download, |
| 1239 | &unhex(KEY).unwrap(), | 1238 | &release_key(), |
| 1240 | Some(&old), | 1239 | Some(&old), |
| 1241 | Some(&install), | 1240 | Some(&install), |
| 1242 | &|_| {}, | 1241 | &|_| {}, |
minisign.pub created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | untrusted comment: minisign public key EE04228E6E76F65A | ||
| 2 | RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ | ||
readme.md+1| ... | @@ -12,6 +12,7 @@ pen and drawing tools, recording audio and video, revision history, | ... | @@ -12,6 +12,7 @@ pen and drawing tools, recording audio and video, revision history, |
| 12 | multi-machine live collaboration, and much more. | 12 | multi-machine live collaboration, and much more. |
| 13 | 13 | ||
| 14 | <p align="center"><b>Download</b>: macOS: <a href="https://file.paperclover.net/shr/snowbound/latest/Snowbound-macos-aarch64.zip"><img src="docs/badges/macos-silicon.svg" alt="Silicon" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/Snowbound-macos-x86_64.zip"><img src="docs/badges/macos-intel.svg" alt="Intel" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/Snowbound-macos-10.6.zip"><img src="docs/badges/macos-legacy.svg" alt="OS X 10.6+" align="middle"></a> • Linux: <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-linux-x86_64"><img src="docs/badges/linux-x86_64.svg" alt="x86_64" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-linux-aarch64"><img src="docs/badges/linux-aarch64.svg" alt="aarch64" align="middle"></a> • Windows: <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-windows-x86_64.exe"><img src="docs/badges/windows-x64.svg" alt="x64" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-windows-aarch64.exe"><img src="docs/badges/windows-arm.svg" alt="Arm" align="middle"></a></p> | 14 | <p align="center"><b>Download</b>: macOS: <a href="https://file.paperclover.net/shr/snowbound/latest/Snowbound-macos-aarch64.zip"><img src="docs/badges/macos-silicon.svg" alt="Silicon" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/Snowbound-macos-x86_64.zip"><img src="docs/badges/macos-intel.svg" alt="Intel" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/Snowbound-macos-10.6.zip"><img src="docs/badges/macos-legacy.svg" alt="OS X 10.6+" align="middle"></a> • Linux: <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-linux-x86_64"><img src="docs/badges/linux-x86_64.svg" alt="x86_64" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-linux-aarch64"><img src="docs/badges/linux-aarch64.svg" alt="aarch64" align="middle"></a> • Windows: <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-windows-x86_64.exe"><img src="docs/badges/windows-x64.svg" alt="x64" align="middle"></a> <a href="https://file.paperclover.net/shr/snowbound/latest/snowbound-windows-aarch64.exe"><img src="docs/badges/windows-arm.svg" alt="Arm" align="middle"></a></p> |
| 15 | <p align="center">Each download has a <code>.minisig</code> beside it: <code>minisign -Vm FILE -P RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ</code></p> | ||
| 15 | 16 | ||
| 16 | <!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound): | 17 | <!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound): |
| 17 | python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && SNOWBOUND_SCREENSHOT_SYSTEM=snow-leopard target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --settings "$d/settings.json" --screenshot "$d/screenshot" && cp "$d"/screenshot-{light,dark}.png docs/ && oxipng -o 6 --strip all docs/screenshot-{light,dark}.png | 18 | python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && SNOWBOUND_SCREENSHOT_SYSTEM=snow-leopard target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --settings "$d/settings.json" --screenshot "$d/screenshot" && cp "$d"/screenshot-{light,dark}.png docs/ && oxipng -o 6 --strip all docs/screenshot-{light,dark}.png |
tools/RELEASE.md+39-7| ... | @@ -22,6 +22,7 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 | ... | @@ -22,6 +22,7 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 |
| 22 | 2026-09-29.r10/ | 22 | 2026-09-29.r10/ |
| 23 | build.json version, commit, changes, and per platform: file, size, sha256, signature | 23 | build.json version, commit, changes, and per platform: file, size, sha256, signature |
| 24 | build.json.sig ed25519 signature of build.json, hex | 24 | build.json.sig ed25519 signature of build.json, hex |
| 25 | build.json.minisig and a minisign signature beside every file but build.json.sig | ||
| 25 | Snowbound-2026-09-29-r10-macos-aarch64.zip | 26 | Snowbound-2026-09-29-r10-macos-aarch64.zip |
| 26 | Snowbound-2026-09-29-r10-macos-x86_64.zip | 27 | Snowbound-2026-09-29-r10-macos-x86_64.zip |
| 27 | Snowbound-2026-09-29-r10-macos-10.6.zip | 28 | Snowbound-2026-09-29-r10-macos-10.6.zip |
| ... | @@ -33,6 +34,10 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 | ... | @@ -33,6 +34,10 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64 |
| 33 | snowbound-2026-09-29-r10-linux-x86_64.debug.zip | 34 | snowbound-2026-09-29-r10-linux-x86_64.debug.zip |
| 34 | snowbound-2026-09-29-r10-windows-x86_64.debug.zip | 35 | snowbound-2026-09-29-r10-windows-x86_64.debug.zip |
| 35 | ... | 36 | ... |
| 37 | latest/ each platform's newest archive, the version dropped from its name | ||
| 38 | Snowbound-macos-aarch64.zip | ||
| 39 | Snowbound-macos-aarch64.zip.minisig | ||
| 40 | ... | ||
| 36 | ``` | 41 | ``` |
| 37 | 42 | ||
| 38 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into | 43 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into |
| ... | @@ -67,11 +72,31 @@ they don't know, and builds without `changes` read as listing none. | ... | @@ -67,11 +72,31 @@ they don't know, and builds without `changes` read as listing none. |
| 67 | 72 | ||
| 68 | Every `build.json` and archive is signed with the ed25519 release key in | 73 | Every `build.json` and archive is signed with the ed25519 release key in |
| 69 | `~/.config/snowbound/release-key` (PKCS#8, mode 600, never in the repository). | 74 | `~/.config/snowbound/release-key` (PKCS#8, mode 600, never in the repository). |
| 70 | Its public half is `crates/snowbound/release-key.pub`, compiled into the app. | 75 | Its public half is `minisign.pub` at the repository's root, in minisign's |
| 76 | format, which the app compiles in. | ||
| 71 | `cargo run -p snowbound --example release_sign -- KEY FILE...` prints | 77 | `cargo run -p snowbound --example release_sign -- KEY FILE...` prints |
| 72 | signatures and refuses a key that doesn't match `release-key.pub`; | 78 | signatures and refuses a key that doesn't match `minisign.pub`; |
| 73 | `release_sign new KEY` makes a new key. Replacing the key means shipping a | 79 | `release_sign new KEY` makes a new key and prints its `minisign.pub`. Replacing |
| 74 | build with the new public half, signed with the old key. | 80 | the key means shipping a build with the new public half, signed with the old |
| 81 | key. | ||
| 82 | |||
| 83 | Every published file but `build.json.sig` also gets `FILE.minisig`, which | ||
| 84 | [minisign](https://jedisct1.github.io/minisign/) (or `rsign verify`) checks: | ||
| 85 | |||
| 86 | ```sh | ||
| 87 | minisign -Vm Snowbound-macos-aarch64.zip -P RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ | ||
| 88 | ``` | ||
| 89 | |||
| 90 | minisign is Ed25519, so the release key makes these too, with no second key to | ||
| 91 | guard: `release.py` has `release_sign` sign each file's BLAKE2b-512, then that | ||
| 92 | signature followed by the trusted comment, as `minisign -S` does, and the key id | ||
| 93 | is the public key's first 8 bytes. Neither scheme's signature passes for the | ||
| 94 | other's: what minisign signs is a 64-byte hash, or a signature and a comment, | ||
| 95 | never a `build.json` or an archive with the hash `build.json` lists. | ||
| 96 | |||
| 97 | The app reads the archive's size and SHA-256 from the signed `build.json`. | ||
| 98 | Each archive's `signature` there, the release key's of its raw bytes, is for | ||
| 99 | apps that predate that, which check it as well. | ||
| 75 | 100 | ||
| 76 | The macOS app is signed with Clover's Developer ID Application certificate | 101 | The macOS app is signed with Clover's Developer ID Application certificate |
| 77 | (team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is | 102 | (team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is |
| ... | @@ -103,7 +128,14 @@ or with an app-specific password from account.apple.com: | ... | @@ -103,7 +128,14 @@ or with an app-specific password from account.apple.com: |
| 103 | `xcrun notarytool store-credentials snowbound --apple-id EMAIL --team-id 9R7DPNW28H --password APP-SPECIFIC-PASSWORD`. | 128 | `xcrun notarytool store-credentials snowbound --apple-id EMAIL --team-id 9R7DPNW28H --password APP-SPECIFIC-PASSWORD`. |
| 104 | Until the app is notarized, a download opened in Finder needs Open from its | 129 | Until the app is notarized, a download opened in Finder needs Open from its |
| 105 | context menu the first time; updates the app installs itself carry no | 130 | context menu the first time; updates the app installs itself carry no |
| 106 | quarantine and open directly. | 131 | quarantine and open directly. The zips carry their `.minisig` like every |
| 132 | download, which for the unsigned 10.6 app is the only signature. | ||
| 133 | |||
| 134 | Windows executables are unsigned, so SmartScreen warns on a download. | ||
| 135 | Authenticode would take a code signing certificate: Azure Trusted Signing at | ||
| 136 | about $10 a month, where it accepts an individual developer, or an OV | ||
| 137 | certificate at a few hundred dollars a year, now kept on a hardware token or | ||
| 138 | cloud HSM. `osslsigncode` or `jsign` would sign from the Mac. | ||
| 107 | 139 | ||
| 108 | ## Publishing | 140 | ## Publishing |
| 109 | 141 | ||
| ... | @@ -184,8 +216,8 @@ published build, it checks shortly after launch and then daily, skipping while | ... | @@ -184,8 +216,8 @@ published build, it checks shortly after launch and then daily, skipping while |
| 184 | Work Offline is on; Check for Updates… (the app menu on macOS, the command | 216 | Work Offline is on; Check for Updates… (the app menu on macOS, the command |
| 185 | palette elsewhere) checks at once and reports what it found. A check reads | 217 | palette elsewhere) checks at once and reports what it found. A check reads |
| 186 | `latest.json`, then the named build's `build.json` and signature, and | 218 | `latest.json`, then the named build's `build.json` and signature, and |
| 187 | downloads the archive for this platform, verifying size, SHA-256 and | 219 | downloads the archive for this platform, verifying its size and SHA-256 |
| 188 | signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at | 220 | against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at |
| 189 | its own version. It stages the update beside the install, so the swap is a rename: | 221 | its own version. It stages the update beside the install, so the swap is a rename: |
| 190 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the | 222 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the |
| 191 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on | 223 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on |
tools/release.py+33-5| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Builds, signs and publishes Snowbound for each desktop platform; see tools/RELEASE.md.""" | 2 | """Builds, signs and publishes Snowbound for each desktop platform; see tools/RELEASE.md.""" |
| 3 | import argparse | 3 | import argparse |
| 4 | import base64 | ||
| 4 | from datetime import datetime | 5 | from datetime import datetime |
| 5 | import hashlib | 6 | import hashlib |
| 6 | import json | 7 | import json |
| ... | @@ -11,6 +12,7 @@ import shutil | ... | @@ -11,6 +12,7 @@ import shutil |
| 11 | import subprocess | 12 | import subprocess |
| 12 | import sys | 13 | import sys |
| 13 | import tempfile | 14 | import tempfile |
| 15 | import time | ||
| 14 | import zipfile | 16 | import zipfile |
| 15 | from zoneinfo import ZoneInfo | 17 | from zoneinfo import ZoneInfo |
| 16 | 18 | ||
| ... | @@ -18,6 +20,8 @@ ROOT = Path(__file__).resolve().parents[1] | ... | @@ -18,6 +20,8 @@ ROOT = Path(__file__).resolve().parents[1] |
| 18 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') | 20 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') |
| 19 | URL = 'https://file.paperclover.net/shr/snowbound/' | 21 | URL = 'https://file.paperclover.net/shr/snowbound/' |
| 20 | KEY = Path.home() / '.config/snowbound/release-key' | 22 | KEY = Path.home() / '.config/snowbound/release-key' |
| 23 | # Its public half, which the app checks updates against. | ||
| 24 | MINISIGN = ROOT / 'minisign.pub' | ||
| 21 | ZONE = ZoneInfo('America/Los_Angeles') | 25 | ZONE = ZoneInfo('America/Los_Angeles') |
| 22 | PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', | 26 | PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', |
| 23 | 'windows-x86_64', 'windows-aarch64'] | 27 | 'windows-x86_64', 'windows-aarch64'] |
| ... | @@ -142,6 +146,26 @@ def sign(files): | ... | @@ -142,6 +146,26 @@ def sign(files): |
| 142 | return output.split() | 146 | return output.split() |
| 143 | 147 | ||
| 144 | 148 | ||
| 149 | def minisign(files): | ||
| 150 | """Writes FILE.minisig beside each of `files` as `minisign -S` would with the release key: | ||
| 151 | a signature of the file's BLAKE2b-512, then one of that and the trusted comment.""" | ||
| 152 | key_id = base64.b64decode(MINISIGN.read_text().splitlines()[1])[2:10] | ||
| 153 | comments = [f'timestamp:{int(time.time())}\tfile:{file.name}\thashed' for file in files] | ||
| 154 | with tempfile.TemporaryDirectory() as scratch: | ||
| 155 | def signed(messages): | ||
| 156 | paths = [Path(scratch) / str(index) for index in range(len(messages))] | ||
| 157 | for path, message in zip(paths, messages): | ||
| 158 | path.write_bytes(message) | ||
| 159 | return [bytes.fromhex(signature) for signature in sign(paths)] | ||
| 160 | signatures = signed([hashlib.blake2b(file.read_bytes()).digest() for file in files]) | ||
| 161 | global_signatures = signed([signature + comment.encode() for signature, comment in zip(signatures, comments)]) | ||
| 162 | for file, signature, comment, global_signature in zip(files, signatures, comments, global_signatures): | ||
| 163 | Path(f'{file}.minisig').write_text( | ||
| 164 | 'untrusted comment: signature from the Snowbound release key\n' | ||
| 165 | f'{base64.b64encode(b"ED" + key_id + signature).decode()}\n' | ||
| 166 | f'trusted comment: {comment}\n{base64.b64encode(global_signature).decode()}\n') | ||
| 167 | |||
| 168 | |||
| 145 | def split_debug(executable, debug): | 169 | def split_debug(executable, debug): |
| 146 | """Moves `executable`'s debug info to `debug`, which its debug link then names.""" | 170 | """Moves `executable`'s debug info to `debug`, which its debug link then names.""" |
| 147 | sysroot = subprocess.check_output(['rustc', '--print', 'sysroot'], text=True).strip() | 171 | sysroot = subprocess.check_output(['rustc', '--print', 'sysroot'], text=True).strip() |
| ... | @@ -285,15 +309,18 @@ def main(): | ... | @@ -285,15 +309,18 @@ def main(): |
| 285 | 'file': file.name, | 309 | 'file': file.name, |
| 286 | 'size': file.stat().st_size, | 310 | 'size': file.stat().st_size, |
| 287 | 'sha256': hashlib.sha256(file.read_bytes()).hexdigest(), | 311 | 'sha256': hashlib.sha256(file.read_bytes()).hexdigest(), |
| 312 | # Older apps check it; newer ones trust the sha256 build.json.sig vouches for. | ||
| 288 | 'signature': signature, | 313 | 'signature': signature, |
| 289 | } for (platform, file), signature in zip(files.items(), signatures)}, | 314 | } for (platform, file), signature in zip(files.items(), signatures)}, |
| 290 | } | 315 | } |
| 291 | (stage / 'build.json').write_text(json.dumps(build, indent=2) + '\n') | 316 | (stage / 'build.json').write_text(json.dumps(build, indent=2) + '\n') |
| 292 | (stage / 'build.json.sig').write_text(sign([stage / 'build.json'])[0] + '\n') | 317 | (stage / 'build.json.sig').write_text(sign([stage / 'build.json'])[0] + '\n') |
| 318 | downloads = [*files.values(), *symbols, stage / 'build.json'] | ||
| 319 | minisign(downloads) | ||
| 293 | partial = target.with_name(f'.{target.name}.partial') | 320 | partial = target.with_name(f'.{target.name}.partial') |
| 294 | shutil.rmtree(partial, ignore_errors=True) | 321 | shutil.rmtree(partial, ignore_errors=True) |
| 295 | partial.mkdir() | 322 | partial.mkdir() |
| 296 | for file in [*files.values(), *symbols, stage / 'build.json', stage / 'build.json.sig']: | 323 | for file in [*downloads, *(Path(f'{file}.minisig') for file in downloads), stage / 'build.json.sig']: |
| 297 | # copy() keeps the Linux executables executable for anyone running them off the share. | 324 | # copy() keeps the Linux executables executable for anyone running them off the share. |
| 298 | shutil.copy(file, partial / file.name) | 325 | shutil.copy(file, partial / file.name) |
| 299 | partial.rename(target) | 326 | partial.rename(target) |
| ... | @@ -316,10 +343,11 @@ def main(): | ... | @@ -316,10 +343,11 @@ def main(): |
| 316 | if newest_name != name(version): | 343 | if newest_name != name(version): |
| 317 | continue | 344 | continue |
| 318 | file = build['archives'][platform]['file'] | 345 | file = build['archives'][platform]['file'] |
| 319 | stable = downloads / file.replace(f'-{name(version)}', '') | 346 | for published_name in (file, f'{file}.minisig'): |
| 320 | partial = stable.with_name(f'.{stable.name}.partial') | 347 | stable = downloads / published_name.replace(f'-{name(version)}', '') |
| 321 | shutil.copy(target / file, partial) | 348 | partial = stable.with_name(f'.{stable.name}.partial') |
| 322 | os.replace(partial, stable) | 349 | shutil.copy(target / published_name, partial) |
| 350 | os.replace(partial, stable) | ||
| 323 | if not args.dry_run: | 351 | if not args.dry_run: |
| 324 | print(f'{URL}{folder(version)}/') | 352 | print(f'{URL}{folder(version)}/') |
| 325 | 353 |
tools/test_release.py+27| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import base64 | ||
| 1 | from datetime import datetime, timezone | 2 | from datetime import datetime, timezone |
| 3 | import hashlib | ||
| 2 | from pathlib import Path | 4 | from pathlib import Path |
| 3 | import runpy | 5 | import runpy |
| 4 | import tempfile | 6 | import tempfile |
| ... | @@ -52,6 +54,31 @@ class ReleaseTest(unittest.TestCase): | ... | @@ -52,6 +54,31 @@ class ReleaseTest(unittest.TestCase): |
| 52 | 'macos-10.6': '2026-09-29-r10'}) | 54 | 'macos-10.6': '2026-09-29-r10'}) |
| 53 | self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest) | 55 | self.assertEqual(release['newest'](latest, {'macos-aarch64': {}}, ('2026-09-29', 9)), latest) |
| 54 | 56 | ||
| 57 | def test_minisig_signs_the_files_blake2b_then_that_with_its_comment(self): | ||
| 58 | minisign, scope = release['minisign'], release['minisign'].__globals__ | ||
| 59 | messages = [] | ||
| 60 | |||
| 61 | def sign(paths): | ||
| 62 | messages.extend(Path(path).read_bytes() for path in paths) | ||
| 63 | return [bytes([len(messages)]).hex() * 64 for _ in paths] | ||
| 64 | |||
| 65 | real, scope['sign'] = scope['sign'], sign | ||
| 66 | try: | ||
| 67 | with tempfile.TemporaryDirectory() as folder: | ||
| 68 | file = Path(folder) / 'snowbound-linux-x86_64' | ||
| 69 | file.write_bytes(b'an executable') | ||
| 70 | minisign([file]) | ||
| 71 | untrusted, signature, trusted, global_signature = Path(f'{file}.minisig').read_text().splitlines() | ||
| 72 | finally: | ||
| 73 | scope['sign'] = real | ||
| 74 | key_id = base64.b64decode(release['MINISIGN'].read_text().splitlines()[1])[2:10] | ||
| 75 | self.assertTrue(untrusted.startswith('untrusted comment: ')) | ||
| 76 | self.assertEqual(base64.b64decode(signature), b'ED' + key_id + bytes([1]) * 64) | ||
| 77 | self.assertRegex(trusted, r'^trusted comment: timestamp:\d+\tfile:snowbound-linux-x86_64\thashed$') | ||
| 78 | self.assertEqual(base64.b64decode(global_signature), bytes([2]) * 64) | ||
| 79 | self.assertEqual(messages, [hashlib.blake2b(b'an executable').digest(), | ||
| 80 | bytes([1]) * 64 + trusted.removeprefix('trusted comment: ').encode()]) | ||
| 81 | |||
| 55 | def test_build_macos_signs_each_mac_app(self): | 82 | def test_build_macos_signs_each_mac_app(self): |
| 56 | build_mac, scope = release['build_mac'], release['build_mac'].__globals__ | 83 | build_mac, scope = release['build_mac'], release['build_mac'].__globals__ |
| 57 | commands = [] | 84 | commands = [] |