Redline updater hardening: signed-update verification, atomic install + revert, update visibility, notarization pipeline (MMDB-2681) #24

Merged
kua-agent merged 7 commits from feat/updater-hardening-20260905 into main 2026-09-05 06:07:06 +00:00
Member

What changed and why

Ben (2026-09-05): "ok do all and deploy" on the six updater recommendations. Backlog MMDB-2681.

  1. Signature verification of the staged update (UpdateChecker.verifySignature): Security framework strict validation (kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode), bundle id must be ai.flowmaster.shotdeck, team id must be PWMCBMX5M8 or L3N9S54CN3 (env REDLINE_ALLOWED_TEAMS for the self-test only). Previously only the SHA256 from the same host was checked.
  2. Atomic install + rollback: copy into itemReplacementDirectory, replaceItemAt with Redline.app.previous kept as the one rollback copy, installed target re-verified. New revertToPrevious.
  3. Update visibility: badge on the menu-bar icon while an update is staged; rows "Check for updates" / "Checking…", "Revert to "; footer "Redline " with the status line.
  4. Timeouts: 30 s request / 600 s resource (was 15 s both).
  5. Relaunch handoff: detached shell waits for the old pid to exit before open -n; no overlapping instances.
  6. publish-update.sh: resolves a real signing identity (REDLINE_SIGN_IDENTITY → first Developer ID Application → Apple Development with a loud warning), --options runtime --timestamp, optional notarytool submit + staple + DMG rebuild from the stapled app, spctl gate, notarized + teamIdentifier written into appcast.json.

Gates run (real output)

Signed build via scripts/build-app.sh, then the full self-test chain, run by the builder and re-run independently by the coordinator:

REDLINE_ALLOWED_TEAMS=PWMCBMX5M8 SHOTDECK_PICKER_SELFTEST=<tmp>/picker SHOTDECK_UPDATE_SELFTEST=<tmp>/update .build/Redline.app/Contents/MacOS/Shotdeck
PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0)
REGION-PERSIST PASS
SEND-TRUTH PASS
UPDATE-SELFTEST reject-unsigned PASS
UPDATE-SELFTEST staged-signed PASS
UPDATE-SELFTEST atomic-install PASS
UPDATE-SELFTEST revert PASS
UPDATE-SELFTEST PASS version=99.0.0
exit=0

swift build -c release clean; swift test 99/99 ShotdeckCoreTests pass; bash -n scripts/publish-update.sh OK; nm -u on the binary shows no UNUserNotificationCenter / NSAppleScript / AXUIElement symbols (no permission prompts).

Runtime-verified

  • Self-test installs into a temp target only; /Applications/Redline.app confirmed untouched (still 0.2.0) after the run.
  • Unsigned (plist-edited) bundle rejected; re-signed bundle staged; atomic install leaves Redline.app.previous; revert swaps back.

NOT verified

  • Notarization path (notarytool submit / staple / re-DMG) not executed end to end: no Developer ID certificate exists yet (Apple API refused to issue one: Account Holder only). Release 0.3.0 ships Apple Development signed, NOT NOTARIZED printed.
  • Menu rows, icon badge and the real relaunch handoff not driven in the GUI by an agent (macOS menu-bar UI, no headless lane). Ben-GUI-only.
  • Conflicts: open PR #23 (OneDrive transport) touches AppModel.swift, MenuBarView.swift, PickerSelfTest.swift, main.swift and will need a rebase after this merges.

🤖 Generated with Claude Code

