156 lines
9.0 KiB
C#
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;
|
|
}
|
|
}
|