| 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 | 3672 | "accesskit_consumer", |
| 3673 | 3673 | "accesskit_winit", |
| 3674 | 3674 | "arboard", |
| 3675 | "base64", | |
| 3675 | 3676 | "block2 0.5.1", |
| 3676 | 3677 | "canvas", |
| 3677 | 3678 | "draw", |
crates/snowbound/Cargo.toml+2| ... | ... | @@ -45,6 +45,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] } |
| 45 | 45 | rustls-native-certs = "0.8" |
| 46 | 46 | webpki-root-certs = "1.0" |
| 47 | 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 | 51 | # The browser: see arc/platforms.md. |
| 50 | 52 | [target.'cfg(target_arch = "wasm32")'.dependencies] |
crates/snowbound/examples/release_sign.rs+17-7| ... | ... | @@ -1,16 +1,26 @@ |
| 1 | //! Signs releases with the ed25519 key whose public half the app embeds | |
| 2 | //! (`release-key.pub`): `release_sign new KEY` makes a key, readable only by its owner; | |
| 3 | //! `release_sign KEY FILE...` prints each file's signature, in hex, one per line. | |
| 1 | //! Signs releases with the ed25519 key whose public half the app embeds (`minisign.pub`): | |
| 2 | //! `release_sign new KEY` makes a key, readable only by its owner, and prints its | |
| 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 | 7 | use ring::signature::{Ed25519KeyPair, KeyPair}; |
| 6 | 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 | 12 | fn hex(bytes: &[u8]) -> String { |
| 11 | 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 | 24 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 15 | 25 | let args: Vec<String> = std::env::args().skip(1).collect(); |
| 16 | 26 | match args.as_slice() { |
| ... | ... | @@ -26,13 +36,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 26 | 36 | std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600); |
| 27 | 37 | file.open(key)?.write_all(document.as_ref())?; |
| 28 | 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 | 41 | [key, files @ ..] if !files.is_empty() => { |
| 32 | 42 | let pair = Ed25519KeyPair::from_pkcs8(&std::fs::read(key)?) |
| 33 | 43 | .map_err(|_| format!("{key} is not an ed25519 key"))?; |
| 34 | if hex(pair.public_key().as_ref()) != PUBLIC.trim() { | |
| 35 | return Err(format!("{key} is not the key release-key.pub names").into()); | |
| 44 | if minisign(pair.public_key().as_ref()) != PUBLIC { | |
| 45 | return Err(format!("{key} is not the key minisign.pub names").into()); | |
| 36 | 46 | } |
| 37 | 47 | for file in files { |
| 38 | 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 | 28 | /// Where the builds are published. |
| 29 | 29 | const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; |
| 30 | 30 | |
| 31 | /// The release key's public half, in hex. | |
| 32 | const KEY: &str = include_str!("../release-key.pub"); | |
| 31 | /// The release key's public half, as `minisign -V` reads it. | |
| 32 | const KEY: &str = include_str!("../../../minisign.pub"); | |
| 33 | 33 | |
| 34 | 34 | /// The argument `relaunch` starts the old executable with to finish an update. |
| 35 | 35 | pub const FINISH: &str = "--finish-update"; |
| ... | ... | @@ -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 | 195 | #[derive(Clone, Debug, Deserialize)] |
| 194 | 196 | struct Archive { |
| 195 | 197 | file: String, |
| 196 | 198 | size: u64, |
| 197 | 199 | sha256: String, |
| 198 | /// The release key's signature of the archive's bytes. | |
| 199 | signature: String, | |
| 200 | 200 | } |
| 201 | 201 | |
| 202 | 202 | fn unhex(text: &str) -> Option<Vec<u8>> { |
| ... | ... | @@ -221,6 +221,15 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { |
| 221 | 221 | #[cfg(target_arch = "wasm32")] |
| 222 | 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 | 233 | #[cfg(not(target_arch = "wasm32"))] |
| 225 | 234 | fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { |
| 226 | 235 | let signature = unhex(signature).ok_or("The signature isn’t hex")?; |
| ... | ... | @@ -278,7 +287,7 @@ fn archive( |
| 278 | 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 | 291 | if bytes.len() as u64 != archive.size { |
| 283 | 292 | return Err(format!( |
| 284 | 293 | "{} is {} bytes, not {}", |
| ... | ... | @@ -294,7 +303,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri |
| 294 | 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 | 309 | /// What an update replaces: the app bundle on macOS, the executable elsewhere. Development |
| ... | ... | @@ -455,7 +464,7 @@ fn check( |
| 455 | 464 | &format!("{}{}", version.folder(), archive.file), |
| 456 | 465 | archive.size, |
| 457 | 466 | )?; |
| 458 | check_archive(key, &archive, &bytes).map_err(unverified)?; | |
| 467 | check_archive(&archive, &bytes).map_err(unverified)?; | |
| 459 | 468 | Ok(match stage(&bytes, &folder, &version) { |
| 460 | 469 | Ok(item) => Status::Ready(version, item, changes), |
| 461 | 470 | Err(error) => { |
| ... | ... | @@ -532,15 +541,15 @@ impl Updates { |
| 532 | 541 | automatic, |
| 533 | 542 | ..Shared::default() |
| 534 | 543 | })); |
| 535 | let key = unhex(KEY).expect("release-key.pub holds a key in hex"); | |
| 536 | 544 | #[cfg(target_arch = "wasm32")] |
| 537 | 545 | let thread = { |
| 538 | let _ = (key, proxy); | |
| 546 | let _ = proxy; | |
| 539 | 547 | std::thread::current() |
| 540 | 548 | }; |
| 541 | 549 | #[cfg(not(target_arch = "wasm32"))] |
| 542 | 550 | let thread = { |
| 543 | 551 | let shared = Arc::clone(&shared); |
| 552 | let key = release_key(); | |
| 544 | 553 | std::thread::Builder::new() |
| 545 | 554 | .name("updates".into()) |
| 546 | 555 | .spawn(move || { |
| ... | ... | @@ -882,7 +891,6 @@ mod tests { |
| 882 | 891 | "file": file, |
| 883 | 892 | "size": bytes.len(), |
| 884 | 893 | "sha256": hex(digest.as_ref()), |
| 885 | "signature": hex(pair.sign(bytes).as_ref()), | |
| 886 | 894 | }}, |
| 887 | 895 | })) |
| 888 | 896 | .unwrap(); |
| ... | ... | @@ -892,6 +900,7 @@ mod tests { |
| 892 | 900 | |
| 893 | 901 | #[test] |
| 894 | 902 | fn signatures_and_hashes_are_checked() { |
| 903 | assert_eq!(release_key().len(), 32); | |
| 895 | 904 | let pair = generate(); |
| 896 | 905 | let key = pair.public_key().as_ref(); |
| 897 | 906 | let tenth = version("2026-09-29-r10"); |
| ... | ... | @@ -899,7 +908,7 @@ mod tests { |
| 899 | 908 | let (build, signature) = |
| 900 | 909 | publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); |
| 901 | 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 | 913 | let mut tampered = build.clone(); |
| 905 | 914 | let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); |
| ... | ... | @@ -931,25 +940,15 @@ mod tests { |
| 931 | 940 | assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); |
| 932 | 941 | |
| 933 | 942 | assert!( |
| 934 | check_archive(key, &found, b"an archivf") | |
| 943 | check_archive(&found, b"an archivf") | |
| 935 | 944 | .unwrap_err() |
| 936 | 945 | .contains("SHA-256") |
| 937 | 946 | ); |
| 938 | 947 | assert!( |
| 939 | check_archive(key, &found, b"an archive!") | |
| 948 | check_archive(&found, b"an archive!") | |
| 940 | 949 | .unwrap_err() |
| 941 | 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 | 954 | fn change(kind: Kind, title: &str) -> Change { |
| ... | ... | @@ -1236,7 +1235,7 @@ mod tests { |
| 1236 | 1235 | let old = version("2000-01-01-r1"); |
| 1237 | 1236 | let status = check( |
| 1238 | 1237 | &download, |
| 1239 | &unhex(KEY).unwrap(), | |
| 1238 | &release_key(), | |
| 1240 | 1239 | Some(&old), |
| 1241 | 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 | 12 | multi-machine live collaboration, and much more. |
| 13 | 13 | |
| 14 | 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 | 17 | <!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound): |
| 17 | 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 | 22 | 2026-09-29.r10/ |
| 23 | 23 | build.json version, commit, changes, and per platform: file, size, sha256, signature |
| 24 | 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 | 26 | Snowbound-2026-09-29-r10-macos-aarch64.zip |
| 26 | 27 | Snowbound-2026-09-29-r10-macos-x86_64.zip |
| 27 | 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 | 34 | snowbound-2026-09-29-r10-linux-x86_64.debug.zip |
| 34 | 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 | 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 | 72 | |
| 68 | 73 | Every `build.json` and archive is signed with the ed25519 release key in |
| 69 | 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 | 77 | `cargo run -p snowbound --example release_sign -- KEY FILE...` prints |
| 72 | signatures and refuses a key that doesn't match `release-key.pub`; | |
| 73 | `release_sign new KEY` makes a new key. Replacing the key means shipping a | |
| 74 | build with the new public half, signed with the old key. | |
| 78 | signatures and refuses a key that doesn't match `minisign.pub`; | |
| 79 | `release_sign new KEY` makes a new key and prints its `minisign.pub`. Replacing | |
| 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 | 101 | The macOS app is signed with Clover's Developer ID Application certificate |
| 77 | 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 | 128 | `xcrun notarytool store-credentials snowbound --apple-id EMAIL --team-id 9R7DPNW28H --password APP-SPECIFIC-PASSWORD`. |
| 104 | 129 | Until the app is notarized, a download opened in Finder needs Open from its |
| 105 | 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 | 140 | ## Publishing |
| 109 | 141 | |
| ... | ... | @@ -184,8 +216,8 @@ published build, it checks shortly after launch and then daily, skipping while |
| 184 | 216 | Work Offline is on; Check for Updates… (the app menu on macOS, the command |
| 185 | 217 | palette elsewhere) checks at once and reports what it found. A check reads |
| 186 | 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 | |
| 188 | signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at | |
| 219 | downloads the archive for this platform, verifying its size and SHA-256 | |
| 220 | against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at | |
| 189 | 221 | its own version. It stages the update beside the install, so the swap is a rename: |
| 190 | 222 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the |
| 191 | 223 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on |
tools/release.py+33-5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """Builds, signs and publishes Snowbound for each desktop platform; see tools/RELEASE.md.""" |
| 3 | 3 | import argparse |
| 4 | import base64 | |
| 4 | 5 | from datetime import datetime |
| 5 | 6 | import hashlib |
| 6 | 7 | import json |
| ... | ... | @@ -11,6 +12,7 @@ import shutil |
| 11 | 12 | import subprocess |
| 12 | 13 | import sys |
| 13 | 14 | import tempfile |
| 15 | import time | |
| 14 | 16 | import zipfile |
| 15 | 17 | from zoneinfo import ZoneInfo |
| 16 | 18 | |
| ... | ... | @@ -18,6 +20,8 @@ ROOT = Path(__file__).resolve().parents[1] |
| 18 | 20 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') |
| 19 | 21 | URL = 'https://file.paperclover.net/shr/snowbound/' |
| 20 | 22 | KEY = Path.home() / '.config/snowbound/release-key' |
| 23 | # Its public half, which the app checks updates against. | |
| 24 | MINISIGN = ROOT / 'minisign.pub' | |
| 21 | 25 | ZONE = ZoneInfo('America/Los_Angeles') |
| 22 | 26 | PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', |
| 23 | 27 | 'windows-x86_64', 'windows-aarch64'] |
| ... | ... | @@ -142,6 +146,26 @@ def sign(files): |
| 142 | 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 | 169 | def split_debug(executable, debug): |
| 146 | 170 | """Moves `executable`'s debug info to `debug`, which its debug link then names.""" |
| 147 | 171 | sysroot = subprocess.check_output(['rustc', '--print', 'sysroot'], text=True).strip() |
| ... | ... | @@ -285,15 +309,18 @@ def main(): |
| 285 | 309 | 'file': file.name, |
| 286 | 310 | 'size': file.stat().st_size, |
| 287 | 311 | 'sha256': hashlib.sha256(file.read_bytes()).hexdigest(), |
| 312 | # Older apps check it; newer ones trust the sha256 build.json.sig vouches for. | |
| 288 | 313 | 'signature': signature, |
| 289 | 314 | } for (platform, file), signature in zip(files.items(), signatures)}, |
| 290 | 315 | } |
| 291 | 316 | (stage / 'build.json').write_text(json.dumps(build, indent=2) + '\n') |
| 292 | 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 | 320 | partial = target.with_name(f'.{target.name}.partial') |
| 294 | 321 | shutil.rmtree(partial, ignore_errors=True) |
| 295 | 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 | 324 | # copy() keeps the Linux executables executable for anyone running them off the share. |
| 298 | 325 | shutil.copy(file, partial / file.name) |
| 299 | 326 | partial.rename(target) |
| ... | ... | @@ -316,10 +343,11 @@ def main(): |
| 316 | 343 | if newest_name != name(version): |
| 317 | 344 | continue |
| 318 | 345 | file = build['archives'][platform]['file'] |
| 319 | stable = downloads / file.replace(f'-{name(version)}', '') | |
| 320 | partial = stable.with_name(f'.{stable.name}.partial') | |
| 321 | shutil.copy(target / file, partial) | |
| 322 | os.replace(partial, stable) | |
| 346 | for published_name in (file, f'{file}.minisig'): | |
| 347 | stable = downloads / published_name.replace(f'-{name(version)}', '') | |
| 348 | partial = stable.with_name(f'.{stable.name}.partial') | |
| 349 | shutil.copy(target / published_name, partial) | |
| 350 | os.replace(partial, stable) | |
| 323 | 351 | if not args.dry_run: |
| 324 | 352 | print(f'{URL}{folder(version)}/') |
| 325 | 353 |
tools/test_release.py+27| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | import base64 | |
| 1 | 2 | from datetime import datetime, timezone |
| 3 | import hashlib | |
| 2 | 4 | from pathlib import Path |
| 3 | 5 | import runpy |
| 4 | 6 | import tempfile |
| ... | ... | @@ -52,6 +54,31 @@ class ReleaseTest(unittest.TestCase): |
| 52 | 54 | 'macos-10.6': '2026-09-29-r10'}) |
| 53 | 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 | 82 | def test_build_macos_signs_each_mac_app(self): |
| 56 | 83 | build_mac, scope = release['build_mac'], release['build_mac'].__globals__ |
| 57 | 84 | commands = [] |