## What changed and why Ben (2026-09-05): "ok do all and deploy" on the six updater recommendations. Backlog MMDB-2681. 1. **Signature verification of the staged update** (`UpdateChecker.verifySignature`): Security framework strict validation (`kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode`), bundle id must be `ai.flowmaster.shotdeck`, team id must be `PWMCBMX5M8` or `L3N9S54CN3` (env `REDLINE_ALLOWED_TEAMS` for the self-test only). Previously only the SHA256 from the same host was checked. 2. **Atomic install + rollback**: copy into `itemReplacementDirectory`, `replaceItemAt` with `Redline.app.previous` kept as the one rollback copy, installed target re-verified. New `revertToPrevious`. 3. **Update visibility**: badge on the menu-bar icon while an update is staged; rows "Check for updates" / "Checking…", "Revert to <previous>"; footer "Redline <version>" with the status line. 4. **Timeouts**: 30 s request / 600 s resource (was 15 s both). 5. **Relaunch handoff**: detached shell waits for the old pid to exit before `open -n`; no overlapping instances. 6. **publish-update.sh**: resolves a real signing identity (`REDLINE_SIGN_IDENTITY` → first Developer ID Application → Apple Development with a loud warning), `--options runtime --timestamp`, optional notarytool submit + staple + DMG rebuild from the stapled app, spctl gate, `notarized` + `teamIdentifier` written into appcast.json. ## Gates run (real output) Signed build via `scripts/build-app.sh`, then the full self-test chain, run by the builder and re-run independently by the coordinator: ``` REDLINE_ALLOWED_TEAMS=PWMCBMX5M8 SHOTDECK_PICKER_SELFTEST=<tmp>/picker SHOTDECK_UPDATE_SELFTEST=<tmp>/update .build/Redline.app/Contents/MacOS/Shotdeck PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0) REGION-PERSIST PASS SEND-TRUTH PASS UPDATE-SELFTEST reject-unsigned PASS UPDATE-SELFTEST staged-signed PASS UPDATE-SELFTEST atomic-install PASS UPDATE-SELFTEST revert PASS UPDATE-SELFTEST PASS version=99.0.0 exit=0 ``` `swift build -c release` clean; `swift test` 99/99 ShotdeckCoreTests pass; `bash -n scripts/publish-update.sh` OK; `nm -u` on the binary shows no UNUserNotificationCenter / NSAppleScript / AXUIElement symbols (no permission prompts). ## Runtime-verified - Self-test installs into a temp target only; `/Applications/Redline.app` confirmed untouched (still 0.2.0) after the run. - Unsigned (plist-edited) bundle rejected; re-signed bundle staged; atomic install leaves `Redline.app.previous`; revert swaps back. ## NOT verified - Notarization path (notarytool submit / staple / re-DMG) not executed end to end: no Developer ID certificate exists yet (Apple API refused to issue one: Account Holder only). Release 0.3.0 ships Apple Development signed, `NOT NOTARIZED` printed. - Menu rows, icon badge and the real relaunch handoff not driven in the GUI by an agent (macOS menu-bar UI, no headless lane). Ben-GUI-only. - Conflicts: open PR #23 (OneDrive transport) touches AppModel.swift, MenuBarView.swift, PickerSelfTest.swift, main.swift and will need a rebase after this merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
kua-agent added 4 commits 2026-09-05 06:03:26 +00:00
30s/600s URLSession timeouts on the update session (was 15s/15s, too tight for
a real zip download). Every staged and installed payload is now verified with
the Security framework (SecStaticCodeCheckValidityWithErrors, strict + all
architectures + nested code) against bundle id ai.flowmaster.shotdeck and an
allowed-team set (PWMCBMX5M8, L3N9S54CN3; overridable via REDLINE_ALLOWED_TEAMS
for the self-test only) — an unsigned or wrongly-signed update is discarded
before checkNow ever offers it, and installStaged re-verifies what actually
landed on disk as defense in depth.

installStaged is now atomic: ditto into an itemReplacementDirectory, then
FileManager.replaceItemAt swaps it into place, keeping exactly one
Redline.app.previous rollback copy (older ones are dropped first). Added
revertToPrevious(target:) to swap that copy back in (itself reversible — the
replaced version becomes the new .previous), and previousVersion(target:) to
read its CFBundleShortVersionString. Relaunch is now a detached
"wait for this PID to exit, then open -n" shell handoff instead of a
synchronous open+terminate, so there is never a moment with two instances
running. checkNow(manual:) now says "Redline X is up to date." when the user
asked directly, and exposes isCheckingNow/lastCheckedAt for the UI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AppModel exposes appVersion, previousVersion, isCheckingForUpdates and
updateStatusMessage (an alias for the existing statusLine plumbing — one
status channel, not a new one), plus checkForUpdates() and
revertToPreviousVersion() wired to the hardened UpdateChecker.

Menu gains, in order: "Update to X" (unchanged, staged-only), "Check for
updates" (labelled "Checking…" and disabled mid-check), "Revert to <version>"
(only when a rollback copy exists), then the existing rows unchanged, then a
non-interactive footer "Redline <version>" with the status line under it —
same caption/secondary styles already used elsewhere in the file, no new
tokens.

