authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 11:17:09-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 11:57:55-07:00
log0a185b6b6e414ab455bb8e02d0137bc7b0129f5b
treeb300f2266d850775b7a8b5999182c244a13755b9
parent9b22f115f0b0570cfc78ce223c6a325b1714939b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: minisign signatures on every release download

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.5

10 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"] }
45rustls-native-certs = "0.8"45rustls-native-certs = "0.8"
46webpki-root-certs = "1.0"46webpki-root-certs = "1.0"
47ring = "0.17"47ring = "0.17"
48# Reads minisign.pub, the release key the updates are checked against.
49base64 = { version = "0.23.1", default-features = false, features = ["std"] }
4850
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 embeds1//! 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.
44
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD;
5use ring::signature::{Ed25519KeyPair, KeyPair};7use ring::signature::{Ed25519KeyPair, KeyPair};
6use std::io::Write;8use std::io::Write;
79
8const PUBLIC: &str = include_str!("../release-key.pub");10const PUBLIC: &str = include_str!("../../../minisign.pub");
911
10fn hex(bytes: &[u8]) -> String {12fn hex(bytes: &[u8]) -> String {
11 bytes.iter().map(|byte| format!("{byte:02x}")).collect()13 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
12}14}
1315
16/// `public` as a minisign public key, its key id the key's first 8 bytes.
17fn 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
14fn main() -> Result<(), Box<dyn std::error::Error>> {24fn 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 @@
15af6766e8e2204ee52f329af4f5233be3b06419c1df0f8c7d82112b31d346b09
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.
29const BASE: &str = "https://file.paperclover.net/shr/snowbound/";29const BASE: &str = "https://file.paperclover.net/shr/snowbound/";
3030
31/// The release key's public half, in hex.31/// The release key's public half, as `minisign -V` reads it.
32const KEY: &str = include_str!("../release-key.pub");32const KEY: &str = include_str!("../../../minisign.pub");
3333
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.
35pub const FINISH: &str = "--finish-update";35pub 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}
192192
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)]
194struct Archive {196struct 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}
201201
202fn unhex(text: &str) -> Option<Vec<u8>> {202fn 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")]
222const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens.";222const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens.";
223223
224/// `KEY`'s ed25519 public key, after minisign's algorithm and key id.
225#[cfg(not(target_arch = "wasm32"))]
226fn 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"))]
225fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {234fn 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}
280289
281fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> {290fn 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}
299308
300/// What an update replaces: the app bundle on macOS, the executable elsewhere. Development309/// 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 {
892900
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();
903912
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());
932941
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 }
954953
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 @@
1untrusted comment: minisign public key EE04228E6E76F65A
2RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ
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,
12multi-machine live collaboration, and much more.12multi-machine live collaboration, and much more.
1313
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>
1516
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):
17python3 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}.png18python3 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
222026-09-29.r10/222026-09-29.r10/
23 build.json version, commit, changes, and per platform: file, size, sha256, signature23 build.json version, commit, changes, and per platform: file, size, sha256, signature
24 build.json.sig ed25519 signature of build.json, hex24 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.zip26 Snowbound-2026-09-29-r10-macos-aarch64.zip
26 Snowbound-2026-09-29-r10-macos-x86_64.zip27 Snowbound-2026-09-29-r10-macos-x86_64.zip
27 Snowbound-2026-09-29-r10-macos-10.6.zip28 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.zip34 snowbound-2026-09-29-r10-linux-x86_64.debug.zip
34 snowbound-2026-09-29-r10-windows-x86_64.debug.zip35 snowbound-2026-09-29-r10-windows-x86_64.debug.zip
35 ...36 ...
37latest/ 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```
3742
38A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into43A 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.
6772
68Every `build.json` and archive is signed with the ed25519 release key in73Every `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).
70Its public half is `crates/snowbound/release-key.pub`, compiled into the app.75Its public half is `minisign.pub` at the repository's root, in minisign's
76format, which the app compiles in.
71`cargo run -p snowbound --example release_sign -- KEY FILE...` prints77`cargo run -p snowbound --example release_sign -- KEY FILE...` prints
72signatures and refuses a key that doesn't match `release-key.pub`;78signatures and refuses a key that doesn't match `minisign.pub`;
73`release_sign new KEY` makes a new key. Replacing the key means shipping a79`release_sign new KEY` makes a new key and prints its `minisign.pub`. Replacing
74build with the new public half, signed with the old key.80the key means shipping a build with the new public half, signed with the old
81key.
82
83Every 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
87minisign -Vm Snowbound-macos-aarch64.zip -P RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ
88```
89
90minisign is Ed25519, so the release key makes these too, with no second key to
91guard: `release.py` has `release_sign` sign each file's BLAKE2b-512, then that
92signature followed by the trusted comment, as `minisign -S` does, and the key id
93is the public key's first 8 bytes. Neither scheme's signature passes for the
94other's: what minisign signs is a 64-byte hash, or a signature and a comment,
95never a `build.json` or an archive with the hash `build.json` lists.
96
97The app reads the archive's size and SHA-256 from the signed `build.json`.
98Each archive's `signature` there, the release key's of its raw bytes, is for
99apps that predate that, which check it as well.
75100
76The macOS app is signed with Clover's Developer ID Application certificate101The 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 is102(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`.
104Until the app is notarized, a download opened in Finder needs Open from its129Until the app is notarized, a download opened in Finder needs Open from its
105context menu the first time; updates the app installs itself carry no130context menu the first time; updates the app installs itself carry no
106quarantine and open directly.131quarantine and open directly. The zips carry their `.minisig` like every
132download, which for the unsigned 10.6 app is the only signature.
133
134Windows executables are unsigned, so SmartScreen warns on a download.
135Authenticode would take a code signing certificate: Azure Trusted Signing at
136about $10 a month, where it accepts an individual developer, or an OV
137certificate at a few hundred dollars a year, now kept on a hardware token or
138cloud HSM. `osslsigncode` or `jsign` would sign from the Mac.
107139
108## Publishing140## Publishing
109141
...@@ -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
184Work Offline is on; Check for Updates… (the app menu on macOS, the command216Work Offline is on; Check for Updates… (the app menu on macOS, the command
185palette elsewhere) checks at once and reports what it found. A check reads217palette elsewhere) checks at once and reports what it found. A check reads
186`latest.json`, then the named build's `build.json` and signature, and218`latest.json`, then the named build's `build.json` and signature, and
187downloads the archive for this platform, verifying size, SHA-256 and219downloads the archive for this platform, verifying its size and SHA-256
188signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at220against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
189its own version. It stages the update beside the install, so the swap is a rename:221its own version. It stages the update beside the install, so the swap is a rename:
190the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the222the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
191executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on223executable 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 python31#!/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."""
3import argparse3import argparse
4import base64
4from datetime import datetime5from datetime import datetime
5import hashlib6import hashlib
6import json7import json
...@@ -11,6 +12,7 @@ import shutil...@@ -11,6 +12,7 @@ import shutil
11import subprocess12import subprocess
12import sys13import sys
13import tempfile14import tempfile
15import time
14import zipfile16import zipfile
15from zoneinfo import ZoneInfo17from zoneinfo import ZoneInfo
1618
...@@ -18,6 +20,8 @@ ROOT = Path(__file__).resolve().parents[1]...@@ -18,6 +20,8 @@ ROOT = Path(__file__).resolve().parents[1]
18PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')20PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
19URL = 'https://file.paperclover.net/shr/snowbound/'21URL = 'https://file.paperclover.net/shr/snowbound/'
20KEY = Path.home() / '.config/snowbound/release-key'22KEY = Path.home() / '.config/snowbound/release-key'
23# Its public half, which the app checks updates against.
24MINISIGN = ROOT / 'minisign.pub'
21ZONE = ZoneInfo('America/Los_Angeles')25ZONE = ZoneInfo('America/Los_Angeles')
22PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64',26PLATFORMS = ['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()
143147
144148
149def 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
145def split_debug(executable, debug):169def 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 continue344 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)}/')
325353
tools/test_release.py+27
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import base64
1from datetime import datetime, timezone2from datetime import datetime, timezone
3import hashlib
2from pathlib import Path4from pathlib import Path
3import runpy5import runpy
4import tempfile6import 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)
5456
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 = []