using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace DodoSSH.Client.Domain; /// A decoded Spelunk key payload, together with the schema version it was written at. /// The key. /// The version the writing client used. public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion) { /// public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion; } /// /// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload. /// /// /// Mirrors , for the same reasons and with the same guarantees. /// public static class SpelunkKeySecretCodec { /// 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(SpelunkKeySecret key) { ArgumentNullException.ThrowIfNull(key); if (!key.TryValidate(out var reason)) { throw new ArgumentException(reason, nameof(key)); } var document = new SpelunkKeyPayloadDocument { SchemaVersion = CurrentSchemaVersion, Label = key.Label, ApiBase = key.ApiBase, ApiKey = key.ApiKey, }; return JsonSerializer.SerializeToUtf8Bytes( document, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument); } /// Parses a decrypted payload. /// public static bool TryDecode( ReadOnlySpan payload, [NotNullWhen(true)] out SpelunkKeySecretDocument? document) { document = null; SpelunkKeyPayloadDocument? parsed; try { parsed = JsonSerializer.Deserialize( payload, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument); } catch (JsonException) { return false; } if (parsed is null || parsed.SchemaVersion < 1) { return false; } var candidate = new SpelunkKeySecret { Label = parsed.Label ?? string.Empty, ApiBase = parsed.ApiBase ?? string.Empty, ApiKey = parsed.ApiKey ?? string.Empty, }; // A key whose address is not in its normal form is refused rather than normalised here: the address // is what decides where the key may go, and a payload that disagrees with how every client writes it // is not one to guess about. if (!candidate.TryValidate(out _)) { return false; } document = new SpelunkKeySecretDocument(candidate, parsed.SchemaVersion); return true; } } /// The serialised shape. Mutable and nullable because it models untrusted input. /// internal sealed class SpelunkKeyPayloadDocument { public int SchemaVersion { get; set; } public string? Label { get; set; } public string? ApiBase { get; set; } public string? ApiKey { get; set; } } [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(SpelunkKeyPayloadDocument))] internal sealed partial class SpelunkKeyPayloadJsonContext : JsonSerializerContext;