using System.Buffers.Binary; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using DodoSSH.Client.Session; using Tpm2Lib; namespace DodoSSH.Client.App.Platform; /// Why the store is asking for a PIN. /// /// The two cases need different words in front of a person — one is choosing a PIN and the other is /// entering one — and the store is the only thing that knows which is happening. Passing an enum rather /// than two delegates keeps the prompt a single seam. /// public enum DevicePinPurpose { /// A PIN is being chosen, as part of registering this machine. Choosing, /// A PIN is being asked for, to unlock. Entering, } /// /// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks. /// /// /// /// The Linux answer to and MacDeviceKeyStore, and ADR 0007's /// bar is the one it has to clear: the enforcement must be the platform's rather than this process's. /// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's /// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file. /// /// /// Here the TPM holds the key and the TPM checks the PIN. The 32-byte device key is sealed into a /// keyedhash object whose userAuth is derived from the PIN, so unsealing is a command the TPM /// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any /// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user /// can ask; it cannot answer. /// /// /// And the TPM counts the failures, which is what makes a short PIN safe. The sealed object /// deliberately does not carry NoDA, so every wrong answer advances the dictionary-attack counter /// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and /// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has /// on this platform and cannot have in a file. /// /// /// ◆ What this does NOT match, stated because the difference is real and invisible. Windows Hello /// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the /// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is /// typed into a window this application draws, and a sufficiently determined attacker already running as /// the user could draw a convincing copy of it. The mitigation is that they could do the same for the /// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is /// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying /// parity. /// /// /// Availability still depends on reaching the device, and the package arranges that rather than asking /// the user to. Fedora leaves /dev/tpmrm0 as root:tss mode 0660, so without help this /// process could not open it on an ordinary desktop account. The RPM installs /// build/linux/71-dodossh-tpm.rules, which tags the device uaccess — logind then grants an /// ACL to whoever is logged in at the machine, and takes it away when they log out. Not over SSH and not /// to a user on an inactive VT. /// /// So a build run from a checkout, or one on a machine where an administrator has masked that rule, still /// finds the device unreadable and reports unavailable — which keeps the passphrase path exactly as it was. /// The rule carries its own reasoning, including the lockout denial-of-service it accepts; see ADR 0007 /// decision 4. /// /// [SupportedOSPlatform("linux")] public sealed class LinuxDeviceKeyStore : IDeviceKeyStore { /// What the blob on disk starts with, so a file from elsewhere is refused rather than parsed. private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray(); /// /// Domain separation for the PIN, so the value handed to the TPM is this application's and not the /// user's PIN as typed. /// /// /// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed /// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not /// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the /// blob and the machine — they have this constant too — and it costs one line. /// private const string PinLabel = "dodossh/devicekey/pin/v1"; private readonly ClientPaths paths; private readonly Func> askForPin; private readonly Func openDevice; /// Creates a store against this machine's TPM. /// Where the sealed blob lives. /// /// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is /// ordinary — see — and lands on the passphrase. /// public LinuxDeviceKeyStore( ClientPaths paths, Func> askForPin) : this(paths, askForPin, static () => new LinuxTpmDevice()) { } /// Creates a store against a named TPM, which is how the tests reach a simulator. /// /// The seam exists because this class is testable in a way the other two stores are not, and that is /// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised /// sealing would block forever on a dialog — WindowsDeviceKeyStoreTests records the ten minutes /// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be /// supplied and the whole path runs unattended. /// internal LinuxDeviceKeyStore( ClientPaths paths, Func> askForPin, Func openDevice) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(askForPin); ArgumentNullException.ThrowIfNull(openDevice); this.paths = paths; this.askForPin = askForPin; this.openDevice = openDevice; } /// /// The parent every sealed object hangs off, as a template rather than as a stored handle. /// /// /// /// Recreated from a fixed template on every use, and not made persistent. A persistent handle /// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by /// , and would collide with whatever else on the machine had chosen the same /// index. Deriving it costs a command and leaves nothing behind. /// /// /// ECC P-256 rather than RSA-2048, and that is a user-visible choice. The two are equally /// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is /// generated, not derived — and this runs on the unlock path, so it would show up as the application /// hanging every time somebody opened their vault. The prototype this was built from used RSA and that /// is exactly what it was changed for. /// /// /// NoDA is set here and deliberately not on the sealed object. The parent has no /// authValue to guess, so counting failures against it would only give an attacker a way to lock the /// machine's TPM out by asking for something they were never going to get. /// /// private static TpmPublic PrimaryTemplate => new( TpmAlgId.Sha256, ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA, null, new EccParms( new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb), new NullAsymScheme(), EccCurve.TpmEccNistP256, new NullKdfScheme()), new EccPoint()); /// /// The sealed object: bytes the TPM holds, released against an authValue the TPM checks. /// /// /// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. UserWithAuth is /// what makes the authValue the thing that opens it, and the deliberate absence of /// NoDA is what puts every wrong PIN on the dictionary-attack counter. Removing that one /// attribute would leave a four-digit secret that could be guessed at full speed by anything holding /// the blob and the machine, which is the whole of the protection this class offers. /// private static TpmPublic SealedTemplate => new( TpmAlgId.Sha256, ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth, null, new KeyedhashParms(new NullSchemeKeyedhash()), new Tpm2bDigestKeyedhash()); /// Whether this machine has a TPM this process may use. /// /// Probes by creating and flushing a primary rather than by looking for the device node, for the /// reason WindowsDeviceKeyStore.IsSupported gives about its own provider: the node can be /// present on a machine where the first real command then fails. Permissions are the common case here /// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at /// CreatePrimary. Neither is a fault and both mean the same thing. /// internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice()); /// internal static bool IsSupported(Func openDevice) { ArgumentNullException.ThrowIfNull(openDevice); try { using var device = openDevice(); device.Connect(); var tpm = new Tpm2(device); var primary = CreatePrimary(tpm); tpm.FlushContext(primary); return true; } catch (Exception exception) when (exception is not OutOfMemoryException) { // Every way this fails means the same thing to the caller: no device key here, so keep asking // for the passphrase. A missing device node throws IOException, a device this user may not // open throws UnauthorizedAccessException, and a TPM that refuses the command throws // TpmException — and on a machine with no TPM at all the constructor itself may throw before // any of that. return false; } } /// public ValueTask IsAvailableAsync(CancellationToken cancellationToken) => ValueTask.FromResult(IsSupported(openDevice)); /// /// /// Asks for a PIN first and writes nothing if the user declines, which matters more here than the /// symmetry suggests: SaveAsync is called as part of registering this machine, and a half-done /// registration would leave a wrap on the server that nothing can open. /// public async ValueTask SaveAsync( ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) { var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(pin)) { throw new OperationCanceledException("No PIN was chosen, so no device key was stored."); } var key = devicePrivateKey.ToArray(); // Off the calling thread, because every command below blocks on the chip. Creating the primary // alone is hundreds of milliseconds on real hardware, and this is called from the unlock path — // which on the desktop head is the UI thread. var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false); paths.EnsureCreated(); await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false); } /// Seals the key into the TPM and answers with the blob to store. private byte[] Seal(string pin, byte[] devicePrivateKey) { var auth = PinAuth(pin); try { using var device = openDevice(); device.Connect(); var tpm = new Tpm2(device); var primary = CreatePrimary(tpm); try { var priv = tpm.Create( primary, new SensitiveCreate(auth, devicePrivateKey), SealedTemplate, null, [], out var pub, out _, out _, out _); return Pack(pub, priv); } finally { tpm.FlushContext(primary); } } finally { CryptographicOperations.ZeroMemory(auth); CryptographicOperations.ZeroMemory(devicePrivateKey); } } /// /// /// /// One attempt per call, and a wrong PIN is not retried here. Every wrong answer advances the /// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget /// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who /// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a /// loop. /// /// /// A locked-out TPM is deliberately indistinguishable from a wrong PIN. /// asks for exactly that and the reasoning holds here: the /// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore /// rather than telling somebody something they can act on — the answer is still to type the /// passphrase. /// /// public async ValueTask TryLoadAsync(CancellationToken cancellationToken) { if (!File.Exists(paths.DeviceKeyFile)) { return null; } var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken) .ConfigureAwait(false); if (!TryUnpack(stored, out var pub, out var priv)) { return null; } // After the blob is known to be readable and before anything is asked of the user, because a // prompt raised over a file this cannot use is a question with no useful answer. var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(pin)) { return null; } return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false); } /// Asks the TPM to release the key, and answers null however it declines. private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv) { var auth = PinAuth(pin); try { using var device = openDevice(); device.Connect(); var tpm = new Tpm2(device); var primary = CreatePrimary(tpm); try { var loaded = tpm.Load(primary, priv, pub); try { return tpm[auth].Unseal(loaded); } finally { tpm.FlushContext(loaded); } } finally { tpm.FlushContext(primary); } } catch (Exception exception) when (exception is not OutOfMemoryException) { // TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away // between the availability check and now, which a suspend or a firmware update can do. return null; } finally { CryptographicOperations.ZeroMemory(auth); } } /// /// /// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission. /// The object lives nowhere but in that file — it was never made persistent inside the TPM, see /// — so the bytes are unrecoverable the moment it is gone. There is no /// TPM state to clean up and nothing left behind for a later install to trip over. /// public ValueTask ForgetAsync(CancellationToken cancellationToken) { if (File.Exists(paths.DeviceKeyFile)) { File.Delete(paths.DeviceKeyFile); } return ValueTask.CompletedTask; } /// Derives the TPM authValue from the PIN as typed. private static byte[] PinAuth(string pin) => SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}")); private static TpmHandle CreatePrimary(Tpm2 tpm) => tpm.CreatePrimary( TpmRh.Owner, new SensitiveCreate(), PrimaryTemplate, null, [], out _, out _, out _, out _); /// Puts the two halves of the sealed object in one file. /// /// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's /// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than /// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file /// left by another version, or by the Windows store on a dual-boot profile directory, is refused /// rather than fed to the TPM as a malformed structure. /// private static byte[] Pack(TpmPublic pub, TpmPrivate priv) { var publicPart = pub.GetTpmRepresentation(); var privatePart = priv.GetTpmRepresentation(); var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length]; var at = Magic.Length; Magic.CopyTo(packed.AsSpan()); BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length); at += 4; publicPart.CopyTo(packed.AsSpan(at)); at += publicPart.Length; BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length); at += 4; privatePart.CopyTo(packed.AsSpan(at)); return packed; } /// Reads back what wrote, or answers false. private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv) { pub = new TpmPublic(); priv = new TpmPrivate(); try { if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic)) { return false; } var at = Magic.Length; var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at)); at += 4; if (publicLength < 0 || at + publicLength + 4 > stored.Length) { return false; } var publicPart = stored.AsSpan(at, publicLength).ToArray(); at += publicLength; var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at)); at += 4; if (privateLength < 0 || at + privateLength != stored.Length) { return false; } var privatePart = stored.AsSpan(at, privateLength).ToArray(); pub = Marshaller.FromTpmRepresentation(publicPart); priv = Marshaller.FromTpmRepresentation(privatePart); return true; } catch (Exception exception) when (exception is not OutOfMemoryException) { // A blob that does not parse is a blob this machine cannot use, which is the same answer as no // blob at all. Throwing here would mean a corrupted file stopped the application from starting. return false; } } }