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

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

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

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

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

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

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

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
This commit is contained in:
2026-08-10 10:43:28 +02:00
parent e936ab4646
commit 890a5f2246
17 changed files with 2219 additions and 39 deletions
@@ -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;
}
}