using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// An SSH key pair as the user sees it, decrypted. /// /// /// /// The private key is the most valuable thing this product stores, and it is held here as an ordinary /// string, in managed memory, exactly like and every passphrase in the /// client. That is a deliberate choice rather than an oversight, and it is worth being plain about what it /// does and does not give you. /// /// /// It does not give you a zeroable buffer. A .NET string cannot be wiped, so the key material lives until /// the garbage collector reuses the memory, and a process dump taken in the meantime contains it. The /// alternative — libsodium's guarded memory, which DodoSSH.Crypto uses for the vault keys — was /// considered and rejected here for one reason: the passphrase protecting this key, the password on the next /// item, and the decoded JSON the codec produced are all strings on the same heap. Protecting one field /// among them would be a measure that reads as security and buys nothing, and the honest place for that /// effort is the threat this design actually addresses, which is the server and the disk rather than another /// process running as the same user. See LocalCacheProtector, which says the same thing about the /// cache. /// /// /// What it does give you: the key never reaches the disk in plaintext and never reaches the server at all. /// It is sealed under the vault key before it leaves this type, and SSH.NET is handed it through a /// MemoryStream rather than a file, so there is no temporary key file to leak or forget. /// /// public sealed record SshKeySecret : IVaultSecret { private readonly string? passphrase; /// What the user calls this key. public required string Label { get; init; } /// /// The private key, in the armoured form ssh-keygen writes. /// /// /// Stored verbatim, including its header and trailer. Not reformatted, not re-encoded, not normalised: /// OpenSSH, PKCS#1 and PKCS#8 all round-trip through here untouched, and a client that rewrote them /// would eventually rewrite one it did not fully understand. /// public required string PrivateKeyPem { get; init; } /// /// The passphrase protecting the private key, when it has one. /// /// /// /// Kept with the key rather than typed per connection, which is the entire point of a vault: the /// passphrase defends the key file on a disk, and inside a vault the key is not on a disk. Storing both /// together means the vault passphrase is what protects them, which is the guarantee this product is /// built to make. A user who wants the second factor can leave this null and be prompted. /// /// /// An empty string is normalised to null, so there is exactly one way to say "no passphrase". /// Two spellings of one state cost more than they look: two clients that agree about a key and disagree /// only about which spelling they used would produce different payload bytes for an identical key and a /// spurious field conflict out of the merge, and Passphrase is not null would stop being a /// reliable answer to "is this key protected?" — which is what the interface reads to describe a key. /// Normalising here rather than at each call site means every way one can arrive lands on the same /// value: an editor whose box was left blank, a codec decoding another client's "", a merge /// picking one side. /// /// /// It is not a defence against SSH.NET, which was the original reason given here and turned out /// to be false: a passphrase handed to PrivateKeyFile for a key that has none is ignored, not /// rejected, and the connection succeeds. See docs/platform-flags.md. /// /// public string? Passphrase { get => passphrase; init => passphrase = string.IsNullOrEmpty(value) ? null : value; } /// /// The public half, in authorized_keys form, when it is known. /// /// /// Not derived from the private key, and deliberately optional. Deriving it means parsing every key /// format this type accepts verbatim, which is the thing above that it declines to do. It is worth /// keeping because installing a key on a host needs exactly this line, and a user who imported only a /// private key should be told the public half is missing rather than have one silently reconstructed. /// public string? PublicKey { get; init; } /// Free text. public string? Notes { get; init; } /// /// Whether this key is storable, and why not if it is not. /// /// /// The public-key mix-up is checked explicitly because it is the mistake a person actually makes: /// ssh-keygen writes two files whose names differ by four characters, and pasting the wrong one /// produces a vault item that looks fine and fails at connection time with an authentication error that /// says nothing about which file you chose. /// public bool TryValidate([NotNullWhen(false)] out string? reason) { if (string.IsNullOrWhiteSpace(Label)) { reason = "A key needs a name."; return false; } if (string.IsNullOrWhiteSpace(PrivateKeyPem)) { reason = "A key needs its private key material."; return false; } var material = PrivateKeyPem.TrimStart(); if (material.StartsWith("ssh-", StringComparison.Ordinal) || material.StartsWith("ecdsa-", StringComparison.Ordinal)) { reason = "That is a public key. Paste the private key — the file without the .pub extension."; return false; } if (!material.StartsWith("-----BEGIN", StringComparison.Ordinal)) { reason = "That does not look like a private key; it should begin with \"-----BEGIN\"."; return false; } reason = null; return true; } }