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;