Give the desktop a macOS head, signed from the first release
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s

The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
This commit is contained in:
2026-08-10 10:43:28 +02:00
parent e936ab4646
commit 890a5f2246
17 changed files with 2219 additions and 39 deletions
+61 -3
View File
@@ -3,8 +3,18 @@
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
have already cost time once. Development is Windows-first, but **the full test suite now runs on
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
in question and must not be assumed to work.
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
assumed to work.
**macOS now builds and packages, and has still never run.** The distinction matters more here than
anywhere else on this page, because the two halves are verified in completely different places. The
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
that will not compose. Everything past that — whether the window draws, whether the terminal's
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
otherwise.
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
has been verified or made moot — not when it merely stops being convenient.
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
any vault on macOS.
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
too. *Unverified:* no enclave call in this repository has ever run.
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
surprises people — **any build that is not code signed**, because enclave key creation needs a
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
or notarization fails with an error that does not name the offending file.
or notarization fails with an error that does not name the offending file. *Mitigated* in
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
`--deep` and is not; do not delete it because a release once succeeded without it.
## Desktop client
@@ -366,6 +393,37 @@ AppContainer where loopback connections are blocked without a `CheckNetIsolation
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
terminal is that listener. Developer ID distribution outside the App Store does not require the
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
application without solving the data plane differently first. See
`build/macos/DodoSSH.entitlements`.
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
refuses a Developer ID submission without it, and CoreCLR will not start under it without
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
Each is argued individually in the entitlements file; the failure mode for a missing one is a
process that dies during runtime initialisation, before anything exists that could report it.
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
template the release script renders rather than a committed file. A committed plist would carry one
version into every release afterwards, and the symptom is silent: Velopack's index would still be
right, the updater would still work, and only Get Info and any crash report would disagree.
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
that fraction and the `.ico` at full bleed, from one geometry.
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,