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;
}
}