using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// A decoded SSH key payload, together with the schema version it was written at.
/// The key.
/// The version the writing client used.
public sealed record SshKeySecretDocument(SshKeySecret Key, int SchemaVersion)
{
///
///
/// 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.
///
public bool IsReadOnly => SchemaVersion > SshKeySecretCodec.CurrentSchemaVersion;
}
///
/// Encodes and decodes the plaintext inside an SSH key item's encrypted payload.
///
///
/// Mirrors , 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.
///
public static class SshKeySecretCodec
{
/// The schema version this build writes.
public const int CurrentSchemaVersion = 1;
/// Serialises a key to the bytes that get sealed.
/// The key is not valid for storage.
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);
}
/// Parses a decrypted payload.
///
public static bool TryDecode(
ReadOnlySpan 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;
}
}
/// The serialised shape. Mutable and nullable because it models untrusted input.
///
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;