Public Access
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.
186 lines
8.1 KiB
C#
186 lines
8.1 KiB
C#
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;
|
|
}
|