using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace DodoSSH.Client.Domain; /// A decoded bucket payload, together with the schema version it was written at. /// The bucket. /// The version the writing client used. public sealed record ObjectStoreSecretDocument(ObjectStoreSecret Store, int SchemaVersion) { /// public bool IsReadOnly => SchemaVersion > ObjectStoreSecretCodec.CurrentSchemaVersion; } /// /// Encodes and decodes the plaintext inside a bucket item's encrypted payload. /// /// /// Mirrors , for the same reasons and with the same guarantees. /// public static class ObjectStoreSecretCodec { /// The schema version this build writes. public const int CurrentSchemaVersion = 1; /// Serialises a bucket to the bytes that get sealed. /// The bucket is not valid for storage. public static byte[] Encode(ObjectStoreSecret store) { ArgumentNullException.ThrowIfNull(store); if (!store.TryValidate(out var reason)) { throw new ArgumentException(reason, nameof(store)); } var document = new ObjectStorePayloadDocument { SchemaVersion = CurrentSchemaVersion, Label = store.Label, Bucket = store.Bucket, AccessKeyId = store.AccessKeyId, SecretAccessKey = store.SecretAccessKey, Region = store.Region, Endpoint = store.Endpoint, UsePathStyle = store.UsePathStyle, Notes = store.Notes, }; return JsonSerializer.SerializeToUtf8Bytes( document, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument); } /// Parses a decrypted payload. /// public static bool TryDecode( ReadOnlySpan payload, [NotNullWhen(true)] out ObjectStoreSecretDocument? document) { document = null; ObjectStorePayloadDocument? parsed; try { parsed = JsonSerializer.Deserialize( payload, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument); } catch (JsonException) { return false; } if (parsed is null || parsed.SchemaVersion < 1) { return false; } var candidate = new ObjectStoreSecret { Label = parsed.Label ?? string.Empty, Bucket = parsed.Bucket ?? string.Empty, AccessKeyId = parsed.AccessKeyId ?? string.Empty, SecretAccessKey = parsed.SecretAccessKey ?? string.Empty, Region = parsed.Region, Endpoint = parsed.Endpoint, UsePathStyle = parsed.UsePathStyle, Notes = parsed.Notes, }; if (!candidate.TryValidate(out _)) { return false; } document = new ObjectStoreSecretDocument(candidate, parsed.SchemaVersion); return true; } } /// The serialised shape. Mutable and nullable because it models untrusted input. /// internal sealed class ObjectStorePayloadDocument { public int SchemaVersion { get; set; } public string? Label { get; set; } public string? Bucket { get; set; } public string? AccessKeyId { get; set; } public string? SecretAccessKey { get; set; } public string? Region { get; set; } public string? Endpoint { get; set; } public bool UsePathStyle { get; set; } public string? Notes { get; set; } } [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(ObjectStorePayloadDocument))] internal sealed partial class ObjectStorePayloadJsonContext : JsonSerializerContext;