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
+9 -1
View File
@@ -1,4 +1,4 @@
# ADR 0007 — What protects the device key on Windows
# ADR 0007 — What protects the device key on the desktop
**Status:** accepted, 2026-07-30
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
@@ -141,6 +141,14 @@ would have become false under DPAPI alone. A gesture is still something the atta
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
is therefore required, not a nicety.
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
probe answers no, one of which is every unsigned development build.
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
@@ -228,6 +228,44 @@ changes.
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
layer down, declined there for reasons that do not stop applying because the vendor changed.
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
### 10. macOS is a second desktop platform on the same machinery, signed from the start
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
things differ, and each is forced rather than chosen.
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
signing identities, where the Windows script refuses nothing.
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
release channel.
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
the id.
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
Intel package would be the only artefact in this repository reaching users without somebody having walked
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
discipline exists to refuse.
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
Apple's ticket says this build came from this developer account, and says nothing about whether the build
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
### 9. There is a second desktop channel, published by CI, and it is a second application
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
+117
View File
@@ -2593,3 +2593,120 @@ package manager will not offer to.
**Failure means:** the channels are not separate, and a public key is signing the application people keep
their credentials in.
## Phase 18 — Installing the macOS client, and being updated by it
The macOS counterpart of phase 16, and it needs a Mac with a Secure Enclave — an Apple Silicon machine or
an Intel one with a T2. Every check here is structurally unreachable by a test for the reasons phase 16
gives, plus one this platform adds: **CI has no macOS runner at all**, so this phase is the only place the
suite and the application ever run on macOS. Anything `docs/platform-flags.md` marks as unverified on macOS
is verified here or nowhere.
Run `bash scripts/release-macos.sh` first. It stops after packing and notarizing, on purpose, so that
everything below happens before anything reaches a user. Phase 16.0 — the feed being readable without
credentials — applies unchanged and is not repeated.
### 18.1 Gatekeeper accepts it on a machine that did not build it · **do this one first**
The Mac that signed a package trusts it locally whatever happened, so the build machine cannot answer this
question about itself. Copy the `.pkg` to a second Mac — or at minimum download it through a browser, which
is what applies the quarantine attribute — and open it.
**Pass:** it installs with no warning beyond the ordinary installer prompts.
**Failure means:** "cannot be opened because Apple cannot check it for malicious software" is notarization
that did not happen or a ticket that did not staple. The script's `spctl --assess` and `xcrun stapler
validate` should have caught it before this point, so reaching here means one of those two checks was
removed or skipped. Do not distribute the package.
### 18.2 The Dock shows the product and not the pack id
Look at the installed application in `/Applications`, in the Dock, and in the menu bar while it runs.
**Pass:** the menu bar says **DodoSSH**. Finder shows **DodoSSH**. The bundle on disk is
`DodoSSH.Desktop.app` and that is expected — see the pack id note in `scripts/release-macos.sh`.
**Failure means:** "DodoSSH.Desktop" in the menu bar is `CFBundleName` not reaching the bundle, which means
the rendered `Info.plist` did not get used. Since vpk copies a custom plist verbatim and substitutes
nothing, check the same bundle's `CFBundleShortVersionString` — if it reads `@VERSION@`, the template was
passed through unrendered.
### 18.3 The icon is the mark, at every size
Look at it in the Dock, in Finder's icon view at a large size, and in `⌘I` Get Info.
**Pass:** the accent tile and the `>_` mark, crisp at 1024, with the same air around it that Finder and
Safari have.
**Failure means:** a generic application icon is `CFBundleIconFile` naming a file that is not in
`Contents/Resources`. An icon that fills its square edge to edge, larger than its neighbours, is
`New-MarkPng` having been called with the Windows tile fraction — see `dodossh-icon.ps1`.
### 18.4 Touch ID guards the device key, and the enclave enforces it
Register a device key from the security settings page, then lock the vault and unlock it again.
**Pass:** registering shows **no** prompt at all — sealing uses only the public half — and unlocking raises
the system Touch ID sheet saying DodoSSH is trying to *unlock your DodoSSH vault*. The vault opens on a
successful touch.
**Failure means:** a prompt at registration is not a failure of correctness but says the key was not created
in the enclave; check that `kSecAttrTokenID` reached the attributes. **No prompt at unlock, with the vault
opening anyway, is the serious one** — it means the key is a software key and the access control did nothing,
which is precisely the "a gate inside the process is not a gate" mistake `WindowsDeviceKeyStore` documents.
### 18.5 Declining the fingerprint falls back to the passphrase
Repeat 18.4 and cancel the Touch ID sheet.
**Pass:** the unlock screen asks for the passphrase, and it works.
**Failure means:** an error dialog, or a stuck screen, is `TryLoadAsync` throwing rather than answering
null. Every failure it can meet — cancelled, timed out, key invalidated by a password reset — is meant to
be indistinguishable and to land on the passphrase.
### 18.6 A development build offers no device key at all
Run the application with `dotnet run` rather than from the installed bundle, and open the security settings
page.
**Pass:** registering a device key is not offered.
**Failure means:** being offered it is `IsSupported` having inferred availability from the OS rather than
probing. An unsigned build cannot create an enclave key, so accepting the offer would put a wrap on the
server that nothing can ever open and list a capability this machine does not have.
### 18.7 The terminal works, which is the WKWebView question
Connect to a host and use the shell: type, run something that scrolls, resize the window.
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
**Failure means:** a blank pane that reports a renderer timeout after fifteen seconds is the loopback
WebSocket not reaching WKWebView. This is the check that most needs walking, because the data plane has
never run against this backend — see `TerminalDataPlane`. If it fails, the App Sandbox is the first thing to
rule out: the entitlements deliberately do not enable it, and a sandboxed process cannot listen on loopback
without `com.apple.security.network.server`.
### 18.8 An update is offered, downloaded and applied
With the release installed, cut a second release with a higher version and publish it, then leave the first
running.
**Pass:** the banner appears, downloads, and on applying the application closes and reopens on the new
version. The vault's contents and the known hosts survive.
**Failure means:** an update that never arrives is usually the channel — `osx` here and `osx` in
`VelopackUpdateChannel.MacReleaseChannel`, with no error anywhere when they disagree. An update that
downloads and fails to apply, leaving the application unable to restart, is library validation: check that
`com.apple.security.cs.disable-library-validation` survived into the entitlements.
### 18.9 Uninstalling does not take the vault with it
Register a device, sync something, then remove the application.
**Pass:** `~/Library/Application Support/DodoSSH` still holds the cache and the outbox afterwards.
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
+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,