Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ jobs:

Or download \`Boost.zip\` below and drag it to Applications.

Boost is **not notarized** — it is signed with a local certificate, so
a direct download will be stopped by Gatekeeper the first time.
Already on Boost 1.5.0 or later? Choose Check for Updates in the Boost
menu and click Install.

Boost is **not notarized**, so a direct download will be stopped by
Gatekeeper the first time.
Homebrew handles this for you; if you downloaded the zip by hand,
right-click the app and choose *Open*.

Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ Explain the problem, not the diff. A reader can see what changed; what they
cannot see is what was wrong, why the obvious fix was wrong, and what
convinced you it works now.

## Releasing

Maintainers: see [docs/releasing.md](docs/releasing.md). Pushing a tag builds
the release; `Scripts/appcast.sh` then signs it and adds it to the update feed.

## Before opening a PR

```bash
Expand Down
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 21 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,28 @@ import PackageDescription
let package = Package(
name: "Boost",
platforms: [.macOS(.v14)],
dependencies: [
// In-app updates. Pinned to an exact version rather than a range: this
// code replaces the app on people's machines, so a new Sparkle should
// arrive as a deliberate bump someone read the changelog for, not as a
// side effect of resolving packages.
.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0"),
],
targets: [
.target(name: "BoostKit"),
.executableTarget(name: "boost", dependencies: ["BoostKit"]),
.target(name: "BoostKit", dependencies: [
.product(name: "Sparkle", package: "Sparkle"),
]),
.executableTarget(
name: "boost",
dependencies: ["BoostKit"],
// Sparkle ships as a framework, not a static library, so the binary
// has to be able to find it once build.sh copies it into
// Contents/Frameworks. SwiftPM only adds rpaths for its own build
// folder, which does not exist on anyone else's Mac.
linkerSettings: [
.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"]),
]
),
.testTarget(name: "BoostKitTests", dependencies: ["BoostKit"]),
]
)
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ It lives in your menu bar, shows what is really using your RAM, pauses apps you

If you looked at CleanMyMac, iStat Menus or AppCleaner and did not want a
subscription, Boost covers the parts most people actually use. No account, no
analytics, no network access of any kind.
analytics, and the only network request it makes is an update check you can
turn off.

## Install

Expand All @@ -46,13 +47,24 @@ cd boost && ./Scripts/build.sh
Needs macOS 14 or later. One universal binary for Apple Silicon and Intel. It
builds with the Command Line Tools, so Xcode is not required.

### Updating

From 1.5.0 on, Boost updates itself. Choose Check for Updates… in the Boost
menu, or let it check once a day (it asks the second time you open it). When a
new version is out you see what changed and click Install. There is nothing to
delete or download by hand, and this works the same if you installed with
Homebrew.

If you have 1.4.1 or older, update once the old way (`brew upgrade --cask boost`,
or download the new zip). After that the app does it.

<details>
<summary><b>About signing and Gatekeeper</b></summary>

<br>

Boost is signed with a local certificate, not notarized, because notarizing needs
a paid Apple Developer account. A downloaded `.zip` will be stopped by Gatekeeper
Boost is not notarized, because notarizing needs a paid Apple Developer
account. A downloaded `.zip` will be stopped by Gatekeeper
the first time. The Homebrew cask clears the quarantine flag for you, and building
from source never sets one, so use one of those two.

Expand Down Expand Up @@ -165,8 +177,12 @@ far enough, the kernel may kill something on its own.
| Login item | Starting Boost at login | Only if you turn it on |
| Admin password | `/usr/sbin/purge` | Only if you turn on the optional disk cache purge |

