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;