Menu-bar icon gets a small badge while an update is staged: uses the SF
Symbol's own ".badge" variant when one exists for the current icon, otherwise
overlays a small dot on the plain symbol. Reads live model state, so the
badge disappears on its own once the offer clears.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same fake-99.0.0-bundle setup as before, but now drives it through the
hardened UpdateChecker end to end, in-process:

(a) reject-unsigned — the fake bundle, copied then plist-edited without
    re-signing (editing Info.plist after copy invalidates the inherited
    signature on its own — nothing stripped by hand), must be rejected by
    checkNow(): updateAvailable stays nil and statusMessage is the exact
    "Update is not signed by MMD" text.
(b) staged-signed — codesign --force --deep --sign the same bundle
    (SHOTDECK_SELFTEST_SIGN_IDENTITY or the default Apple Development
    identity), re-zip, re-serve the same appcast path; must now stage.
(c) atomic-install — installStaged into a throwaway <tmp>/Applications
    (never real /Applications) pre-populated with a copy of the actually
    running app; asserts the target lands on 99.0.0, Redline.app.previous
    holds the original version, and no replacement-directory cruft is left
    beside them.
(d) revert — revertToPrevious swaps the rollback copy back in; asserts the
    target is back to the original version and .previous now holds 99.0.0.

Caught a real bug while wiring (d): replaceItemAt(target, withItemAt:
previousURL, backupItemName: "Redline.app.previous") self-clobbers, because
the backup name and the withItemAt source resolve to the same path — the
backup write lands before the swap ever reads it, so target ends up
unchanged. Fixed in UpdateChecker by staging previousURL through a throwaway
ditto copy first (same pattern installStaged already used).

Every existing phase (PICKER-SELFTEST, REGION-PERSIST, SEND-TRUTH, and the
final "UPDATE-SELFTEST PASS version=99.0.0") is unchanged and still prints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SIGN_IDENTITY now comes from REDLINE_SIGN_IDENTITY, else the first
"Developer ID Application" identity in the keychain (REDLINE_KEYCHAIN adds
--keychain everywhere it's searched/used), else the existing Apple
Development identity with a loud WARNING that Gatekeeper will block first
install elsewhere. build-app.sh still has to sign first with its own
hardcoded Apple Development identity (that pair is what keeps the Screen
Recording grant alive) — this script now re-signs the resulting bundle with
the resolved identity, --options runtime --timestamp, before zipping.

Notarization is optional: set REDLINE_NOTARY_PROFILE (a notarytool
keychain profile) or all three of REDLINE_NOTARY_KEY_ID/_ISSUER/_KEY_PATH,
and after the zip is built the script submits it, waits, and on Accepted
staples Redline.app, rebuilds the zip and DMG from the stapled app (a new
build_dmg() that ditto-copies whatever is already at .build/Redline.app
rather than re-invoking make-dmg.sh, which would rebuild from source and
strip both the resolved signature and the staple), staples the DMG, and
requires `spctl -a -vv -t exec` to say "accepted" or the script aborts.
Absent notary config: prints NOT NOTARIZED and continues exactly as before.

appcast.json gains "notarized" and "teamIdentifier" (from codesign -dv);
confirmed UpdateChecker's Appcast Decodable already ignores unknown JSON
keys (verified with a standalone decode), so no app-side change was needed
for old appcasts to keep working. Ends with a summary block: identity used,
notarized yes/no, spctl verdict, team, sha256. --test dry-run behaviour
(upload to .../test/, skip the git commit) is unchanged.

Known gap, out of scope here: build-app.sh's own pre-sign step still hard-
requires its hardcoded Apple Development identity in the keychain even when
a Developer ID identity is what will actually ship — untouched per the task
boundary (this file only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kua-agent added 1 commit 2026-09-05 06:03:44 +00:00
Un-notarized fallback builds (Apple Development identity) are rejected by
spctl by design; the script must still publish them with a warning, otherwise
the fallback path can never release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
kua-agent added 1 commit 2026-09-05 06:03:51 +00:00
kua-agent added 1 commit 2026-09-05 06:07:00 +00:00
kua-agent merged commit 12128b016d into main 2026-09-05 06:07:06 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mmd-it/shotdeck#24