Public Access
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.
◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.
ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.
So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.
Three details that are load-bearing rather than incidental:
- The comparison is rpmvercmp and not SemVer, because rpm is what will accept
or refuse the install. Same argument ADR 0014 makes for versionCode. The
installed side is read out of the rpm database rather than derived from the
assembly, so the SemVer-to-rpm translation exists in exactly one place. All
28 expected values in RpmVersionTests came out of rpm itself rather than out
of a reading of the algorithm.
- The digest is checked and is not a signature. Velopack verifies its own
downloads and Android checks a signature; an RPM over HTTPS has neither. The
manifest names a SHA-256 and a mismatch deletes the file rather than leaving
something installable-by-hand in the profile directory.
- ONLY_TRUSTED is cleared, which is what routes the request to polkit's
package-install-untrusted action. The package is unsigned, so requiring
trust would fail every update at the last step.
◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.
ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.
The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.
This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.
Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.
Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.
◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.
This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.
TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.
The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.
End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.
Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
282 lines
12 KiB
C#
282 lines
12 KiB
C#
using System.Runtime.Versioning;
|
|
using System.Security.Cryptography;
|
|
using DodoSSH.Client.Session;
|
|
|
|
namespace DodoSSH.Client.App.Platform;
|
|
|
|
/// <summary>
|
|
/// Picks the device key store this desktop machine can actually offer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
|
|
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
|
|
/// the honest answer rather than a degraded one.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
|
|
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
|
|
/// all three 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, on macOS also every unsigned
|
|
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
|
|
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
|
|
/// discovering the truth at the moment somebody tried to unlock.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
|
|
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
|
|
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
|
|
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
|
|
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
|
|
/// tried last rather than first among platforms that could in principle overlap.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
|
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project from being
|
|
/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
|
|
/// vault code meant a second head could not reference it without dragging Windows along. The seam that
|
|
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
|
|
/// store and has never known which one. See <c>docs/android-port.md</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class DesktopDeviceKeyStores
|
|
{
|
|
/// <summary>The best store this machine supports.</summary>
|
|
/// <param name="paths">Where a stored key lives.</param>
|
|
/// <param name="askForPin">
|
|
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
|
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
|
|
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
|
|
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
|
|
/// in <c>App.axaml.cs</c>.
|
|
/// </param>
|
|
public static IDeviceKeyStore ForThisMachine(
|
|
ClientPaths paths,
|
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
ArgumentNullException.ThrowIfNull(askForPin);
|
|
|
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
|
{
|
|
return new WindowsDeviceKeyStore(paths);
|
|
}
|
|
|
|
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
|
{
|
|
return new MacDeviceKeyStore(paths);
|
|
}
|
|
|
|
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
|
|
{
|
|
return new LinuxDeviceKeyStore(paths, askForPin);
|
|
}
|
|
|
|
return new UnavailableDeviceKeyStore();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The consent is enforced by CNG, not by this class</b>, and that distinction is the entire security
|
|
/// value. A store that read a DPAPI blob after showing its own prompt would be trivially bypassed:
|
|
/// malware running as the user would skip the prompt, read the file and call
|
|
/// <c>CryptUnprotectData</c> itself. Here the unwrapping key lives in the TPM under
|
|
/// <see cref="CngUIProtectionLevels.ProtectKey"/>, so the Windows consent dialog is a condition of
|
|
/// <em>using</em> the key. Malware can ask; it cannot answer, and the attempt is visible.
|
|
/// </para>
|
|
/// <para>
|
|
/// This is a refinement of what ADR 0007 describes, and stronger than it: the ADR reasoned about a
|
|
/// gesture gating a protected blob and did not notice that a gate inside the process is not a gate. The
|
|
/// mechanism recorded there has been corrected to match this.
|
|
/// </para>
|
|
/// <para>
|
|
/// The TPM key is RSA rather than the ECDH one might expect, because it is used to encrypt 32 bytes and
|
|
/// nothing else. OAEP over a 2048-bit key carries 190, so there is no need for an agreement step, and no
|
|
/// need for the device key itself to be an algorithm the TPM understands — which is what keeps the DSH1
|
|
/// device wrap unchanged at X25519. See ADR 0007 for why changing that is a separate decision.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Both ends prompt, and that was measured rather than assumed.</b> Encrypting uses only the public
|
|
/// half, so it was reasonable to expect sealing to be silent — it is not. <c>CngKey.Create</c> with this
|
|
/// UI policy blocks on a dialog at <em>creation</em>, because <c>ProtectKey</c> means "protect this key
|
|
/// with a PIN", and Windows asks the user to set that up there and then. So registering a device shows one
|
|
/// setup dialog and every unlock shows a consent dialog.
|
|
/// </para>
|
|
/// <para>
|
|
/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
|
|
/// touching this file. Every method except <see cref="IsSupported"/> and the empty case of
|
|
/// <see cref="TryLoadAsync"/> needs an interactive desktop, so <b>none of them can be exercised by an
|
|
/// automated test</b> — see <c>WindowsDeviceKeyStoreTests</c> for where that line falls. And a caller must
|
|
/// treat <see cref="SaveAsync"/> as user-facing: it belongs on a UI thread, behind a button somebody
|
|
/// pressed, never on a background pass.
|
|
/// </para>
|
|
/// </remarks>
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|
{
|
|
/// <remarks>
|
|
/// Versioned, so a future change of algorithm or padding 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 gesture had stopped working.
|
|
/// </remarks>
|
|
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
|
|
|
private const string PlatformProvider = "Microsoft Platform Crypto Provider";
|
|
|
|
/// <remarks>Shown in the Windows consent dialog, so it has to read as a sentence to a person.</remarks>
|
|
private const string ConsentPrompt = "Unlock your DodoSSH vault";
|
|
|
|
private readonly ClientPaths paths;
|
|
|
|
/// <summary>Creates the store.</summary>
|
|
public WindowsDeviceKeyStore(ClientPaths paths)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
this.paths = paths;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this machine has a TPM the platform provider will hold a key in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Probed by creating a throwaway key and deleting it, rather than by asking the provider whether it
|
|
/// exists. The provider is registered on machines with no usable TPM as well, and reports itself
|
|
/// present right up to the point where creating a key fails — so the only honest test is the one that
|
|
/// does the thing. No UI policy on the probe, so nothing prompts.
|
|
/// </remarks>
|
|
internal static bool IsSupported()
|
|
{
|
|
var probe = $"DodoSSH.Probe.{Guid.CreateVersion7():N}";
|
|
|
|
try
|
|
{
|
|
using var key = CngKey.Create(
|
|
CngAlgorithm.Rsa,
|
|
probe,
|
|
new CngKeyCreationParameters { Provider = new CngProvider(PlatformProvider) });
|
|
|
|
key.Delete();
|
|
return true;
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
return false;
|
|
}
|
|
catch (PlatformNotSupportedException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
|
ValueTask.FromResult(IsSupported());
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask SaveAsync(
|
|
ReadOnlyMemory<byte> devicePrivateKey,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var key = OpenOrCreate();
|
|
using var rsa = new RSACng(key);
|
|
|
|
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
|
|
|
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);
|
|
}
|
|
|
|
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
|
{
|
|
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
|
key.Delete();
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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, the TPM may be locked out after too many wrong PINs,
|
|
/// 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 of them are worth telling apart here — see
|
|
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
|
/// </remarks>
|
|
private static byte[]? Unseal(byte[] sealedKey)
|
|
{
|
|
try
|
|
{
|
|
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
|
using var rsa = new RSACng(key);
|
|
|
|
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The UI policy is set at creation and cannot be added afterwards, which is why this opens an existing
|
|
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
|
/// silently is the one behaviour this whole file exists to prevent.
|
|
/// </remarks>
|
|
private static CngKey OpenOrCreate()
|
|
{
|
|
var provider = new CngProvider(PlatformProvider);
|
|
|
|
if (CngKey.Exists(KeyName, provider))
|
|
{
|
|
return CngKey.Open(KeyName, provider);
|
|
}
|
|
|
|
return CngKey.Create(
|
|
CngAlgorithm.Rsa,
|
|
KeyName,
|
|
new CngKeyCreationParameters
|
|
{
|
|
Provider = provider,
|
|
UIPolicy = new CngUIPolicy(CngUIProtectionLevels.ProtectKey, ConsentPrompt),
|
|
|
|
// Machine-wide would put one key behind every account on the computer. This key stands for
|
|
// "this user, on this machine", which is what a device wrap means.
|
|
KeyCreationOptions = CngKeyCreationOptions.None,
|
|
});
|
|
}
|
|
}
|