No analytics, no telemetry, no update check, no network access of any kind. There
is nothing to opt out of. See [SECURITY.md](SECURITY.md).
No analytics and no telemetry. The one network request Boost makes is the update
check: it downloads a small file listing the latest version from
`kernel-hunter.github.io` over HTTPS. It sends no identifiers, only what any
download sends (your IP address, and a User-Agent naming Boost's version). It
happens only after you say yes to automatic checks, or when you click Check for
Updates. Turn it off in Settings, under Updates. See [SECURITY.md](SECURITY.md).

</details>

Expand Down
45 changes: 37 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,32 @@ Expect a reply within a week. There is no bounty — this is a solo project.
| Accessibility (optional) | Count an app's open windows, for "close button quits the app" | Read-only, window counts only. Off unless you enable the feature |
| Admin password (optional) | `/usr/sbin/purge` | Off by default. See below |

It does **not** read window contents, keystrokes, files, or the network. It has
no analytics, no telemetry, no update check, and makes no outbound connections
of any kind. There is nothing to opt out of.
It does **not** read window contents, keystrokes or files. It has no analytics
and no telemetry. Its only outbound connection is the update check below.

## Update checks

Boost updates itself with [Sparkle](https://sparkle-project.org) 2, pinned to
an exact version in `Package.swift`.

- **What is sent.** One HTTPS GET for
`https://kernel-hunter.github.io/boost/appcast.xml`, a small file listing the
latest version. No identifiers, no system profile (`SUEnableSystemProfiling`
is never set). Like any request it carries your IP address and a User-Agent
with Boost's and Sparkle's version numbers. If you install an update, the zip
is downloaded from GitHub Releases.
- **When.** Never on the first launch. On the second launch Sparkle asks
whether to check automatically; say no and nothing is sent unless you click
Check for Updates. Say yes and it checks about once a day.
- **How to turn it off.** Settings, Updates, "Check for updates
automatically".
- **What gets installed.** Nothing without your click. Automatic installs are
disabled (`SUAllowsAutomaticUpdates` is off). Every update zip must carry an
EdDSA signature that matches the public key built into the app
(`SUPublicEDKey`), or Sparkle refuses it before replacing anything. The
private key never leaves the maintainer's Mac and is not stored in GitHub, so
a compromised GitHub account alone cannot ship an update. Details in
`docs/releasing.md`.

## The guard list

Expand Down Expand Up @@ -59,17 +82,23 @@ Turn the feature off and Boost never calls the API.

## Code signing

Release builds are signed and notarized. Builds you make yourself are signed
with a local self-signed certificate (see `docs/codesigning.md`) — which is why
a build of your own will ask for Accessibility access again the first time.
Release builds are ad-hoc signed by CI and are not notarized, because
notarizing needs a paid Apple Developer account. Builds you make yourself are
signed with a local self-signed certificate if you created one (see
`docs/codesigning.md`), which is why a build of your own will ask for
Accessibility access again the first time. Updates are verified with Sparkle's
EdDSA signature, not with Apple code signing, as described above.

Verify a release before trusting it:

```bash
codesign -dv --verbose=4 /Applications/Boost.app
spctl -a -vvv -t exec /Applications/Boost.app
shasum -a 256 Boost.zip # compare with the SHA-256 in the release notes
codesign --verify --deep --strict /Applications/Boost.app
```

`spctl` will reject a release build, because it is not notarized. That is
expected and is not a sign of tampering.

## Scope

In scope: privilege escalation, signalling processes outside the documented
Expand Down
177 changes: 177 additions & 0 deletions Scripts/appcast.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/bin/bash
# Adds a published release to the update feed, docs/appcast.xml.
# Usage: Scripts/appcast.sh <version> e.g. Scripts/appcast.sh 1.5.0
#
# Run it after the release workflow has published v<version>. It downloads
# that release's Boost.zip, checks it against the SHA-256 the workflow wrote
# into the release notes, signs it with the EdDSA key in your login Keychain,
# and adds (or replaces) the matching item in the feed. Then it commits.
# Pushing that commit is what makes existing installs see the update.
#
# Signing happens here, on your Mac, and not in CI on purpose: the private key
# would otherwise have to be a GitHub secret, and anyone who got it could ship
# an update to every Boost user.
#
# Needs: curl, python3, and Sparkle's sign_update. `swift build` downloads it
# into .build/artifacts; set SPARKLE_BIN to use another copy. The first run
# makes macOS ask whether sign_update may use the key; choose Always Allow.
# If the Keychain is not an option, SPARKLE_KEY_FILE=<exported key file>
# signs with that instead (and skips the public key check below, which needs
# the Keychain).
set -euo pipefail
cd "$(dirname "$0")/.."

VERSION="${1:-}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "usage: Scripts/appcast.sh <version> (for example 1.5.0)" >&2
exit 1
fi

REPO="Kernel-Hunter/boost"
TAG="v$VERSION"
FEED="docs/appcast.xml"
ZIP_URL="https://github.com/$REPO/releases/download/$TAG/Boost.zip"
NOTES_URL="https://github.com/$REPO/releases/tag/$TAG"

SPARKLE_BIN="${SPARKLE_BIN:-.build/artifacts/sparkle/Sparkle/bin}"
if [ ! -x "$SPARKLE_BIN/sign_update" ]; then
echo "sign_update not found in $SPARKLE_BIN. Run 'swift build' once, or set SPARKLE_BIN." >&2
exit 1
fi

# The feed edit goes into a commit, so start from a clean feed. A half-edited
# appcast.xml mixed into this commit is how a broken feed reaches users.
if ! git diff --quiet -- "$FEED" || ! git diff --cached --quiet -- "$FEED"; then
echo "$FEED has uncommitted changes. Commit or discard them first." >&2
exit 1
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

echo "reading release $TAG…"
curl -fsSL "https://api.github.com/repos/$REPO/releases/tags/$TAG" -o "$WORK/release.json" \
|| { echo "No published release $TAG on GitHub yet." >&2; exit 1; }

# The release notes carry a line "SHA-256: `<hash>`", written by release.yml
# from the zip it built. Checking against it means the file signed here is the
# one CI built, not whatever a redirect or a cache happened to hand back.
read -r EXPECTED PUBLISHED < <(python3 -I - "$WORK/release.json" <<'PY'
import json, re, sys
release = json.load(open(sys.argv[1]))
match = re.search(r"SHA-256:\s*`([0-9a-f]{64})`", release.get("body") or "")
print(match.group(1) if match else "-", release.get("published_at") or "-")
PY
)
if [ "$EXPECTED" = "-" ]; then
echo "The notes for $TAG have no SHA-256 line. Was it published by release.yml?" >&2
exit 1
fi

echo "downloading Boost.zip…"
curl -fsSL "$ZIP_URL" -o "$WORK/Boost.zip"
ACTUAL="$(shasum -a 256 "$WORK/Boost.zip" | cut -d' ' -f1)"
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Checksum mismatch for $ZIP_URL" >&2
echo " release notes: $EXPECTED" >&2
echo " downloaded: $ACTUAL" >&2
exit 1
fi
echo " SHA-256 matches the release notes"

# Two mistakes that would only show up on users' Macs, caught here instead:
# a zip whose app is a different version than its tag, and an app that
# carries a different public key than the one we are about to sign with
# (every install of it would then refuse the next update).
ditto -x -k "$WORK/Boost.zip" "$WORK/unzipped"
PLIST="$WORK/unzipped/Boost.app/Contents/Info.plist"
APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST")"
APP_KEY="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$PLIST" 2>/dev/null || true)"
if [ "$APP_VERSION" != "$VERSION" ]; then
echo "The app in the zip says $APP_VERSION, not $VERSION." >&2
exit 1
fi
if [ -n "${SPARKLE_KEY_FILE:-}" ]; then
KEY_ARGS=(--ed-key-file "$SPARKLE_KEY_FILE")
echo " signing with $SPARKLE_KEY_FILE; not checked against the app's key ($APP_KEY)"
else
KEY_ARGS=()
OUR_KEY="$("$SPARKLE_BIN/generate_keys" -p)"
if [ "$APP_KEY" != "$OUR_KEY" ]; then
echo "The app's SUPublicEDKey ($APP_KEY) is not the key in your Keychain ($OUR_KEY)." >&2
exit 1
fi
fi

# Prints: sparkle:edSignature="…" length="…"
echo "signing…"
if ! SIGNED="$("$SPARKLE_BIN/sign_update" ${KEY_ARGS[@]+"${KEY_ARGS[@]}"} "$WORK/Boost.zip" 2>&1)"; then
echo "sign_update failed: $SIGNED" >&2
exit 1
fi
SIGNATURE="$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' <<<"$SIGNED")"
LENGTH="$(sed -n 's/.*length="\([0-9]*\)".*/\1/p' <<<"$SIGNED")"
if [ -z "$SIGNATURE" ] || [ -z "$LENGTH" ]; then
echo "Could not read sign_update's output: $SIGNED" >&2
exit 1
fi

python3 -I - "$FEED" "$VERSION" "$PUBLISHED" "$ZIP_URL" "$LENGTH" "$SIGNATURE" "$NOTES_URL" <<'PY'
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from email.utils import format_datetime

feed, version, published, url, length, signature, notes = sys.argv[1:]
SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle"
ET.register_namespace("sparkle", SPARKLE)
s = lambda tag: f"{{{SPARKLE}}}{tag}"

tree = ET.parse(feed)
channel = tree.getroot().find("channel")

# Replacing rather than appending, so running this twice for one version
# (after a re-upload, say) leaves one item, not two that disagree.
for old in channel.findall("item"):
if old.findtext(s("version")) == version:
channel.remove(old)

when = datetime.now(timezone.utc) if published == "-" else \
datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)

