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:
2026-07-29 15:33:28 +02:00
parent e93acc856f
commit c4dbd85da0
5 changed files with 683 additions and 0 deletions
@@ -0,0 +1,126 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded SSH key payload, together with the schema version it was written at.</summary>
/// <param name="Key">The key.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record SshKeySecretDocument(SshKeySecret Key, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
/// <remarks>
/// The consequence is sharper for a key than for a host. Re-encoding an item written by a newer client
/// drops the fields this build has no concept of — and if one of those fields were, say, a certificate
/// or a second key format, the item would still decrypt, still look complete, and no longer
/// authenticate. Refusing to write is the only safe answer.
/// </remarks>
public bool IsReadOnly => SchemaVersion > SshKeySecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside an SSH key item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="HostSecretCodec"/>, for the same reasons and with the same guarantees: JSON so a field
/// can be added without a migration, deterministic property order so an unchanged key does not look like a
/// change to the sync engine, and a separate mutable document type so a decode failure cannot produce a
/// half-built key that looks valid downstream.
/// </remarks>
public static class SshKeySecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a key to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The key is not valid for storage.</exception>
public static byte[] Encode(SshKeySecret key)
{
ArgumentNullException.ThrowIfNull(key);
if (!key.TryValidate(out var error))
{
throw new ArgumentException(error, nameof(key));
}
var document = new SshKeyPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = key.Label,
PrivateKeyPem = key.PrivateKeyPem,
Passphrase = key.Passphrase,
PublicKey = key.PublicKey,
Notes = key.Notes,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, SshKeyPayloadJsonContext.Default.SshKeyPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out SshKeySecretDocument? document)
{
document = null;
SshKeyPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, SshKeyPayloadJsonContext.Default.SshKeyPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new SshKeySecret
{
Label = parsed.Label ?? string.Empty,
PrivateKeyPem = parsed.PrivateKeyPem ?? string.Empty,
Passphrase = parsed.Passphrase,
PublicKey = parsed.PublicKey,
Notes = parsed.Notes,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new SshKeySecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class SshKeyPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? PrivateKeyPem { get; set; }
public string? Passphrase { get; set; }
public string? PublicKey { get; set; }
public string? Notes { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SshKeyPayloadDocument))]
internal sealed partial class SshKeyPayloadJsonContext : JsonSerializerContext;