Public Access
Give the desktop a macOS head, signed from the first release
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:
@@ -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
|
||||
# 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
|
||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
||||
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||
# 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
|
||||
#
|
||||
# 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.
|
||||
# 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
|
||||
@@ -30,29 +35,49 @@ $ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
|
||||
# the gap, and its downsampler is not kind to a hairline.
|
||||
$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)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$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
|
||||
# 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
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
||||
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||
$g.FillPath($brush, $path)
|
||||
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
||||
$scale = [double]$size / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||
$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
|
||||
# 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()
|
||||
|
||||
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>
|
||||
|
||||
<!--
|
||||
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
|
||||
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>
|
||||
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();
|
||||
}
|
||||
@@ -55,14 +60,21 @@ internal static class UpdateChannels
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
||||
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||
/// 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>
|
||||
/// 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:
|
||||
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||
/// </remarks>
|
||||
private const string ReleaseChannel = "win";
|
||||
private const string WindowsReleaseChannel = "win";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </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;
|
||||
|
||||
@@ -141,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>Always, on this head.</summary>
|
||||
/// <summary>Always, on this head, on either platform.</summary>
|
||||
/// <remarks>
|
||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
||||
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
public bool ApplyingEndsTheProcess => true;
|
||||
@@ -183,7 +217,36 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
|
||||
return new UpdateManager(
|
||||
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 />
|
||||
|
||||
@@ -9,9 +9,17 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
||||
/// passphrase — which is the honest answer rather than a degraded one.
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
|
||||
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
|
||||
/// 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>
|
||||
/// <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);
|
||||
|
||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
||||
? new WindowsDeviceKeyStore(paths)
|
||||
: new UnavailableDeviceKeyStore();
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new WindowsDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new MacDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
return new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user