item = ET.Element("item")
ET.SubElement(item, "title").text = f"Boost {version}"
ET.SubElement(item, "pubDate").text = format_datetime(when)
ET.SubElement(item, s("version")).text = version
ET.SubElement(item, s("shortVersionString")).text = version
ET.SubElement(item, s("minimumSystemVersion")).text = "14.0"
# Shown inside the update window. Links in it open in the browser.
ET.SubElement(item, "description").text = (
f'<p>See <a href="{notes}">what changed in Boost {version}</a> on GitHub.</p>')
# The "Version History" link Sparkle offers next to the notes.
ET.SubElement(item, s("fullReleaseNotesLink")).text = notes
ET.SubElement(item, "enclosure", {
"url": url,
"length": length,
"type": "application/octet-stream",
s("edSignature"): signature,
})

# Newest first, so the file reads like a changelog.
first = next((i for i, child in enumerate(channel) if child.tag == "item"), len(channel))
channel.insert(first, item)

ET.indent(tree, space=" ")
tree.write(feed, encoding="utf-8", xml_declaration=True)
with open(feed, "a") as f:
f.write("\n")
PY

if git diff --quiet -- "$FEED"; then
echo "Boost $VERSION is already in $FEED exactly as signed now. Nothing to commit."
exit 0
fi
git add "$FEED"
git commit -q -m "Add Boost $VERSION to the update feed" -- "$FEED"
echo "added Boost $VERSION to $FEED and committed it."
echo "Push it, and installed copies will offer the update on their next check."
Loading
Loading