Public Access
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.
131 lines
4.5 KiB
C#
131 lines
4.5 KiB
C#
using System.Security.Cryptography;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.Sync;
|
|
|
|
/// <summary>
|
|
/// Turns an SSH key into an item payload and back.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Mirrors <see cref="HostCipher"/> exactly, including the rule that a payload is sealed at the version the
|
|
/// server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The resource type is the one thing not to copy.</b> The AAD binds it, and the two enums that name item
|
|
/// types do not agree: <c>SyncEntityType.SshKey</c> is 3 while <c>CryptoSpec.AadResourceType.SshKey</c> is 6,
|
|
/// because the crypto enum also carries None, User, Device and Vault ahead of the item types. Casting one to
|
|
/// the other would seal a private key under the resource type for a <em>vault</em> — which encrypts
|
|
/// perfectly, decrypts perfectly on the machine that wrote it, and is a specification violation that no test
|
|
/// would notice until an interoperating client refused the item. So it is named here as a constant, and
|
|
/// <c>AadResourceTypeTests</c> pins the pairing.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class SshKeyCipher
|
|
{
|
|
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.SshKey;
|
|
|
|
/// <summary>Encrypts an SSH key.</summary>
|
|
/// <param name="key">The key. Must be valid for storage.</param>
|
|
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
|
|
/// <param name="entityId">The item id, which the AAD binds.</param>
|
|
/// <param name="keyGeneration">The vault's current key generation.</param>
|
|
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
|
|
public static EncryptedPayload Seal(
|
|
SshKeySecret key,
|
|
ReadOnlySpan<byte> vaultKey,
|
|
Guid entityId,
|
|
uint keyGeneration,
|
|
int itemVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(key);
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
|
|
|
|
var plaintext = SshKeySecretCodec.Encode(key);
|
|
var dataKey = ItemKeys.CreateDataKey();
|
|
|
|
try
|
|
{
|
|
var dataKeyId = Guid.CreateVersion7();
|
|
|
|
var wrappedDataKey = ItemKeys.WrapDataKey(
|
|
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
|
|
|
|
var envelope = ItemKeys.SealPayload(
|
|
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
|
|
|
|
return new EncryptedPayload(
|
|
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(dataKey);
|
|
|
|
// The encoded key material, wiped. This is the one buffer in the client whose contents are a
|
|
// private key and which can actually be cleared — the strings the codec read it from cannot be.
|
|
CryptographicOperations.ZeroMemory(plaintext);
|
|
}
|
|
}
|
|
|
|
/// <summary>Decrypts an SSH key.</summary>
|
|
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
|
|
public static SshKeySecretDocument? TryOpen(
|
|
EncryptedPayload payload,
|
|
ReadOnlySpan<byte> vaultKey,
|
|
Guid entityId,
|
|
int itemVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
|
|
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var dataKey = ItemKeys.TryUnwrapDataKey(
|
|
vaultKey,
|
|
payload.WrappedDataKey,
|
|
Resource,
|
|
entityId,
|
|
payload.KeyGeneration,
|
|
(uint)itemVersion);
|
|
|
|
if (dataKey is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var plaintext = ItemKeys.TryOpenPayload(
|
|
dataKey,
|
|
payload.Envelope,
|
|
Resource,
|
|
entityId,
|
|
payload.DataKeyId,
|
|
payload.KeyGeneration,
|
|
(uint)itemVersion);
|
|
|
|
if (plaintext is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
return SshKeySecretCodec.TryDecode(plaintext, out var document) ? document : null;
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(plaintext);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(dataKey);
|
|
}
|
|
}
|
|
}
|