Give the desktop a macOS head, signed from the first release #3

Merged
jaap-jan merged 2 commits from claude/macos-build-release-2a8a0d into main 2026-08-10 08:59:00 +00:00
17 changed files with 2219 additions and 39 deletions
+88
View File
@@ -291,6 +291,7 @@ jobs:
# so it is not done either. What reaches users is built, installed and walked through Phase 16 # so it is not done either. What reaches users is built, installed and walked through Phase 16
# of docs/manual-checks.md by a person first. # of docs/manual-checks.md by a person first.
- name: package the windows desktop client - name: package the windows desktop client
id: winpack
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: | run: |
set -euo pipefail set -euo pipefail
@@ -374,6 +375,93 @@ jobs:
ls -la "$releases" ls -la "$releases"
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version." echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
# Handed to the macOS step below rather than worked out again there. The floor logic above
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
# is a second thing to keep in step — while two desktop packages built from one commit
# carrying different version numbers is precisely the confusion this file spends that
# reasoning to avoid.
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
#
# Same argument as the Windows step above, one platform along: the failures a release is most
# exposed to are the ones only the packager finds, and the person who would otherwise find them
# is the one midway through a release on the one Mac that can cut one.
#
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
# nobody can retry without a Mac.
#
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
#
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
# where the platform does.
#
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
# the machine that can also check the result; passing a rendered plist here would mean copying
# the substitution out of that script to no end, since nothing looks at what this produces.
#
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
#
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
# would open — so publishing it would be handing out a file whose only possible use is confusion.
- name: publish and bundle the macos desktop client
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
# long note there for why the committed lock files are deliberately RID-free. This runner's
# checkout is thrown away, so the lock files it rewrites go nowhere.
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime osx-arm64 --self-contained true \
-p:RestoreLockedMode=false \
--output "$RUNNER_TEMP/osx-arm64"
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
# rather than globbed, because a publish that produced no apphost at all would otherwise
# bundle happily and produce an .app that launches nothing.
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
echo "The osx-arm64 publish produced no apphost." >&2
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
exit 1
fi
bundles="$RUNNER_TEMP/osx-bundle"
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
# the shell reads it as a glob matching any one of o, s and x.
dotnet vpk '[osx]' bundle \
--skip-updates \
--packId DodoSSH.Desktop \
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
--packDir "$RUNNER_TEMP/osx-arm64" \
--packTitle DodoSSH \
--packAuthors DodoTech \
--mainExe DodoSSH \
--bundleId dev.dodotech.dodossh \
--runtime osx-arm64 \
--channel osx \
--outputDir "$bundles"
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
# macOS will not treat as an application at all, and it is the one part of the .app that
# vpk composes rather than copies.
app="$bundles/DodoSSH.Desktop.app"
if [ ! -s "$app/Contents/Info.plist" ]; then
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
find "$bundles" -maxdepth 3 >&2 || true
exit 1
fi
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH # This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a # server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than # Docker daemon and gets one here. That is why the tests run on ubuntu rather than
+40 -4
View File
@@ -80,6 +80,8 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
docs/manual-checks.md what no test can reach, and what to look for when checking by hand docs/manual-checks.md what no test can reach, and what to look for when checking by hand
docs/android-port.md the Android head: what was decided, what is built, what is left docs/android-port.md the Android head: what was decided, what is built, what is left
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
release-macos.sh — the same, signed and notarized, on a Mac
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
``` ```
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia. Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
@@ -152,8 +154,32 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
this machine's device key from your account. this machine's device key from your account.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a ## Installing on macOS
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
about the update check and about uninstalling applies unchanged; what differs is worth three short
paragraphs.
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
software", the file did not come from the project's release page, and that is worth taking literally.
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
to check it on, and this project does not ship desktop builds nobody has run — see
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
graceful version of that, and the honest answer is that the platform is not covered yet.
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
first never touches the second.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
not only that the runners are Linux.
### The nightly desktop build ### The nightly desktop build
@@ -886,8 +912,18 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
[Installing on Windows](#installing-on-windows) for what a user sees. [Installing on Windows](#installing-on-windows) for what a user sees.
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the **The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
first release aimed at strangers), and macOS and Linux packaging. choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
on every main build to prove it still packages, and uploads nothing. See
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
[Installing on macOS](#installing-on-macos).
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
everything above is verified only as far as the bundle.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys. - **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence ## Licence
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
What the hardened runtime has to be asked to relax before a .NET application will run under it.
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
listed one at a time, with what breaks without each, because the temptation when notarization
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
◆ WHAT IS DELIBERATELY NOT HERE.
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
ruled out for the same loopback reason — docs/platform-flags.md.
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
anything, and it is the entitlement most worth not having.
-->
<plist version="1.0">
<dict>
<!--
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
hardened runtime's default is that no page is both writable and executable, so without this the
process does not start — it dies during runtime initialisation, before any of this application's
code runs, which means before anything exists that could report it.
-->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!--
The broader form of the same permission, and it is needed as well as allow-jit rather than
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
still fails.
-->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!--
Library validation requires every loaded dylib to be signed by the same team as the main
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
with this Developer ID, which would in principle satisfy validation.
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
relaunches it, and the process doing the replacing is not always signed by the same team as the
process being replaced during the changeover. Leaving validation on makes the failure mode of a
bad update "the application will not start", with no way to recover except a reinstall the user
would have to be told about through some other channel.
-->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!--
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
update path sets them. Without this the hardened runtime strips them silently and the failure
surfaces later as a library that cannot be found, naming a file that is plainly present.
-->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+125
View File
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
The Info.plist for the macOS bundle, with the version left as a placeholder.
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
--bundleId together — with a plist supplied, every key is the caller's problem.
So a committed Info.plist would carry whatever version it was written with into every release
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
right version, the updater would compare correctly and update correctly, and only the About window,
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
and passes that.
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
Three keys it does not write, each of which is a real defect without it:
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
be DodoSSH — see scripts/release-macos.sh for the directory collision
that rule prevents. On Windows the pack id is invisible; on macOS it
names the thing in /Applications and in the Dock. This key is what puts
"DodoSSH" back in front of a person while the bundle keeps the id.
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
never built for, and the user gets a dyld crash rather than a sentence.
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
-->
<plist version="1.0">
<dict>
<!--
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
directory does not. See the note above.
-->
<key>CFBundleName</key>
<string>DodoSSH</string>
<key>CFBundleDisplayName</key>
<string>DodoSSH</string>
<!--
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
id: changing it makes an update a different application, and it orphans anything the previous
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
to this identifier and cannot be migrated because its whole point is that it never leaves the
enclave.
-->
<key>CFBundleIdentifier</key>
<string>dev.dodotech.dodossh</string>
<!--
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
than for the project. Must match --mainExe or the bundle launches nothing.
-->
<key>CFBundleExecutable</key>
<string>DodoSSH</string>
<!--
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
defines them as one to three dot-separated integers and notarization rejects what it cannot
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
is the one the updater compares. These two are for Finder and for Gatekeeper.
They are the same value rather than the usual marketing/build split, because there is no build
counter here that a release does not already bump.
-->
<key>CFBundleShortVersionString</key>
<string>@VERSION@</string>
<key>CFBundleVersion</key>
<string>@VERSION@</string>
<!--
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
showing the generic application icon and no error anywhere.
-->
<key>CFBundleIconFile</key>
<string>dodossh.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<!--
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
so 12.0 is the oldest release these bytes are built to load on.
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
all, and refusing on a release where it would in fact have run is the worse of the two errors.
A user on an unsupported-but-working macOS gets the application; the support matrix governs
what gets fixed if it misbehaves there, which is a different question.
-->
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<!--
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
terminal — the one surface in this application that is nothing but small text — into a blur.
-->
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!--
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
this one is an ordinary windowed application and the default is already false, but the key
being absent is indistinguishable from somebody having removed it.
-->
<key>LSUIElement</key>
<false/>
<key>NSHumanReadableCopyright</key>
<string>© DodoTech. MIT licensed.</string>
</dict>
</plist>
+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 **Status:** accepted, 2026-07-30
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap. **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 - **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 unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
is therefore required, not a nicety. 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. - **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 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. 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 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. 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 ### 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 [ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
+117
View File
@@ -2596,3 +2596,120 @@ package manager will not offer to.
**Failure means:** the channels are not separate, and a public key is signing the application people keep **Failure means:** the channels are not separate, and a public key is signing the application people keep
their credentials in. 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 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 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 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 suspicion. Anything marked *unverified* has not run on the platform in question and must not be
in question and must not be assumed to work. 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 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. 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 **must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
any vault on macOS. 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. **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 The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
elsewhere:* recalibrate on the slowest target platform before recommending a default profile, 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 **libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`) on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed, 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 ## 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 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. 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 *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 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, directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env bash
#
# Builds, packages and publishes the macOS desktop client.
#
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
# capability to ship somebody a build on a machine which is not a runner, and
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
# it, so whoever can write a release can ship an update every install runs.
#
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
# except the notarization submission, which Apple sees and users do not.
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
#
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
#
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
# notarization round trip are the price of the package being installable at all, not an improvement to
# be bought later.
#
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
#
# Three values are read from the environment, and none of them is itself a secret — they name things the
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
#
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
#
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
# third is created once per machine:
#
# xcrun notarytool store-credentials DodoSSH \
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
#
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
# minutes a credential that can publish an update spends in a shell's memory the better.
#
# Usage:
# bash scripts/release-macos.sh
# bash scripts/release-macos.sh --upload
# bash scripts/release-macos.sh --skip-tests
set -euo pipefail
UPLOAD=0
SKIP_TESTS=0
for arg in "$@"; do
case "$arg" in
--upload) UPLOAD=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
exit 1
;;
esac
done
# ---- The contract with every installed client ---------------------------------------------------------
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
# script states — it is what an installed client matches an update against.
#
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
#
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
#
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
PACK_ID='DodoSSH.Desktop'
PACK_TITLE='DodoSSH'
PACK_AUTHORS='DodoTech'
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
# upload does not.
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
CHANNEL='osx'
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
#
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
# repository that reached users unverified.
#
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
RUNTIME='osx-arm64'
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
RELEASES_DIR="$REPO_ROOT/Releases"
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
# ---- Is this machine able to do the job at all? -------------------------------------------------------
if [ "$(uname -s)" != 'Darwin' ]; then
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
# cannot be produced anywhere but here.
stop_with 'This builds a signed macOS package and has to run on macOS.'
fi
for tool in dotnet git xcrun codesign; do
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
done
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
# this script does and the slowest, and discovering there that a profile name was never exported means
# throwing away a full build and test run.
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
if [ -z "${!required-}" ]; then
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
fi
done
cd "$REPO_ROOT"
# ---- What is being released ---------------------------------------------------------------------------
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
# clone where obj/ has no MinVer targets to import yet.
write_step 'Restoring the desktop head, so the version can be read'
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
TAG="v$VERSION"
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
#
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
# care that the string parses and not what it says. See build/macos/Info.plist.template.
PLIST_VERSION="${VERSION%%-*}"
PLIST_VERSION="${PLIST_VERSION%%+*}"
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
if [ "$UPLOAD" -eq 1 ]; then
# The installer package is the artefact a person downloads, so its absence is the honest test of
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
fi
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
# -s so the token is never echoed and never lands in the shell's history.
printf 'Gitea token (write:repository): '
read -r -s TOKEN
echo
[ -n "$TOKEN" ] || stop_with 'No token given.'
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
# also already hold the Windows package for the same tag, which is the case --merge is really doing
# the work for: without it the second platform to publish a given version fails on a release that
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
UPLOAD_ARGS=(
upload gitea
--repoUrl "$REPO_URL"
--token "$TOKEN"
--outputDir "$RELEASES_DIR"
--channel "$CHANNEL"
--releaseName "$TAG"
--tag "$TAG"
--merge
--publish
)
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
# release candidate is a prerelease in every channel or in none.
case "$VERSION" in
*-*) UPLOAD_ARGS+=(--pre) ;;
esac
write_step 'Uploading'
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
write_step "Published $TAG."
exit 0
fi
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
# somebody pins a version by hand this is the guard that notices.
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
write_step 'Restoring tools'
dotnet tool restore || stop_with 'dotnet tool restore failed.'
write_step 'Restoring packages (locked, exactly as CI does)'
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
write_step 'Building'
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
if [ "$SKIP_TESTS" -eq 0 ]; then
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
write_step 'Testing'
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
fi
write_step "Publishing $RUNTIME"
rm -rf "$PUBLISH_DIR"
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
# per RID and a self-extracting bundle breaks delta updates.
#
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
# build. The gate that matters is the locked solution restore above, which is untouched.
dotnet publish "$PROJECT" \
--configuration Release \
--runtime "$RUNTIME" \
--self-contained true \
--output "$PUBLISH_DIR" \
-p:RestoreLockedMode=false \
|| stop_with 'Publish failed.'
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
# tree, so anything modified here is its own.
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
# application that never checks for updates — which looks completely normal until the next release goes out
# and nobody receives it.
for required in DodoSSH Velopack.dll; do
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
done
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
#
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
# pass and applies the outer entitlements to everything it touches.
#
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
# notarization rejection that does not name the offending file, on a submission that took its time getting
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
# an already correctly signed binary with -f is a no-op in effect.
#
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
write_step 'Signing native libraries'
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
# people forget because it has no extension to grep for.
NATIVE_COUNT=0
while IFS= read -r -d '' binary; do
codesign --force --verbose=0 --timestamp --options runtime \
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|| stop_with "codesign failed on $binary"
NATIVE_COUNT=$((NATIVE_COUNT + 1))
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
echo " signed $NATIVE_COUNT native binaries"
# ---- The bundle's Info.plist --------------------------------------------------------------------------
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
# so a committed one would carry whatever version it was written with into every release afterwards.
# See the header of build/macos/Info.plist.template.
write_step "Rendering Info.plist for $PLIST_VERSION"
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
trap 'rm -f "$RENDERED_PLIST"' EXIT
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
mkdir -p "$RELEASES_DIR"
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
write_step 'Fetching the previous release, for deltas'
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
fi
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
# part is not local: it builds the .app from the published files, signs it with the Developer ID
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
#
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
#
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
# reads like a keychain problem rather than like the wrong certificate.
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
dotnet vpk pack \
--packId "$PACK_ID" \
--packVersion "$VERSION" \
--packDir "$PUBLISH_DIR" \
--packTitle "$PACK_TITLE" \
--packAuthors "$PACK_AUTHORS" \
--mainExe 'DodoSSH' \
--icon "$ICON" \
--plist "$RENDERED_PLIST" \
--entitlements "$ENTITLEMENTS" \
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
--runtime "$RUNTIME" \
--channel "$CHANNEL" \
--outputDir "$RELEASES_DIR" \
|| stop_with 'vpk pack failed.'
# ---- Did the notarization actually take? --------------------------------------------------------------
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
# certificate.
write_step 'Verifying the notarization the way another Mac will'
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
if ! spctl --assess --type install --verbose=4 "$PKG"; then
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
fi
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
write_step 'Built, notarized, and deliberately not uploaded'
ls -lh "$RELEASES_DIR" | tail -n +2
cat <<EOF
Next:
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
2. Then: bash scripts/release-macos.sh --upload
EOF
+121 -13
View File
@@ -1,13 +1,18 @@
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws. # Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
# #
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole # The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
# reason it is a vector is that there is then one geometry to change and no set of PNG densities # reason it is a vector is that there is then one geometry to change and no set of PNG densities
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing # to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays # and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit. # the rasters exist, and this script is how they stay honest: the numbers below are the ones in
# that XML, and regenerating is the whole edit.
# #
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1 # pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
# #
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
# is how the two files come to be drawn from different geometry — which nobody would notice,
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
#
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas. # Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows, # That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108 # the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
@@ -30,29 +35,49 @@ $ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
# the gap, and its downsampler is not kind to a hairline. # the gap, and its downsampler is not kind to a hairline.
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256) $sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
function New-MarkPng([int]$size) # $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
# differs between the two platforms.
#
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
# whatever size they come in and every other icon on the taskbar does the same.
#
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
# because it sits beside Finder and Safari which do not.
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
{ {
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) $bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bitmap) $g = [System.Drawing.Graphics]::FromImage($bitmap)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
# The tile, and the inset that centres it when it does not fill the canvas.
$tile = [double]$size * $tileFraction
$inset = ([double]$size - $tile) / 2.0
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the # The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate. # one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
$radius = [double]$size * 0.22 #
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
# it is rounding instead of growing as the air around it does. It is also within a whisker of
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
$radius = $tile * 0.22
$d = $radius * 2.0 $d = $radius * 2.0
$path = New-Object System.Drawing.Drawing2D.GraphicsPath $path = New-Object System.Drawing.Drawing2D.GraphicsPath
$path.AddArc(0.0, 0.0, $d, $d, 180, 90) $path.AddArc($inset, $inset, $d, $d, 180, 90)
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90) $path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90) $path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90) $path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
$path.CloseFigure() $path.CloseFigure()
$brush = New-Object System.Drawing.SolidBrush($accent) $brush = New-Object System.Drawing.SolidBrush($accent)
$g.FillPath($brush, $path) $g.FillPath($brush, $path)
# 108-viewport units to pixels, with the outer 18 dropped on each edge. # 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
$scale = [double]$size / 72.0 # offset by the inset, so the glyph keeps its place within the tile at either fraction.
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) } $scale = $tile / 72.0
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the # A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
# difference between a mark and a smudge. The phone's file already bumps this width for the # difference between a mark and a smudge. The phone's file already bumps this width for the
@@ -117,3 +142,86 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
$w.Dispose(); $out.Dispose() $w.Dispose(); $out.Dispose()
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)" Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
#
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
# be regenerated on the machine this project is developed on — the geometry above would change and
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
#
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
#
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
#
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
# so this does too.
$icnsTypes = @(
@{ Type = 'ic11'; Size = 32 } # 16@2x
@{ Type = 'ic12'; Size = 64 } # 32@2x
@{ Type = 'ic07'; Size = 128 } # 128@1x
@{ Type = 'ic13'; Size = 256 } # 128@2x
@{ Type = 'ic08'; Size = 256 } # 256@1x
@{ Type = 'ic14'; Size = 512 } # 256@2x
@{ Type = 'ic09'; Size = 512 } # 512@1x
@{ Type = 'ic10'; Size = 1024 } # 512@2x
)
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
$macTileFraction = 824.0 / 1024.0
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
# 512s are byte-identical and the file does not carry the same image twice over at different
# compression. It also halves the drawing, which at 1024 is not nothing.
$rendered = @{}
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
{
[byte[]]$png = New-MarkPng $size $macTileFraction
$rendered[$size] = $png
}
$icns = New-Object System.IO.MemoryStream
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
{
$bytes = [System.BitConverter]::GetBytes($value)
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
$stream.Write($bytes, 0, 4)
}
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
{
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
$stream.Write($bytes, 0, $bytes.Length)
}
# The header's length field covers the whole file including the header, so it is written last —
# eight bytes of nothing now, seeked back to and filled in once the total is known.
Write-Ascii $icns 'icns'
Write-BE32 $icns 0
foreach ($entry in $icnsTypes)
{
$payload = $rendered[$entry.Size]
Write-Ascii $icns $entry.Type
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
# writes once.
Write-BE32 $icns ([uint32]($payload.Length + 8))
$icns.Write($payload, 0, $payload.Length)
}
$total = [uint32]$icns.Length
$icns.Position = 4
Write-BE32 $icns $total
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
$icns.Dispose()
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
Binary file not shown.
@@ -66,6 +66,21 @@
--> -->
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel> <DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
<!--
For the macOS keychain interop in Platform/, and for nothing else.
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
-->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- <!--
False here, unlike every server project. The root Directory.Build.props sets it true because False here, unlike every server project. The root Directory.Build.props sets it true because
the API is container-hosted, UTC-only and has no business formatting anything for a human. the API is container-hosted, UTC-only and has no business formatting anything for a human.
@@ -0,0 +1,598 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using DodoSSH.Client.Session;
using static DodoSSH.Client.App.Platform.MacSecurity;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
/// </summary>
/// <remarks>
/// <para>
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
/// turns it into bytes — and it is created under an access control requiring
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
/// and the attempt is visible.
/// </para>
/// <para>
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
/// applies here unchanged.
/// </para>
/// <para>
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
/// bytes and there is no size limit worth worrying about.
/// </para>
/// <para>
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
/// this platform — but it is still called from where the Windows one has to be, and relying on that
/// difference would make the shared caller platform-specific for no gain.
/// </para>
/// <para>
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
/// passphrase rather than fail at unlock.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
{
/// <summary>
/// The keychain tag this application's enclave key is filed under.
/// </summary>
/// <remarks>
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm can
/// create a new key beside the old one rather than failing to open blobs written by a previous
/// build. A device that cannot be opened falls back to the passphrase, which is survivable — but
/// silently, and a user would only notice their fingerprint had stopped working.
///
/// Prefixed with the bundle identifier because the keychain is shared across every application the
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
/// </remarks>
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
/// <summary>
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
/// </summary>
/// <remarks>
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
/// its own. The same words the Windows consent dialog uses.
/// </remarks>
private const string ConsentPrompt = "unlock your DodoSSH vault";
private readonly ClientPaths paths;
/// <summary>Creates the store.</summary>
public MacDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
}
/// <summary>
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
/// </summary>
/// <remarks>
/// <para>
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
/// moment somebody tried to unlock:
/// </para>
/// <para>
/// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
/// models do not, and there is no single attribute that says so.
/// </para>
/// <para>
/// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
/// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
/// passphrase, and this is what makes it do so without a platform check somewhere else.
/// </para>
/// <para>
/// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
/// nothing to demand, and the framework refuses the access control object rather than silently
/// creating a key anybody could use.
/// </para>
/// <para>
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
/// </para>
/// </remarks>
internal static bool IsSupported()
{
try
{
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return false;
}
var key = CreateEnclaveKey(scope, symbols, probe);
if (key == IntPtr.Zero)
{
return false;
}
// Discarded deliberately. The question this method answers is whether the enclave will make a
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
// stray keychain item and does not make the answer no.
_ = DeleteKey(symbols, probe);
return true;
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
// A macOS without these frameworks is not a thing that exists, so this is really the guard
// for the case that does: a future release renaming or removing one of them. The answer is
// the same as for hardware that is absent — no device key, ask for the passphrase.
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported());
/// <inheritdoc />
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var sealedKey = Seal(devicePrivateKey.Span)
?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
return Unseal(sealedKey);
}
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
var symbols = MacSymbols.Resolve();
if (symbols.Complete)
{
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
// and the file deleted above is the half that decides whether unlock will try at all. A key
// left in the enclave with no ciphertext to open is inert.
_ = DeleteKey(symbols, KeyTag);
}
return ValueTask.CompletedTask;
}
/// <remarks>
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
/// them is the same — do not offer a device unlock.
/// </remarks>
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return null;
}
// Created on first use rather than at registration, so that a device key re-registered after
// a ForgetAsync gets a key again without anything having to notice that it had gone.
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
if (privateKey == IntPtr.Zero)
{
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
}
if (privateKey == IntPtr.Zero)
{
return null;
}
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
if (publicKey == IntPtr.Zero)
{
return null;
}
var plaintext = Data(scope, devicePrivateKey);
if (plaintext == IntPtr.Zero)
{
return null;
}
var ciphertext = scope.Keep(
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
scope.Keep(error);
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return null;
}
}
/// <remarks>
/// <para>
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks:
/// the key may be gone, the user may have cancelled or let the prompt time out, the enclave may have
/// invalidated it after the login password was reset, or the blob may predate a key that has since
/// been replaced. None of them are distinguishable to a user and all have the same remedy, so none
/// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
/// </para>
/// <para>
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
/// on a thread that is also expected to draw the window behind it.
/// </para>
/// </remarks>
private static byte[]? Unseal(byte[] sealedKey)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return null;
}
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
if (privateKey == IntPtr.Zero)
{
return null;
}
var ciphertext = Data(scope, sealedKey);
if (ciphertext == IntPtr.Zero)
{
return null;
}
var plaintext = scope.Keep(
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
scope.Keep(error);
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return null;
}
}
/// <summary>
/// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
/// </summary>
/// <remarks>
/// <para>
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
/// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
/// without it this silently generates an ordinary software key that behaves identically in every
/// visible way and protects nothing.
/// </para>
/// <para>
/// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
/// device key that could be restored onto another machine from a backup would no longer mean "this
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
/// a future change of storage.
/// </para>
/// <para>
/// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
/// macOS routes this to the older file-based keychain, which does not understand access control
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
/// missing key.
/// </para>
/// </remarks>
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
{
var access = scope.Keep(SecAccessControlCreateWithFlags(
IntPtr.Zero,
symbols.AccessibleWhenUnlockedThisDeviceOnly,
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
out var accessError));
scope.Keep(accessError);
if (access == IntPtr.Zero)
{
return IntPtr.Zero;
}
var privateAttrs = Dictionary(
scope,
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
[symbols.True, TagData(scope, tag), access]);
if (privateAttrs == IntPtr.Zero)
{
return IntPtr.Zero;
}
var keySize = Number(scope, 256);
var parameters = Dictionary(
scope,
[
symbols.AttrKeyType,
symbols.AttrKeySizeInBits,
symbols.AttrTokenId,
symbols.UseDataProtectionKeychain,
symbols.PrivateKeyAttrs,
],
[
symbols.KeyTypeEcSecPrimeRandom,
keySize,
symbols.TokenIdSecureEnclave,
symbols.True,
privateAttrs,
]);
if (parameters == IntPtr.Zero)
{
return IntPtr.Zero;
}
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
scope.Keep(error);
return key;
}
/// <summary>
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
/// </summary>
/// <remarks>
/// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
/// decrypt, which is the operation the access control actually guards.
///
/// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
/// the failure this whole class is built to degrade into.
/// </remarks>
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
{
List<IntPtr> keys =
[
symbols.Class,
symbols.AttrApplicationTag,
symbols.AttrKeyType,
symbols.UseDataProtectionKeychain,
symbols.ReturnRef,
];
List<IntPtr> values =
[
symbols.ClassKey,
TagData(scope, tag),
symbols.KeyTypeEcSecPrimeRandom,
symbols.True,
symbols.True,
];
if (prompt is not null)
{
keys.Add(symbols.UseOperationPrompt);
values.Add(scope.Keep(CFString(prompt)));
}
var query = Dictionary(scope, [.. keys], [.. values]);
if (query == IntPtr.Zero)
{
return IntPtr.Zero;
}
var status = SecItemCopyMatching(query, out var result);
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
// it is not distinguished from any other failure for the reason the class remarks give.
return status == Success ? scope.Keep(result) : IntPtr.Zero;
}
/// <summary>Removes the key with this tag from the keychain.</summary>
/// <returns>Whether the keychain now has no key under this tag.</returns>
/// <remarks>
/// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
/// what a machine that never registered a device answers, and what the second of two
/// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
/// twice report a problem that does not exist.
/// </remarks>
private static bool DeleteKey(MacSymbols symbols, string tag)
{
using var scope = new CoreFoundationScope();
var query = Dictionary(
scope,
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
if (query == IntPtr.Zero)
{
return false;
}
var status = SecItemDelete(query);
return status is Success or ItemNotFound;
}
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
/// <remarks>
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
/// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
/// the arrays again.
/// </remarks>
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
{
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
// — it crashes inside the callback table instead.
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
{
return IntPtr.Zero;
}
var symbols = MacSymbols.Resolve();
unsafe
{
fixed (IntPtr* keyPtr = keys)
fixed (IntPtr* valuePtr = values)
{
return scope.Keep(CFDictionaryCreate(
IntPtr.Zero,
(IntPtr)keyPtr,
(IntPtr)valuePtr,
keys.Length,
symbols.TypeDictionaryKeyCallBacks,
symbols.TypeDictionaryValueCallBacks));
}
}
}
/// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
/// <remarks>
/// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
/// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
/// heap and rely on the object staying where the collector first put it.
/// </remarks>
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
{
unsafe
{
fixed (byte* pointer = bytes)
{
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
}
}
}
/// <remarks>
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
/// same way the creation did. It is written once, here, for exactly that reason.
/// </remarks>
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
Data(scope, Encoding.UTF8.GetBytes(tag));
private static IntPtr Number(CoreFoundationScope scope, int value)
{
unsafe
{
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
}
}
/// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
/// <remarks>
/// Built explicitly rather than left to the marshaller, because these calls take a
/// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
/// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
/// </remarks>
private static IntPtr CFString(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
unsafe
{
fixed (byte* pointer = bytes)
{
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
// only encoding constant this file uses.
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
}
}
}
[LibraryImport(CoreFoundation)]
private static partial IntPtr CFStringCreateWithBytes(
IntPtr allocator,
IntPtr bytes,
nint numBytes,
uint encoding,
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
private static byte[] ToArray(IntPtr data)
{
var length = (int)CFDataGetLength(data);
var pointer = CFDataGetBytePtr(data);
if (length <= 0 || pointer == IntPtr.Zero)
{
return [];
}
var result = new byte[length];
Marshal.Copy(pointer, result, 0, length);
return result;
}
}
@@ -0,0 +1,254 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The pieces of CoreFoundation and Security.framework <see cref="MacDeviceKeyStore"/> needs.
/// </summary>
/// <remarks>
/// <para>
/// Separated from the store itself because it is a different kind of code with a different kind of
/// review: nothing here makes a decision, and everything here is a translation of a C declaration that
/// is either right or wrong. Mixing the two would mean the security argument in
/// <see cref="MacDeviceKeyStore"/> had to be read past two hundred lines of marshalling to find.
/// </para>
/// <para>
/// <b>Every Create or Copy returns an object this process owns.</b> That is CoreFoundation's Create
/// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak
/// shows up as nothing at all until a long-running process has done a few thousand unlocks.
/// <see cref="CoreFoundationScope"/> exists so ownership is tracked by construction rather than by
/// remembering, and every function below that returns a handle says whether it is owned.
/// </para>
/// <para>
/// <b>The integer widths are the part worth checking against the headers rather than skimming.</b>
/// <c>CFIndex</c>, <c>CFOptionFlags</c> and <c>CFNumberType</c> are all pointer-width on a 64-bit Mac,
/// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the
/// call receives plausible rubbish and returns a parameter error that names nothing.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
internal static partial class MacSecurity
{
internal const string SecurityFramework =
"/System/Library/Frameworks/Security.framework/Security";
internal const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
/// <summary>
/// The access control flags <c>SecAccessControlCreateWithFlags</c> takes.
/// </summary>
/// <remarks>
/// <c>ulong</c> because the parameter is a <c>CFOptionFlags</c>, which is an <c>unsigned long</c>.
/// Only the two flags that are used are listed; the full set is large, and copying it in would
/// invite somebody to reach for one without reading what it does to the prompt — <c>Biometry</c>
/// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling
/// back to the login password.
/// </remarks>
[Flags]
internal enum AccessControlFlags : ulong
{
/// <summary>
/// Touch ID if the machine has it, the login password if not.
/// </summary>
/// <remarks>
/// The forgiving one, deliberately. <c>BiometryCurrentSet</c> would additionally invalidate the
/// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing:
/// this key wraps a device key whose loss already means "ask for the passphrase", so the only
/// effect would be users being sent back to their passphrase by an unrelated Settings change
/// they would never connect to it.
/// </remarks>
UserPresence = 1ul << 0,
/// <summary>Required for any key that lives in the Secure Enclave.</summary>
PrivateKeyUsage = 1ul << 30,
}
/// <summary>The CFNumberType code for a 32-bit int, from CFNumber.h.</summary>
internal const long CFNumberIntType = 9;
/// <summary>errSecSuccess.</summary>
internal const int Success = 0;
/// <summary>errSecItemNotFound, which is an answer rather than a failure.</summary>
internal const int ItemNotFound = -25300;
// ---- CoreFoundation ------------------------------------------------------------------------------
/// <summary>Releases an owned handle.</summary>
[LibraryImport(CoreFoundation)]
internal static partial void CFRelease(IntPtr handle);
/// <summary>Copies bytes into a new CFData. Owned.</summary>
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length);
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFDataGetBytePtr(IntPtr data);
[LibraryImport(CoreFoundation)]
internal static partial nint CFDataGetLength(IntPtr data);
/// <summary>Boxes a value as a CFNumber. Owned.</summary>
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr);
/// <summary>Builds an immutable dictionary. Owned.</summary>
/// <remarks>
/// <para>
/// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as
/// managed arrays. Source-generated interop wants an explicit element count for a marshalled array,
/// and supplying one here would mean stating the length twice — once for the marshaller and once as
/// <paramref name="numValues"/> — which is exactly the pair that drifts.
/// </para>
/// <para>
/// The two callback tables are what make the dictionary retain its keys and values, which is why
/// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and
/// keeps nothing alive, and the resulting use-after-free is intermittent by nature.
/// </para>
/// </remarks>
[LibraryImport(CoreFoundation)]
internal static partial IntPtr CFDictionaryCreate(
IntPtr allocator,
IntPtr keys,
IntPtr values,
nint numValues,
IntPtr keyCallBacks,
IntPtr valueCallBacks);
// ---- Security.framework --------------------------------------------------------------------------
/// <summary>Builds the access policy a Secure Enclave key is created under. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecAccessControlCreateWithFlags(
IntPtr allocator,
IntPtr protection,
AccessControlFlags flags,
out IntPtr error);
/// <summary>Creates a key pair from an attribute dictionary. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
/// <summary>The public half of a key. Owned.</summary>
/// <remarks>
/// Available even for an enclave key, and that asymmetry is the whole reason this design works: the
/// public half is an ordinary key this process can hold and use, while the private half is a handle
/// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is
/// the thing the user is asked about.
/// </remarks>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
/// <summary>Encrypts with a public key. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCreateEncryptedData(
IntPtr key,
IntPtr algorithm,
IntPtr plaintext,
out IntPtr error);
/// <summary>Decrypts with a private key, prompting for whatever guards it. Owned.</summary>
[LibraryImport(SecurityFramework)]
internal static partial IntPtr SecKeyCreateDecryptedData(
IntPtr key,
IntPtr algorithm,
IntPtr ciphertext,
out IntPtr error);
/// <summary>Finds a keychain item. The out handle is owned when the result is <see cref="Success"/>.</summary>
[LibraryImport(SecurityFramework)]
internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
/// <summary>Deletes every keychain item matching the query.</summary>
[LibraryImport(SecurityFramework)]
internal static partial int SecItemDelete(IntPtr query);
// ---- The framework constants ---------------------------------------------------------------------
/// <summary>
/// Reads one of a framework's global CFString constants, or zero if it is not exported.
/// </summary>
/// <remarks>
/// <para>
/// The keys these dictionaries take are not strings this code may spell for itself. They are
/// pointer-comparable constants exported by the framework, and a CFString built here with the same
/// characters is a different object — the lookups would miss and the call would fail with a
/// parameter error naming nothing.
/// </para>
/// <para>
/// <b>Dereferenced once, because the exported symbol is the variable rather than its value.</b>
/// <c>TryGetExport</c> answers the address of the global; the CFStringRef is what that address
/// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is
/// the worst of the three available outcomes.
/// </para>
/// <para>
/// Zero on a missing symbol rather than an exception, because the caller's answer to every failure
/// is the same one — report the store unavailable and let unlock ask for the passphrase — and a
/// constant that has been renamed by a future macOS should reach that answer rather than a crash.
/// </para>
/// </remarks>
internal static IntPtr Constant(IntPtr library, string symbol) =>
NativeLibrary.TryGetExport(library, symbol, out var address)
? Marshal.ReadIntPtr(address)
: IntPtr.Zero;
}
/// <summary>
/// Releases every CoreFoundation handle put into it, in reverse order, exactly once.
/// </summary>
/// <remarks>
/// <para>
/// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a
/// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys
/// nested that deep stop being read, and a handle released twice is a crash rather than a leak.
/// </para>
/// <para>
/// <see cref="Keep"/> returns what it was given, so a handle can be tracked in the same expression that
/// produces it and the call sites read as ordinary code.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
internal sealed class CoreFoundationScope : IDisposable
{
private readonly List<IntPtr> owned = [];
private bool disposed;
/// <summary>Takes ownership of a handle and hands it straight back.</summary>
/// <remarks>
/// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so
/// accepting it lets a caller track the result in the expression that produces it and check it on
/// the next line, instead of writing the check twice.
/// </remarks>
internal IntPtr Keep(IntPtr handle)
{
if (handle != IntPtr.Zero)
{
owned.Add(handle);
}
return handle;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
// Reverse order, so a container is released before the things it retains. CoreFoundation does not
// require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a
// debugger, where a released container that still lists its contents is a confusing thing to meet.
for (var i = owned.Count - 1; i >= 0; i--)
{
MacSecurity.CFRelease(owned[i]);
}
owned.Clear();
}
}
@@ -0,0 +1,185 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The framework constants <see cref="MacDeviceKeyStore"/> passes to CoreFoundation and Security.
/// </summary>
/// <remarks>
/// <para>
/// Every field here is a pointer read out of a loaded framework rather than a value this code could
/// write down. The dictionaries these go into are matched by pointer identity, so a CFString built with
/// the same characters is a different key and the lookup misses — see <see cref="MacSecurity.Constant"/>
/// for the indirection that trips people.
/// </para>
/// <para>
/// <b>Resolved once and cached, and the caching is what makes the failure survivable.</b> Two frameworks
/// and nineteen symbols is a lot of things to be wrong about, and the useful property is that being
/// wrong about any one of them shows up here — as <see cref="Complete"/> being false — rather than
/// three calls later as a parameter error. A store that reports itself unavailable sends the user back
/// to their passphrase; a store that half works corrupts the moment somebody registers a device.
/// </para>
/// <para>
/// <see cref="Lazy{T}"/> rather than a static constructor, because a type initialiser that throws
/// poisons the type for the life of the process and turns a missing symbol into a
/// <c>TypeInitializationException</c> at every later call site. The load is done inside a try instead,
/// and its failure is a value.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
internal sealed class MacSymbols
{
private static readonly Lazy<MacSymbols> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
private MacSymbols()
{
}
/// <summary>Whether every symbol resolved.</summary>
/// <remarks>
/// Checked by every caller before any of the pointers are used. It is one check rather than
/// nineteen, which is the only reason the call sites in <see cref="MacDeviceKeyStore"/> are
/// readable.
///
/// Computed rather than stored, so that the instance returned when a framework will not load at all
/// — every field left at zero — answers false without that having to be set anywhere. One rule,
/// applied to the only state there is.
/// </remarks>
internal bool Complete => AllResolved();
// CoreFoundation.
internal IntPtr True { get; private init; }
internal IntPtr TypeDictionaryKeyCallBacks { get; private init; }
internal IntPtr TypeDictionaryValueCallBacks { get; private init; }
// Security: item classes and query keys.
internal IntPtr Class { get; private init; }
internal IntPtr ClassKey { get; private init; }
internal IntPtr ReturnRef { get; private init; }
internal IntPtr UseDataProtectionKeychain { get; private init; }
internal IntPtr UseOperationPrompt { get; private init; }
// Security: key attributes.
internal IntPtr AttrKeyType { get; private init; }
internal IntPtr AttrKeySizeInBits { get; private init; }
internal IntPtr AttrTokenId { get; private init; }
internal IntPtr AttrIsPermanent { get; private init; }
internal IntPtr AttrApplicationTag { get; private init; }
internal IntPtr AttrAccessControl { get; private init; }
internal IntPtr PrivateKeyAttrs { get; private init; }
// Security: attribute values.
internal IntPtr KeyTypeEcSecPrimeRandom { get; private init; }
internal IntPtr TokenIdSecureEnclave { get; private init; }
internal IntPtr AccessibleWhenUnlockedThisDeviceOnly { get; private init; }
/// <summary>
/// <c>kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM</c>.
/// </summary>
/// <remarks>
/// The one algorithm the Secure Enclave's P-256 keys support for encryption, and the reason this
/// store wraps rather than signs. The long name spells out the whole construction: an ephemeral
/// key agreed against the enclave's public half with cofactor ECDH, run through the X9.63 KDF with
/// SHA-256, used as an AES-GCM key. The ephemeral public key travels in the output, which is why the
/// ciphertext is larger than the 32 bytes going in and why nothing else has to be stored beside it.
/// </remarks>
internal IntPtr EciesAlgorithm { get; private init; }
/// <summary>The resolved symbols, loaded once.</summary>
internal static MacSymbols Resolve() => Cached.Value;
private static MacSymbols Load()
{
try
{
if (!NativeLibrary.TryLoad(MacSecurity.CoreFoundation, out var cf)
|| !NativeLibrary.TryLoad(MacSecurity.SecurityFramework, out var sec))
{
// Every pointer left at zero, which AllResolved reads as incomplete.
return new MacSymbols();
}
// The two callback tables are structs rather than object pointers, so what is wanted is the
// address of the export itself and not what it holds. Every other symbol here is a CFTypeRef
// global and needs the dereference; these two do not, and mixing them up produces a
// dictionary that does not retain its contents.
var keyCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryKeyCallBacks", out var k)
? k
: IntPtr.Zero;
var valueCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryValueCallBacks", out var v)
? v
: IntPtr.Zero;
return new MacSymbols
{
True = MacSecurity.Constant(cf, "kCFBooleanTrue"),
TypeDictionaryKeyCallBacks = keyCallBacks,
TypeDictionaryValueCallBacks = valueCallBacks,
Class = MacSecurity.Constant(sec, "kSecClass"),
ClassKey = MacSecurity.Constant(sec, "kSecClassKey"),
ReturnRef = MacSecurity.Constant(sec, "kSecReturnRef"),
UseDataProtectionKeychain = MacSecurity.Constant(sec, "kSecUseDataProtectionKeychain"),
UseOperationPrompt = MacSecurity.Constant(sec, "kSecUseOperationPrompt"),
AttrKeyType = MacSecurity.Constant(sec, "kSecAttrKeyType"),
AttrKeySizeInBits = MacSecurity.Constant(sec, "kSecAttrKeySizeInBits"),
AttrTokenId = MacSecurity.Constant(sec, "kSecAttrTokenID"),
AttrIsPermanent = MacSecurity.Constant(sec, "kSecAttrIsPermanent"),
AttrApplicationTag = MacSecurity.Constant(sec, "kSecAttrApplicationTag"),
AttrAccessControl = MacSecurity.Constant(sec, "kSecAttrAccessControl"),
PrivateKeyAttrs = MacSecurity.Constant(sec, "kSecPrivateKeyAttrs"),
KeyTypeEcSecPrimeRandom = MacSecurity.Constant(sec, "kSecAttrKeyTypeECSECPrimeRandom"),
TokenIdSecureEnclave = MacSecurity.Constant(sec, "kSecAttrTokenIDSecureEnclave"),
AccessibleWhenUnlockedThisDeviceOnly =
MacSecurity.Constant(sec, "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
EciesAlgorithm = MacSecurity.Constant(
sec,
"kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM"),
};
}
catch (Exception exception) when (exception is DllNotFoundException or BadImageFormatException)
{
return new MacSymbols();
}
}
private bool AllResolved() =>
True != IntPtr.Zero
&& TypeDictionaryKeyCallBacks != IntPtr.Zero
&& TypeDictionaryValueCallBacks != IntPtr.Zero
&& Class != IntPtr.Zero
&& ClassKey != IntPtr.Zero
&& ReturnRef != IntPtr.Zero
&& UseDataProtectionKeychain != IntPtr.Zero
&& UseOperationPrompt != IntPtr.Zero
&& AttrKeyType != IntPtr.Zero
&& AttrKeySizeInBits != IntPtr.Zero
&& AttrTokenId != IntPtr.Zero
&& AttrIsPermanent != IntPtr.Zero
&& AttrApplicationTag != IntPtr.Zero
&& AttrAccessControl != IntPtr.Zero
&& PrivateKeyAttrs != IntPtr.Zero
&& KeyTypeEcSecPrimeRandom != IntPtr.Zero
&& TokenIdSecureEnclave != IntPtr.Zero
&& AccessibleWhenUnlockedThisDeviceOnly != IntPtr.Zero
&& EciesAlgorithm != IntPtr.Zero;
}
@@ -31,7 +31,12 @@ internal static class UpdateChannels
/// </remarks> /// </remarks>
internal static IUpdateChannel ForThisMachine() internal static IUpdateChannel ForThisMachine()
{ {
if (!OperatingSystem.IsWindows()) // Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
// AppImage an addition here rather than a thing that silently already half-happened.
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{ {
return new UnavailableUpdateChannel(); return new UnavailableUpdateChannel();
} }
@@ -55,14 +60,21 @@ internal static class UpdateChannels
} }
/// <summary> /// <summary>
/// The Windows update channel, backed by Velopack against the project's own forge. /// The desktop update channel, backed by Velopack against the project's own forge.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c> /// The one file in the repository that names Velopack. It lives beside the platform key stores rather
/// rather than in a project of its own because it is the same kind of thing — a Windows-only /// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because /// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater. /// shared with the Android head, which must never acquire an updater.
/// </para>
/// <para>
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
/// </para> /// </para>
/// <para> /// <para>
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>. /// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
@@ -103,7 +115,7 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere: /// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
/// the check succeeds, finds nothing, and reports that the client is up to date forever. /// the check succeeds, finds nothing, and reports that the client is up to date forever.
/// </remarks> /// </remarks>
private const string ReleaseChannel = "win"; private const string WindowsReleaseChannel = "win";
/// <summary> /// <summary>
/// The nightly channel, which is a different name rather than the same one on a different tag. /// The nightly channel, which is a different name rather than the same one on a different tag.
@@ -122,7 +134,26 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// a download somebody watched. A channel each means neither ever sees the other's releases at all. /// a download somebody watched. A channel each means neither ever sees the other's releases at all.
/// </para> /// </para>
/// </remarks> /// </remarks>
private const string NightlyChannel = "win-nightly"; private const string WindowsNightlyChannel = "win-nightly";
/// <summary>The macOS release channel, and Velopack's own default there.</summary>
/// <remarks>
/// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
/// PowerShell script. Stated for the same reason, which applies with more force here: the four
/// channels all publish to one repository, so the only thing keeping a Mac from being offered a
/// <c>win</c> package is that it never reads that index.
/// </remarks>
private const string MacReleaseChannel = "osx";
/// <summary>The macOS nightly channel.</summary>
/// <remarks>
/// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
/// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
/// build checking this feed finds an empty channel and reports itself up to date, which is the
/// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
/// on later is one job rather than a job plus a rename that has to reach every installed client.
/// </remarks>
private const string MacNightlyChannel = "osx-nightly";
private readonly UpdateManager manager; private readonly UpdateManager manager;
@@ -141,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// <inheritdoc /> /// <inheritdoc />
public bool IsSupported => true; public bool IsSupported => true;
/// <summary>Always, on this head.</summary> /// <summary>Always, on this head, on either platform.</summary>
/// <remarks> /// <remarks>
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is /// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
/// gone by the time anything could have asked a question. The phone's is the other answer; see /// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
/// relaunches it, so the process is gone by the time anything could have asked a question. The
/// mechanism differs and the answer does not, which is why this is a constant rather than another
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently. /// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
/// </remarks> /// </remarks>
public bool ApplyingEndsTheProcess => true; public bool ApplyingEndsTheProcess => true;
@@ -183,7 +217,36 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
return new UpdateManager( return new UpdateManager(
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly), new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel }); new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
}
/// <summary>
/// The one of the four channel names this build belongs to.
/// </summary>
/// <remarks>
/// <para>
/// Two independent axes — which platform, and which of that platform's two channels — and they are
/// resolved in one place so that neither can be answered differently somewhere else. The platform
/// half is the running OS rather than anything recorded in the build, because a package can only
/// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
/// because a release build and a nightly are the same bytes on the same OS and only the metadata
/// tells them apart.
/// </para>
/// <para>
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
/// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
/// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
/// later has to reason about to discover it cannot happen.
/// </para>
/// </remarks>
private static string ChannelFor(bool nightly)
{
if (OperatingSystem.IsMacOS())
{
return nightly ? MacNightlyChannel : MacReleaseChannel;
}
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -9,9 +9,17 @@ namespace DodoSSH.Client.App.Platform;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that /// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the /// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
/// passphrase — which is the honest answer rather than a degraded one. /// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
/// both platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
/// mean each of those discovering the truth at the moment somebody tried to unlock.
/// </para> /// </para>
/// <para> /// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used /// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
@@ -29,9 +37,17 @@ public static class DesktopDeviceKeyStores
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported() if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
? new WindowsDeviceKeyStore(paths) {
: new UnavailableDeviceKeyStore(); return new WindowsDeviceKeyStore(paths);
}
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
{
return new MacDeviceKeyStore(paths);
}
return new UnavailableDeviceKeyStore();
} }
} }