using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace DodoSSH.Client.Domain; /// A decoded credential payload, together with the schema version it was written at. /// The credential. /// The version the writing client used. public sealed record CredentialSecretDocument(CredentialSecret Credential, int SchemaVersion) { /// public bool IsReadOnly => SchemaVersion > CredentialSecretCodec.CurrentSchemaVersion; } /// /// Encodes and decodes the plaintext inside a credential 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 credential 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 credential that looks valid downstream. /// public static class CredentialSecretCodec { /// The schema version this build writes. public const int CurrentSchemaVersion = 1; /// Serialises a credential to the bytes that get sealed. /// The credential is not valid for storage. public static byte[] Encode(CredentialSecret credential) { ArgumentNullException.ThrowIfNull(credential); if (!credential.TryValidate(out var reason)) { throw new ArgumentException(reason, nameof(credential)); } var document = new CredentialPayloadDocument { SchemaVersion = CurrentSchemaVersion, Label = credential.Label, Password = credential.Password, Username = credential.Username, Notes = credential.Notes, }; return JsonSerializer.SerializeToUtf8Bytes( document, CredentialPayloadJsonContext.Default.CredentialPayloadDocument); } /// Parses a decrypted payload. /// public static bool TryDecode( ReadOnlySpan payload, [NotNullWhen(true)] out CredentialSecretDocument? document) { document = null; CredentialPayloadDocument? parsed; try { parsed = JsonSerializer.Deserialize( payload, CredentialPayloadJsonContext.Default.CredentialPayloadDocument); } catch (JsonException) { return false; } if (parsed is null || parsed.SchemaVersion < 1) { return false; } var candidate = new CredentialSecret { Label = parsed.Label ?? string.Empty, Password = parsed.Password ?? string.Empty, Username = parsed.Username, Notes = parsed.Notes, }; if (!candidate.TryValidate(out _)) { return false; } document = new CredentialSecretDocument(candidate, parsed.SchemaVersion); return true; } } /// The serialised shape. Mutable and nullable because it models untrusted input. /// internal sealed class CredentialPayloadDocument { public int SchemaVersion { get; set; } public string? Label { get; set; } public string? Password { get; set; } public string? Username { get; set; } public string? Notes { get; set; } } [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(CredentialPayloadDocument))] internal sealed partial class CredentialPayloadJsonContext : JsonSerializerContext;