Files

156 lines
9.0 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Security.Cryptography;
namespace QSafe.Core;
public sealed class VaultData
{
public int Format { get; set; } = 1;
public string Id { get; set; } = Guid.NewGuid().ToString();
public List<Entry> Entries { get; set; } = [];
[JsonExtensionData] public Dictionary<string, JsonElement>? Extra { get; set; }
}
public sealed class Entry
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Rev { get; set; } = Guid.NewGuid().ToString();
public string Title { get; set; } = "";
public string Notes { get; set; } = "";
public string Kind { get; set; } = "";
public List<Field> Fields { get; set; } = [];
public List<string> Tags { get; set; } = [];
public bool Star { get; set; }
public bool Deleted { get; set; }
public long Modified { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
[JsonExtensionData] public Dictionary<string, JsonElement>? Extra { get; set; }
[JsonIgnore] public string Category => Templates.Kind(this);
[JsonIgnore] public string Subtitle => Templates.Subtitle(this);
}
public sealed class Field
{
public string Name { get; set; } = "";
public string Type { get; set; } = "text";
public string Value { get; set; } = "";
[JsonExtensionData] public Dictionary<string, JsonElement>? Extra { get; set; }
[JsonIgnore] public bool Secret => !new[] { "login", "website", "url", "email", "phone", "text", "date" }.Contains(Type.ToLowerInvariant());
}
public static class Vault
{
public static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, MaxDepth = 64 };
public static byte[] Bytes(VaultData data) => JsonSerializer.SerializeToUtf8Bytes(data, Json);
public static T Copy<T>(T data) => JsonSerializer.Deserialize<T>(JsonSerializer.SerializeToUtf8Bytes(data, Json), Json)!;
public static VaultData Parse(byte[] bytes)
{
if (bytes.Length > VaultCrypto.MaxBytes) throw new InvalidDataException("Хранилище слишком большое.");
var d = JsonSerializer.Deserialize<VaultData>(bytes, Json) ?? throw new InvalidDataException("Пустое хранилище.");
// Required properties must be present; generated defaults are only for new objects.
using var raw = JsonDocument.Parse(bytes);
if (!raw.RootElement.TryGetProperty("format", out _) || !raw.RootElement.TryGetProperty("id", out _) || !raw.RootElement.TryGetProperty("entries", out _) || d.Format != 1 || string.IsNullOrWhiteSpace(d.Id) || d.Entries == null)
throw new InvalidDataException("Неверный формат хранилища.");
var ids = new HashSet<string>();
foreach (var node in raw.RootElement.GetProperty("entries").EnumerateArray())
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty("id", out _) || !node.TryGetProperty("rev", out _) || !node.TryGetProperty("fields", out _))
throw new InvalidDataException("Повреждённая карточка.");
foreach (var e in d.Entries)
if (e == null || string.IsNullOrEmpty(e.Id) || string.IsNullOrEmpty(e.Rev) || !ids.Add(e.Id) || e.Fields == null || e.Tags == null || e.Title == null || e.Notes == null || e.Fields.Any(f => f == null || f.Name == null || f.Type == null || f.Value == null) || e.Tags.Any(t => t == null))
throw new InvalidDataException("Повреждённая карточка.");
return d;
}
public static VaultData Read(byte[] envelope, VaultKey key)
{
var plain = VaultCrypto.Decrypt(envelope, key);
try { return Parse(plain); } finally { CryptographicOperations.ZeroMemory(plain); }
}
public static byte[] Encrypt(VaultData data, VaultKey key)
{
var plain = Bytes(data);
try { return VaultCrypto.Encrypt(plain, key); } finally { CryptographicOperations.ZeroMemory(plain); }
}
public static void Update(VaultData data, Entry entry)
{
entry.Rev = Guid.NewGuid().ToString(); entry.Modified = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
int i = data.Entries.FindIndex(e => e.Id == entry.Id);
if (i < 0) data.Entries.Add(entry); else data.Entries[i] = entry;
}
public static (VaultData Data, int Conflicts) Merge(VaultData? ancestor, VaultData local, VaultData remote)
{
if (local.Id != remote.Id || ancestor != null && ancestor.Id != local.Id)
throw new InvalidDataException("На сервере другое хранилище. Локальная база сохранена.");
var b = ancestor?.Entries.ToDictionary(e => e.Id) ?? [];
var l = local.Entries.ToDictionary(e => e.Id); var r = remote.Entries.ToDictionary(e => e.Id);
var result = Copy(local); result.Entries.Clear(); int conflicts = 0;
foreach (string id in l.Keys.Union(r.Keys))
{
l.TryGetValue(id, out var le); r.TryGetValue(id, out var re); b.TryGetValue(id, out var be);
Entry? chosen;
if (re == null) chosen = le;
else if (le == null || le.Rev == be?.Rev) chosen = re;
else if (re.Rev == be?.Rev || le.Rev == re.Rev) chosen = le;
else if (le.Deleted && re.Deleted) chosen = re;
else
{
conflicts++;
if (le.Deleted || re.Deleted)
{
chosen = Copy(le.Deleted ? re : le); chosen.Title += " · конфликт удаления"; chosen.Rev = Guid.NewGuid().ToString();
}
else
{
chosen = re;
var duplicate = Copy(le); duplicate.Id = Guid.NewGuid().ToString(); duplicate.Rev = Guid.NewGuid().ToString(); duplicate.Title += " · локальная версия (конфликт)";
result.Entries.Add(duplicate);
}
}
if (chosen != null) result.Entries.Add(Copy(chosen));
}
return (result, conflicts);
}
}
public static class Templates
{
public static readonly string[] Kinds = ["login", "bank", "email", "wifi", "server", "document", "note", "custom"];
public static readonly string[] Titles = ["Сайты и приложения", "Банковские карты", "Электронная почта", "Сети Wi-Fi", "Серверы", "Документы", "Заметки", "Другие карточки"];
public static readonly string[] Glyphs = ["\uE774", "\uE8C7", "\uE715", "\uE701", "\uE968", "\uE8A5", "\uE70B", "\uE713"];
public static readonly string[] Colors = ["#85AEFF", "#C4A2FF", "#FFBE83", "#7ED9C2", "#9EAFFF", "#EDAADE", "#F3D684", "#A9BBCD"];
public static int Index(string kind) => Math.Max(0, Array.IndexOf(Kinds, kind));
public static string Kind(Entry e)
{
if (Kinds.Contains(e.Kind)) return e.Kind;
if (e.Fields.Any(f => f.Type.Contains("card", StringComparison.OrdinalIgnoreCase))) return "bank";
if (e.Fields.Any(f => f.Name.Contains("ssid", StringComparison.OrdinalIgnoreCase) || f.Name.Contains("wi-fi", StringComparison.OrdinalIgnoreCase))) return "wifi";
return e.Fields.Any(f => f.Type.Equals("email", StringComparison.OrdinalIgnoreCase)) ? "email" : "login";
}
public static string Subtitle(Entry e)
{
foreach (var f in e.Fields.Where(f => f.Type is "website" or "url"))
if (Uri.TryCreate(f.Value.Contains("://") ? f.Value : "https://" + f.Value, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
return uri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? uri.Host[4..] : uri.Host;
return e.Fields.FirstOrDefault(f => f.Type is "login" or "email" && !string.IsNullOrWhiteSpace(f.Value))?.Value ?? e.Tags.FirstOrDefault() ?? Titles[Index(Kind(e))];
}
public static Entry Create(string kind)
{
var e = new Entry { Kind = kind };
(string, string)[] fields = kind switch
{
"bank" => [("Номер карты", "card"), ("Владелец", "text"), ("Срок действия", "date"), ("CVV / CVC", "pin"), ("PIN", "pin")],
"email" => [("Адрес почты", "email"), ("Пароль", "password"), ("Сайт", "website")],
"wifi" => [("Имя сети (SSID)", "text"), ("Пароль сети", "password")],
"server" => [("Сервер / адрес", "text"), ("Логин", "login"), ("Пароль", "password"), ("Порт", "text")],
"document" => [("Тип документа", "text"), ("Номер документа", "secret"), ("Владелец", "text"), ("Срок действия", "date")],
"note" => [], "custom" => [("Секретное поле", "secret")],
_ => [("Логин", "login"), ("Пароль", "password"), ("Сайт", "website")]
};
e.Fields = fields.Select(f => new Field { Name = f.Item1, Type = f.Item2 }).ToList();
return e;
}
}