Public Access
Add the client's SSH key model, codec, merge and cipher
The client can now seal and open an SSH key item. Nothing consumes it yet — the repository, the sync engine's per-type handling and the UI come next — but this is the layer everything above it depends on, and it is the layer where the crypto has to be right. SshKeySecret holds the private key as an ordinary string, deliberately, and says so: a .NET string cannot be wiped, so the material lives until the GC reuses the memory. libsodium's guarded memory was considered and rejected because the passphrase protecting the key, the password on the next item and the JSON the codec just parsed are all strings on the same heap — protecting one field among them reads as security and buys nothing. What the design does give is that the key never reaches the disk in plaintext, never reaches the server at all, and is handed to SSH.NET through a MemoryStream so there is no temporary key file to leak. Validation refuses a public key by name. ssh-keygen writes two files whose names differ by four characters, and pasting the wrong one otherwise produces a vault item that looks fine and fails at connection time with an authentication error that says nothing about which file you chose. The merge redacts the private key and its passphrase from the conflict log. A host conflict shows both values so the loser can be put back; doing that for a private key would write the discarded key into a log that is designed to be read rather than used and is deliberately retained after acknowledgement. Two different private keys are not something anyone reconciles by reading them side by side. And the lesson worth recording, because it nearly shipped: the first version of AadResourceTypeTests proved nothing. It checked that a key payload does not open as a host and vice versa — true however both ciphers are misconfigured, because Seal and TryOpen share one constant, so changing it changes both and the round trip still works. Sealing every private key as if it were a vault passed all twelve tests. The tests now open a sealed payload independently through ItemKeys with the resource type named out of band, and that does fail under the same sabotage. A test that only compares an implementation against itself cannot catch a self-consistent mistake. The trap it defends: SyncEntityType.SshKey is 3, AadResourceType.SshKey is 6, because the crypto enum also carries None, User, Device and Vault ahead of the item types. A cast between them is a specification violation that encrypts cleanly and would only surface when another implementation refused the item.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// An SSH key pair as the user sees it, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="HostSecret.Notes"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>DodoSSH.Crypto</c> 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 <c>LocalCacheProtector</c>, which says the same thing about the
|
||||
/// cache.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>MemoryStream</c> rather than a file, so there is no temporary key file to leak or forget.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SshKeySecret
|
||||
{
|
||||
/// <summary>What the user calls this key.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The private key, in the armoured form <c>ssh-keygen</c> writes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public required string PrivateKeyPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The passphrase protecting the private key, when it has one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public string? Passphrase { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The public half, in <c>authorized_keys</c> form, when it is known.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
/// <summary>Free text.</summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this key is storable, and why not if it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The public-key mix-up is checked explicitly because it is the mistake a person actually makes:
|
||||
/// <c>ssh-keygen</c> 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.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
error = "A key needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PrivateKeyPem))
|
||||
{
|
||||
error = "A key needs its private key material.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var material = PrivateKeyPem.TrimStart();
|
||||
|
||||
if (material.StartsWith("ssh-", StringComparison.Ordinal)
|
||||
|| material.StartsWith("ecdsa-", StringComparison.Ordinal))
|
||||
{
|
||||
error = "That is a public key. Paste the private key — the file without the .pub extension.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!material.StartsWith("-----BEGIN", StringComparison.Ordinal))
|
||||
{
|
||||
error = "That does not look like a private key; it should begin with \"-----BEGIN\".";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user