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 = [
36723672 "accesskit_consumer",
36733673 "accesskit_winit",
36743674 "arboard",
3675 "base64",
36753676 "block2 0.5.1",
36763677 "canvas",
36773678 "draw",
crates/snowbound/Cargo.toml+2
......@@ -45,6 +45,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] }
4545rustls-native-certs = "0.8"
4646webpki-root-certs = "1.0"
4747ring = "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
4951# The browser: see arc/platforms.md.
5052[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.
44
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD;
57use ring::signature::{Ed25519KeyPair, KeyPair};
68use std::io::Write;
79
8const PUBLIC: &str = include_str!("../release-key.pub");
10const PUBLIC: &str = include_str!("../../../minisign.pub");
911
1012fn hex(bytes: &[u8]) -> String {
1113 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
1214}
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
1424fn main() -> Result<(), Box<dyn std::error::Error>> {
1525 let args: Vec<String> = std::env::args().skip(1).collect();
1626 match args.as_slice() {
......@@ -26,13 +36,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
2636 std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600);
2737 file.open(key)?.write_all(document.as_ref())?;
2838 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()));
3040 }
3141 [key, files @ ..] if !files.is_empty() => {
3242 let pair = Ed25519KeyPair::from_pkcs8(&std::fs::read(key)?)
3343 .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());
3646 }
3747 for file in files {
3848 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};
2828/// Where the builds are published.
2929const BASE: &str = "https://file.paperclover.net/shr/snowbound/";
3030
31/// The release key's public half, in hex.
32const KEY: &str = include_str!("../release-key.pub");
31/// The release key's public half, as `minisign -V` reads it.
32const KEY: &str = include_str!("../../../minisign.pub");
3333
3434/// The argument `relaunch` starts the old executable with to finish an update.
3535pub const FINISH: &str = "--finish-update";
......@@ -190,13 +190,13 @@ pub fn summary(changes: &[Change]) -> Option<String> {
190190 }
191191}
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.
193195#[derive(Clone, Debug, Deserialize)]
194196struct Archive {
195197 file: String,
196198 size: u64,
197199 sha256: String,
198 /// The release key's signature of the archive's bytes.
199 signature: String,
200200}
201201
202202fn unhex(text: &str) -> Option<Vec<u8>> {
......@@ -221,6 +221,15 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> {
221221#[cfg(target_arch = "wasm32")]
222222const 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
224233#[cfg(not(target_arch = "wasm32"))]
225234fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {
226235 let signature = unhex(signature).ok_or("The signature isn’t hex")?;
......@@ -278,7 +287,7 @@ fn archive(
278287 Ok((archive, changes))
279288}
280289
281fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> {
290fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> {
282291 if bytes.len() as u64 != archive.size {
283292 return Err(format!(
284293 "{} is {} bytes, not {}",
......@@ -294,7 +303,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri
294303 return Err(format!("{}’s SHA-256 doesn’t match", archive.file));
295304 }
296305 }
297 verify(key, bytes, &archive.signature)
306 Ok(())
298307}
299308
300309/// What an update replaces: the app bundle on macOS, the executable elsewhere. Development
......@@ -455,7 +464,7 @@ fn check(
455464 &format!("{}{}", version.folder(), archive.file),
456465 archive.size,
457466 )?;
458 check_archive(key, &archive, &bytes).map_err(unverified)?;
467 check_archive(&archive, &bytes).map_err(unverified)?;
459468 Ok(match stage(&bytes, &folder, &version) {
460469 Ok(item) => Status::Ready(version, item, changes),
461470 Err(error) => {
......@@ -532,15 +541,15 @@ impl Updates {
532541 automatic,
533542 ..Shared::default()
534543 }));
535 let key = unhex(KEY).expect("release-key.pub holds a key in hex");
536544 #[cfg(target_arch = "wasm32")]
537545 let thread = {
538 let _ = (key, proxy);
546 let _ = proxy;
539547 std::thread::current()
540548 };
541549 #[cfg(not(target_arch = "wasm32"))]
542550 let thread = {
543551 let shared = Arc::clone(&shared);
552 let key = release_key();
544553 std::thread::Builder::new()
545554 .name("updates".into())
546555 .spawn(move || {
......@@ -882,7 +891,6 @@ mod tests {
882891 "file": file,
883892 "size": bytes.len(),
884893 "sha256": hex(digest.as_ref()),
885 "signature": hex(pair.sign(bytes).as_ref()),
886894 }},
887895 }))
888896 .unwrap();
......@@ -892,6 +900,7 @@ mod tests {
892900
893901 #[test]
894902 fn signatures_and_hashes_are_checked() {
903 assert_eq!(release_key().len(), 32);
895904 let pair = generate();
896905 let key = pair.public_key().as_ref();
897906 let tenth = version("2026-09-29-r10");
......@@ -899,7 +908,7 @@ mod tests {
899908 let (build, signature) =
900909 publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes);
901910 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
904913 let mut tampered = build.clone();
905914 let at = tampered.iter().position(|&byte| byte == b'a').unwrap();
......@@ -931,25 +940,15 @@ mod tests {
931940 assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err());
932941
933942 assert!(
934 check_archive(key, &found, b"an archivf")
943 check_archive(&found, b"an archivf")
935944 .unwrap_err()
936945 .contains("SHA-256")
937946 );
938947 assert!(
939 check_archive(key, &found, b"an archive!")
948 check_archive(&found, b"an archive!")
940949 .unwrap_err()
941950 .contains("bytes")
942951 );
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 );
953952 }
954953
955954 fn change(kind: Kind, title: &str) -> Change {
......@@ -1236,7 +1235,7 @@ mod tests {
12361235 let old = version("2000-01-01-r1");
12371236 let status = check(
12381237 &download,
1239 &unhex(KEY).unwrap(),
1238 &release_key(),
12401239 Some(&old),
12411240 Some(&install),
12421241 &|_| {},
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,
1212multi-machine live collaboration, and much more.
1313
1414<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
1617<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):
1718python3 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
22222026-09-29.r10/
2323 build.json version, commit, changes, and per platform: file, size, sha256, signature
2424 build.json.sig ed25519 signature of build.json, hex
25 build.json.minisig and a minisign signature beside every file but build.json.sig
2526 Snowbound-2026-09-29-r10-macos-aarch64.zip
2627 Snowbound-2026-09-29-r10-macos-x86_64.zip
2728 Snowbound-2026-09-29-r10-macos-10.6.zip
......@@ -33,6 +34,10 @@ latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64
3334 snowbound-2026-09-29-r10-linux-x86_64.debug.zip
3435 snowbound-2026-09-29-r10-windows-x86_64.debug.zip
3536 ...
37latest/ each platform's newest archive, the version dropped from its name
38 Snowbound-macos-aarch64.zip
39 Snowbound-macos-aarch64.zip.minisig
40 ...
3641```
3742
3843A 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.
6772
6873Every `build.json` and archive is signed with the ed25519 release key in
6974`~/.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.
7177`cargo run -p snowbound --example release_sign -- KEY FILE...` prints
72signatures 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
74build with the new public half, signed with the old key.
78signatures 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
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
76101The macOS app is signed with Clover's Developer ID Application certificate
77102(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:
103128`xcrun notarytool store-credentials snowbound --apple-id EMAIL --team-id 9R7DPNW28H --password APP-SPECIFIC-PASSWORD`.
104129Until the app is notarized, a download opened in Finder needs Open from its
105130context 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
108140## Publishing
109141
......@@ -184,8 +216,8 @@ published build, it checks shortly after launch and then daily, skipping while
184216Work Offline is on; Check for Updates… (the app menu on macOS, the command
185217palette elsewhere) checks at once and reports what it found. A check reads
186218`latest.json`, then the named build's `build.json` and signature, and
187downloads the archive for this platform, verifying size, SHA-256 and
188signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
219downloads the archive for this platform, verifying its size and SHA-256
220against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
189221its own version. It stages the update beside the install, so the swap is a rename:
190222the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
191223executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
tools/release.py+33-5
......@@ -1,6 +1,7 @@
11#!/usr/bin/env python3
22"""Builds, signs and publishes Snowbound for each desktop platform; see tools/RELEASE.md."""
33import argparse
4import base64
45from datetime import datetime
56import hashlib
67import json
......@@ -11,6 +12,7 @@ import shutil
1112import subprocess
1213import sys
1314import tempfile
15import time
1416import zipfile
1517from zoneinfo import ZoneInfo
1618
......@@ -18,6 +20,8 @@ ROOT = Path(__file__).resolve().parents[1]
1820PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
1921URL = 'https://file.paperclover.net/shr/snowbound/'
2022KEY = Path.home() / '.config/snowbound/release-key'
23# Its public half, which the app checks updates against.
24MINISIGN = ROOT / 'minisign.pub'
2125ZONE = ZoneInfo('America/Los_Angeles')
2226PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64',
2327 'windows-x86_64', 'windows-aarch64']
......@@ -142,6 +146,26 @@ def sign(files):
142146 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
145169def split_debug(executable, debug):
146170 """Moves `executable`'s debug info to `debug`, which its debug link then names."""
147171 sysroot = subprocess.check_output(['rustc', '--print', 'sysroot'], text=True).strip()
......@@ -285,15 +309,18 @@ def main():
285309 'file': file.name,
286310 'size': file.stat().st_size,
287311 'sha256': hashlib.sha256(file.read_bytes()).hexdigest(),
312 # Older apps check it; newer ones trust the sha256 build.json.sig vouches for.
288313 'signature': signature,
289314 } for (platform, file), signature in zip(files.items(), signatures)},
290315 }
291316 (stage / 'build.json').write_text(json.dumps(build, indent=2) + '\n')
292317 (stage / 'build.json.sig').write_text(sign([stage / 'build.json'])[0] + '\n')
318 downloads = [*files.values(), *symbols, stage / 'build.json']
319 minisign(downloads)
293320 partial = target.with_name(f'.{target.name}.partial')
294321 shutil.rmtree(partial, ignore_errors=True)
295322 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']:
297324 # copy() keeps the Linux executables executable for anyone running them off the share.
298325 shutil.copy(file, partial / file.name)
299326 partial.rename(target)
......@@ -316,10 +343,11 @@ def main():
316343 if newest_name != name(version):
317344 continue
318345 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)
323351 if not args.dry_run:
324352 print(f'{URL}{folder(version)}/')
325353
tools/test_release.py+27
......@@ -1,4 +1,6 @@
1import base64
12from datetime import datetime, timezone
3import hashlib
24from pathlib import Path
35import runpy
46import tempfile
......@@ -52,6 +54,31 @@ class ReleaseTest(unittest.TestCase):
5254 'macos-10.6': '2026-09-29-r10'})
5355 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
5582 def test_build_macos_signs_each_mac_app(self):
5683 build_mac, scope = release['build_mac'], release['build_mac'].__globals__
5784 commands = []