Initial commit: QSafe Android 1.1.0 and Windows 1.0.4
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Version>1.0.4</Version>
|
||||
<Authors>Kusoft</Authors>
|
||||
<Company>Kusoft</Company>
|
||||
<Copyright>Copyright © 2026 Kusoft</Copyright>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace QSafe.Core;
|
||||
|
||||
public sealed record LanBinding(string Ssid, bool IsPhysicalWifi, IPAddress Address, int PrefixLength, int InterfaceIndex);
|
||||
public interface ILanNetwork { LanBinding Current(Connection connection); }
|
||||
|
||||
public static class LanPolicy
|
||||
{
|
||||
public static bool PrivateIpv4(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily != AddressFamily.InterNetwork) return false;
|
||||
var b = address.GetAddressBytes();
|
||||
return b[0] == 10 || b[0] == 172 && b[1] is >= 16 and <= 31 || b[0] == 192 && b[1] == 168;
|
||||
}
|
||||
public static IPAddress Target(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url?.Trim(), UriKind.Absolute, out var uri) || uri.Scheme != "https" || uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0 || !IPAddress.TryParse(uri.Host, out var ip) || !PrivateIpv4(ip))
|
||||
throw new InvalidDataException("Для базы разрешён только HTTPS с локальным IPv4 Pi (10.x, 172.16–31.x или 192.168.x). Доменные и внешние адреса запрещены.");
|
||||
return ip;
|
||||
}
|
||||
public static void Check(Connection config, LanBinding binding)
|
||||
{
|
||||
var target = Target(config.Url);
|
||||
if (!binding.IsPhysicalWifi || binding.Ssid != config.HomeWifiSsid || !PrivateIpv4(binding.Address) || binding.InterfaceIndex <= 0 || binding.PrefixLength is < 16 or > 30)
|
||||
throw new IOException("Автономный режим: подключитесь к домашнему Wi-Fi «" + config.HomeWifiSsid + "». Подключение к базе не выполнялось.");
|
||||
var a = target.GetAddressBytes(); var b = binding.Address.GetAddressBytes();
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
int bits = Math.Clamp(binding.PrefixLength - i * 8, 0, 8); int mask = 255 << (8 - bits) & 255;
|
||||
if ((a[i] & mask) != (b[i] & mask)) throw new IOException("Pi должен находиться непосредственно в подсети домашнего Wi-Fi. Маршрутизация через интернет или VPN запрещена.");
|
||||
}
|
||||
uint hostMask = uint.MaxValue >> binding.PrefixLength;
|
||||
uint value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(a);
|
||||
if ((value & hostMask) == 0 || (value & hostMask) == hostMask || target.Equals(binding.Address))
|
||||
throw new IOException("Укажите отдельный адрес Pi в домашней подсети.");
|
||||
}
|
||||
public static bool CertificateMatches(X509Certificate? certificate, SslPolicyErrors errors, string pin)
|
||||
{
|
||||
if (certificate == null || (errors & (SslPolicyErrors.RemoteCertificateNameMismatch | SslPolicyErrors.RemoteCertificateNotAvailable)) != 0) return false;
|
||||
using var cert = new X509Certificate2(certificate);
|
||||
return DateTime.UtcNow >= cert.NotBefore.ToUniversalTime() && DateTime.UtcNow <= cert.NotAfter.ToUniversalTime()
|
||||
&& CryptographicOperations.FixedTimeEquals(SHA256.HashData(cert.RawData), Convert.FromHexString(pin));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace QSafe.Core;
|
||||
|
||||
public enum PasswordMode { Complex, Digits, Memorable }
|
||||
|
||||
public static class PasswordGenerator
|
||||
{
|
||||
private static readonly string[] Words = LoadWords();
|
||||
|
||||
private static string[] LoadWords()
|
||||
{
|
||||
using var stream = typeof(PasswordGenerator).Assembly.GetManifestResourceStream("QSafe.PasswordWords")
|
||||
?? throw new InvalidOperationException("Missing password word list");
|
||||
using var reader = new StreamReader(stream);
|
||||
var words = reader.ReadToEnd().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (words.Length != 256 || words.Distinct().Count() != 256) throw new InvalidOperationException("Invalid password word list");
|
||||
return words;
|
||||
}
|
||||
|
||||
public static string Generate(PasswordMode mode, int length, bool symbols = true, bool digits = true)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case PasswordMode.Complex: return VaultCrypto.Generate(length, symbols);
|
||||
case PasswordMode.Digits:
|
||||
if (length is < 4 or > 128) throw new ArgumentOutOfRangeException(nameof(length));
|
||||
return new string(Enumerable.Range(0, length).Select(_ => (char)('0' + RandomNumberGenerator.GetInt32(10))).ToArray());
|
||||
case PasswordMode.Memorable:
|
||||
if (length is < 2 or > 12) throw new ArgumentOutOfRangeException(nameof(length));
|
||||
const string signs = "!@#$%&*+-=?";
|
||||
// Treat each word as a whole: extras only go before, between or after words.
|
||||
var boundaries = Enumerable.Range(0, length + 1).Select(_ => new StringBuilder()).ToArray();
|
||||
void InsertAtBoundary(char character)
|
||||
{
|
||||
var boundary = boundaries[RandomNumberGenerator.GetInt32(boundaries.Length)];
|
||||
boundary.Insert(RandomNumberGenerator.GetInt32(boundary.Length + 1), character);
|
||||
}
|
||||
if (digits) for (int i = 0; i < 2; i++) InsertAtBoundary((char)('0' + RandomNumberGenerator.GetInt32(10)));
|
||||
if (symbols) for (int i = 1; i < length; i++) InsertAtBoundary(signs[RandomNumberGenerator.GetInt32(signs.Length)]);
|
||||
var value = new StringBuilder();
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
string word = Words[RandomNumberGenerator.GetInt32(Words.Length)];
|
||||
value.Append(boundaries[i]).Append(char.ToUpperInvariant(word[0])).Append(word.AsSpan(1));
|
||||
}
|
||||
return value.Append(boundaries[length]).ToString();
|
||||
default: throw new ArgumentOutOfRangeException(nameof(mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="../../shared/password-words.txt" LogicalName="QSafe.PasswordWords" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace QSafe.Core;
|
||||
|
||||
public sealed class Connection
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public string HomeWifiSsid { get; set; } = "";
|
||||
public string CertificateSha256 { get; set; } = "";
|
||||
public string Token { get; set; } = "";
|
||||
public string Etag { get; set; } = "";
|
||||
public DateTimeOffset? LastSync { get; set; }
|
||||
public bool AutoSync { get; set; } = true;
|
||||
public void Validate()
|
||||
{
|
||||
LanPolicy.Target(Url);
|
||||
Url = new Uri(Url.Trim()).AbsoluteUri.TrimEnd('/') + "/";
|
||||
if (string.IsNullOrWhiteSpace(HomeWifiSsid) || System.Text.Encoding.UTF8.GetByteCount(HomeWifiSsid) > 32)
|
||||
throw new InvalidDataException("Укажите точное имя домашнего Wi-Fi (SSID), до 32 байт.");
|
||||
if (CertificateSha256 == null || !Regex.IsMatch(CertificateSha256, "\\A[0-9a-fA-F]{64}\\z"))
|
||||
throw new InvalidDataException("Нужен SHA-256 локального сертификата Pi из личного файла подключения.");
|
||||
if (Token == null || !Regex.IsMatch(Token, "\\A[A-Za-z0-9_-]{32,128}\\z")) throw new InvalidDataException("Неверный ключ подключения.");
|
||||
}
|
||||
public static Connection Parse(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length > 65536) throw new InvalidDataException("Слишком большой файл подключения.");
|
||||
var c = JsonSerializer.Deserialize<Connection>(bytes, Vault.Json) ?? throw new InvalidDataException("Пустой файл подключения.");
|
||||
c.Validate(); c.Etag = ""; c.LastSync = null; return c;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SyncResult(VaultData Data, byte[] Envelope, int Conflicts);
|
||||
|
||||
public sealed class SyncClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient http;
|
||||
private readonly Connection connection;
|
||||
private readonly ILanNetwork network;
|
||||
public SyncClient(Connection config, ILanNetwork network, HttpMessageHandler? handler = null)
|
||||
{
|
||||
config.Validate(); connection = config; this.network = network;
|
||||
http = new HttpClient(handler ?? CreateHandler()) { Timeout = TimeSpan.FromSeconds(40) };
|
||||
}
|
||||
|
||||
private LanBinding RequireHome()
|
||||
{
|
||||
connection.Validate(); var binding = network.Current(connection); LanPolicy.Check(connection, binding); return binding;
|
||||
}
|
||||
private SocketsHttpHandler CreateHandler()
|
||||
{
|
||||
var handler = new SocketsHttpHandler { AllowAutoRedirect = false, UseProxy = false, PooledConnectionLifetime = TimeSpan.Zero, ConnectTimeout = TimeSpan.FromSeconds(10) };
|
||||
handler.SslOptions.CertificateRevocationCheckMode = X509RevocationMode.NoCheck;
|
||||
handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy { DisableCertificateDownloads = true, RevocationMode = X509RevocationMode.NoCheck };
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, _, errors) => LanPolicy.CertificateMatches(certificate, errors, connection.CertificateSha256);
|
||||
handler.ConnectCallback = async (context, ct) =>
|
||||
{
|
||||
var binding = RequireHome(); var ip = LanPolicy.Target(connection.Url);
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
try
|
||||
{
|
||||
const int IpUnicastIf = 31; // Windows ws2ipdef.h IP_UNICAST_IF; index is in network byte order.
|
||||
socket.SetSocketOption(SocketOptionLevel.IP, (SocketOptionName)IpUnicastIf, IPAddress.HostToNetworkOrder(binding.InterfaceIndex));
|
||||
socket.Bind(new IPEndPoint(binding.Address, 0));
|
||||
await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), ct);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch { socket.Dispose(); throw; }
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
public static async Task<byte[]> Bounded(Stream stream, int limit, CancellationToken ct = default)
|
||||
{
|
||||
using var output = new MemoryStream(); var buffer = new byte[8192]; int n;
|
||||
while ((n = await stream.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
if (output.Length + n > limit) throw new InvalidDataException("Файл превышает допустимый размер.");
|
||||
await output.WriteAsync(buffer.AsMemory(0, n), ct);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Blob, string Etag)> Request(HttpMethod method, byte[]? blob, string? etag, CancellationToken ct)
|
||||
{
|
||||
RequireHome(); // Applies to every manual, automatic, restore, GET and PUT request before any transport access.
|
||||
using var request = new HttpRequestMessage(method, connection.Url + "v1/vault");
|
||||
request.Headers.ConnectionClose = true;
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", connection.Token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
|
||||
if (blob != null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(blob); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
request.Headers.TryAddWithoutValidation(etag == null ? "If-None-Match" : "If-Match", etag == null ? "*" : '"' + etag + '"');
|
||||
}
|
||||
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) throw new IOException("Сервер отклонил ключ подключения.");
|
||||
if (response.StatusCode == HttpStatusCode.PreconditionFailed) throw new SyncConflictException();
|
||||
if (method == HttpMethod.Get && response.StatusCode == HttpStatusCode.NotFound) return (null, "");
|
||||
if (response.StatusCode != HttpStatusCode.OK) throw new IOException($"Сервер вернул HTTP {(int)response.StatusCode}.");
|
||||
var hash = response.Headers.ETag?.Tag.Trim('"') ?? "";
|
||||
if (method != HttpMethod.Get)
|
||||
{
|
||||
if (hash != VaultCrypto.Hash(blob!)) throw new IOException("Сервер не подтвердил контрольную сумму записи.");
|
||||
return (null, hash);
|
||||
}
|
||||
if (response.Content.Headers.ContentLength > VaultCrypto.MaxBytes) throw new InvalidDataException("Ответ сервера слишком большой.");
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
var bytes = await Bounded(stream, VaultCrypto.MaxBytes, ct); VaultCrypto.Validate(bytes);
|
||||
if (hash != VaultCrypto.Hash(bytes)) throw new InvalidDataException("Не совпадает контрольная сумма ответа сервера.");
|
||||
return (bytes, hash);
|
||||
}
|
||||
|
||||
public async Task<byte[]> Download(CancellationToken ct = default) => (await Request(HttpMethod.Get, null, null, ct)).Blob ?? throw new IOException("На сервере пока нет хранилища.");
|
||||
|
||||
public async Task<SyncResult> Sync(byte[] local, byte[]? ancestor, VaultKey key, CancellationToken ct = default)
|
||||
{
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
try { return await SyncOnce(local, ancestor, key, ct); }
|
||||
catch (SyncConflictException) when (attempt < 2) { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SyncResult> SyncOnce(byte[] local, byte[]? ancestor, VaultKey key, CancellationToken ct)
|
||||
{
|
||||
var remote = await Request(HttpMethod.Get, null, null, ct);
|
||||
var localData = Vault.Read(local, key); var localHash = VaultCrypto.Hash(local);
|
||||
if (remote.Blob == null)
|
||||
{
|
||||
if (connection.Etag.Length > 0 || ancestor != null) throw new IOException("Хранилище исчезло с сервера. Проверьте сервер и резервные копии.");
|
||||
await Request(HttpMethod.Put, local, null, ct); return new(localData, local, 0);
|
||||
}
|
||||
if (remote.Etag == localHash) return new(localData, local, 0);
|
||||
var remoteData = Vault.Read(remote.Blob, key);
|
||||
if (localData.Id != remoteData.Id) throw new InvalidDataException("На сервере другое хранилище. Сначала экспортируйте локальную копию.");
|
||||
if (remote.Etag == connection.Etag)
|
||||
{
|
||||
await Request(HttpMethod.Put, local, remote.Etag, ct); return new(localData, local, 0);
|
||||
}
|
||||
if (ancestor != null && VaultCrypto.Hash(ancestor) == localHash) return new(remoteData, remote.Blob, 0);
|
||||
var merged = Vault.Merge(ancestor == null ? null : Vault.Read(ancestor, key), localData, remoteData);
|
||||
var next = Vault.Encrypt(merged.Data, key);
|
||||
await Request(HttpMethod.Put, next, remote.Etag, ct);
|
||||
return new(merged.Data, next, merged.Conflicts);
|
||||
}
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
public sealed class SyncConflictException() : IOException("База на сервере меняется. Повторите синхронизацию.");
|
||||
@@ -0,0 +1,155 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QSafe.Core;
|
||||
|
||||
public sealed class VaultKey(byte[] bytes, byte[] salt, int iterations) : IDisposable
|
||||
{
|
||||
public byte[] Bytes { get; } = bytes;
|
||||
public byte[] Salt { get; } = salt;
|
||||
public int Iterations { get; } = iterations;
|
||||
public void Dispose() => CryptographicOperations.ZeroMemory(Bytes);
|
||||
}
|
||||
|
||||
public static class VaultCrypto
|
||||
{
|
||||
public const int Iterations = 600_000;
|
||||
public const int MaxBytes = 32 * 1024 * 1024;
|
||||
private static ReadOnlySpan<byte> Magic => "QSAFE01!"u8;
|
||||
|
||||
public static void Validate(ReadOnlySpan<byte> envelope)
|
||||
{
|
||||
if (envelope.Length < 56 || envelope.Length > MaxBytes || !envelope[..8].SequenceEqual(Magic))
|
||||
throw new InvalidDataException("Неверный или повреждённый файл QSafe.");
|
||||
if (BinaryPrimitives.ReadInt32BigEndian(envelope[8..]) is < Iterations or > 2_000_000)
|
||||
throw new InvalidDataException("Неподдерживаемые параметры шифрования.");
|
||||
}
|
||||
|
||||
private static VaultKey Derive(ReadOnlySpan<char> password, byte[] salt, int iterations) =>
|
||||
new(Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, 32), salt, iterations);
|
||||
|
||||
public static VaultKey Create(ReadOnlySpan<char> password) => Derive(password, RandomNumberGenerator.GetBytes(16), Iterations);
|
||||
|
||||
public static VaultKey Unlock(byte[] envelope, ReadOnlySpan<char> password)
|
||||
{
|
||||
Validate(envelope);
|
||||
var key = Derive(password, envelope[12..28], BinaryPrimitives.ReadInt32BigEndian(envelope.AsSpan(8)));
|
||||
try
|
||||
{
|
||||
var plain = Decrypt(envelope, key);
|
||||
CryptographicOperations.ZeroMemory(plain);
|
||||
return key;
|
||||
}
|
||||
catch { key.Dispose(); throw; }
|
||||
}
|
||||
|
||||
public static byte[] Encrypt(ReadOnlySpan<byte> plain, VaultKey key)
|
||||
{
|
||||
if (plain.Length > MaxBytes - 56) throw new InvalidDataException("Хранилище превышает 32 МиБ.");
|
||||
var envelope = new byte[plain.Length + 56];
|
||||
Magic.CopyTo(envelope);
|
||||
BinaryPrimitives.WriteInt32BigEndian(envelope.AsSpan(8), key.Iterations);
|
||||
key.Salt.CopyTo(envelope, 12);
|
||||
RandomNumberGenerator.Fill(envelope.AsSpan(28, 12));
|
||||
using var aes = new AesGcm(key.Bytes, 16);
|
||||
aes.Encrypt(envelope.AsSpan(28, 12), plain, envelope.AsSpan(40, plain.Length), envelope.AsSpan(40 + plain.Length, 16), envelope.AsSpan(0, 40));
|
||||
return envelope;
|
||||
}
|
||||
|
||||
public static byte[] Decrypt(byte[] envelope, VaultKey key)
|
||||
{
|
||||
Validate(envelope);
|
||||
if (!CryptographicOperations.FixedTimeEquals(key.Salt, envelope.AsSpan(12, 16)) || key.Iterations != BinaryPrimitives.ReadInt32BigEndian(envelope.AsSpan(8)))
|
||||
throw new CryptographicException("У хранилища другой ключ или мастер-пароль.");
|
||||
var plain = new byte[envelope.Length - 56];
|
||||
using var aes = new AesGcm(key.Bytes, 16);
|
||||
try
|
||||
{
|
||||
aes.Decrypt(envelope.AsSpan(28, 12), envelope.AsSpan(40, plain.Length), envelope.AsSpan(envelope.Length - 16), plain, envelope.AsSpan(0, 40));
|
||||
return plain;
|
||||
}
|
||||
catch { CryptographicOperations.ZeroMemory(plain); throw; }
|
||||
}
|
||||
|
||||
public static string Hash(byte[] data) => Convert.ToHexStringLower(SHA256.HashData(data));
|
||||
|
||||
public static string Generate(int length = 24, bool symbols = true)
|
||||
{
|
||||
if (length is < 16 or > 128) throw new ArgumentOutOfRangeException(nameof(length));
|
||||
string[] groups = symbols ? ["ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnopqrstuvwxyz", "23456789", "!@#$%&*+-=?"] : ["ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnopqrstuvwxyz", "23456789"];
|
||||
var alphabet = string.Concat(groups);
|
||||
var result = new char[length];
|
||||
for (int i = 0; i < length; i++) result[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
|
||||
for (int i = 0; i < groups.Length; i++) result[i] = groups[i][RandomNumberGenerator.GetInt32(groups[i].Length)];
|
||||
for (int i = length - 1; i > 0; i--) { int j = RandomNumberGenerator.GetInt32(i + 1); (result[i], result[j]) = (result[j], result[i]); }
|
||||
return new string(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace QSafe.Core;
|
||||
|
||||
public sealed record ImportPreview(VaultData Data, int Added, int Duplicates, int Skipped, int Attachments);
|
||||
|
||||
public static class XmlImport
|
||||
{
|
||||
public static ImportPreview Preview(byte[] bytes, VaultData current)
|
||||
{
|
||||
if (bytes.Length > 24 * 1024 * 1024) throw new InvalidDataException("XML превышает 24 МиБ.");
|
||||
using var stream = new MemoryStream(bytes);
|
||||
using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 32 * 1024 * 1024 });
|
||||
var doc = XDocument.Load(reader);
|
||||
if (doc.Root?.Name.LocalName != "database") throw new InvalidDataException("Это не XML-экспорт SafeInCloud.");
|
||||
var data = Vault.Copy(current);
|
||||
var labels = doc.Root.Elements("label").ToDictionary(e => (string?)e.Attribute("id") ?? "", e => (string?)e.Attribute("name") ?? "");
|
||||
var fingerprints = new HashSet<string>(data.Entries.Select(e => e.Extra != null && e.Extra.TryGetValue("safeInCloudFingerprint", out var fp) && fp.ValueKind == JsonValueKind.String ? fp.GetString() ?? "" : ""));
|
||||
int added = 0, duplicates = 0, skipped = 0, attachments = 0;
|
||||
foreach (var card in doc.Root.Elements("card"))
|
||||
{
|
||||
bool Flag(string name) => ((string?)card.Attribute(name)) is "true" or "1";
|
||||
if (Flag("deleted") || Flag("template")) { skipped++; continue; }
|
||||
var xml = card.ToString(SaveOptions.DisableFormatting);
|
||||
// Preserve Android's original XML; XML serializers may use different formatting across platforms.
|
||||
var fingerprint = VaultCrypto.Hash(System.Text.Encoding.UTF8.GetBytes(xml));
|
||||
bool sameSource = data.Entries.Any(e => e.Extra != null && e.Extra.TryGetValue("safeInCloudXml", out var source) && Equivalent(source.GetString(), card));
|
||||
if (fingerprints.Contains(fingerprint) || sameSource) { duplicates++; continue; }
|
||||
var entry = new Entry { Title = (string?)card.Attribute("title") ?? "Без названия", Star = Flag("star"), Notes = (string?)card.Element("notes") ?? "" };
|
||||
entry.Fields = card.Elements("field").Select(f => new Field { Name = (string?)f.Attribute("name") ?? "", Type = (string?)f.Attribute("type") ?? "text", Value = f.Value }).ToList();
|
||||
entry.Tags = card.Elements("label_id").Select(e => labels.GetValueOrDefault(e.Value.Trim(), "Метка " + e.Value.Trim())).ToList();
|
||||
var files = card.Elements().Where(e => e.Name.LocalName is "file" or "image").Select(e => new { kind = e.Name.LocalName, name = (string?)e.Attribute("name") ?? "", data = e.Value }).ToArray();
|
||||
entry.Extra = new() { ["safeInCloudXml"] = JsonSerializer.SerializeToElement(xml), ["safeInCloudFingerprint"] = JsonSerializer.SerializeToElement(fingerprint), ["attachments"] = JsonSerializer.SerializeToElement(files) };
|
||||
attachments += files.Length; data.Entries.Add(entry); fingerprints.Add(fingerprint); added++;
|
||||
}
|
||||
return new(data, added, duplicates, skipped, attachments);
|
||||
}
|
||||
private static bool Equivalent(string? source, XElement card)
|
||||
{
|
||||
if (source == null) return false;
|
||||
try
|
||||
{
|
||||
using var text = new StringReader(source);
|
||||
using var reader = XmlReader.Create(text, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 32 * 1024 * 1024 });
|
||||
return XNode.DeepEquals(XElement.Load(reader), card);
|
||||
}
|
||||
catch (XmlException) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<Application x:Class="QSafe.Desktop.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
<SolidColorBrush x:Key="Canvas" Color="#101216" />
|
||||
<SolidColorBrush x:Key="Panel" Color="#171A20" />
|
||||
<SolidColorBrush x:Key="Raised" Color="#20242C" />
|
||||
<SolidColorBrush x:Key="Line" Color="#2C313B" />
|
||||
<SolidColorBrush x:Key="Ink" Color="#F0F2F6" />
|
||||
<SolidColorBrush x:Key="Muted" Color="#969EAD" />
|
||||
<SolidColorBrush x:Key="Accent" Color="#A5E4BC" />
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{StaticResource Canvas}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Ink}" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
<Style TargetType="TextBlock"><Setter Property="TextWrapping" Value="Wrap" /></Style>
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="9" /><Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="Transparent"><Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton><RepeatButton Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="False" /></Track.DecreaseRepeatButton>
|
||||
<Track.Thumb><Thumb><Thumb.Template><ControlTemplate TargetType="Thumb"><Border Background="#424B57" CornerRadius="4" Margin="2,0" /></ControlTemplate></Thumb.Template></Thumb></Track.Thumb>
|
||||
<Track.IncreaseRepeatButton><RepeatButton Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="False" /></Track.IncreaseRepeatButton>
|
||||
</Track></Grid>
|
||||
</ControlTemplate></Setter.Value></Setter>
|
||||
</Style>
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" /><Setter Property="Foreground" Value="{StaticResource Ink}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}" /><Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="14,10" /><Setter Property="Cursor" Value="Hand" /><Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" /><Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Body" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Body" Property="Opacity" Value="0.78" /></Trigger>
|
||||
<Trigger Property="IsPressed" Value="True"><Setter TargetName="Body" Property="Opacity" Value="0.6" /></Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False"><Setter TargetName="Body" Property="Opacity" Value="0.4" /></Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Body" Property="BorderBrush" Value="{StaticResource Accent}" /></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate></Setter.Value></Setter>
|
||||
</Style>
|
||||
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" /><Setter Property="Foreground" Value="#10251A" /><Setter Property="BorderBrush" Value="{StaticResource Accent}" /><Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style x:Key="Quiet" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"><Setter Property="Background" Value="Transparent" /><Setter Property="BorderBrush" Value="Transparent" /></Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Background" Value="#12151A" /><Setter Property="Foreground" Value="{StaticResource Ink}" /><Setter Property="CaretBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}" /><Setter Property="BorderThickness" Value="1" /><Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="FontSize" Value="14" /><Setter Property="VerticalContentAlignment" Value="Center" /><Setter Property="SelectionBrush" Value="#42694F" />
|
||||
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="Box" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1" CornerRadius="8">
|
||||
<ScrollViewer x:Name="PART_ContentHost" VerticalScrollBarVisibility="Hidden" HorizontalScrollBarVisibility="Hidden" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers><Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource Accent}" /></Trigger></ControlTemplate.Triggers>
|
||||
</ControlTemplate></Setter.Value></Setter>
|
||||
</Style>
|
||||
<Style TargetType="PasswordBox">
|
||||
<Setter Property="Background" Value="#12151A" /><Setter Property="Foreground" Value="{StaticResource Ink}" /><Setter Property="CaretBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}" /><Setter Property="BorderThickness" Value="1" /><Setter Property="Padding" Value="12,12" /><Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="PasswordBox">
|
||||
<Border x:Name="Box" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1" CornerRadius="8"><ScrollViewer x:Name="PART_ContentHost" VerticalScrollBarVisibility="Hidden" HorizontalScrollBarVisibility="Hidden" /></Border>
|
||||
<ControlTemplate.Triggers><Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource Accent}" /></Trigger></ControlTemplate.Triggers>
|
||||
</ControlTemplate></Setter.Value></Setter>
|
||||
</Style>
|
||||
<Style TargetType="ListBox">
|
||||
<Setter Property="Background" Value="Transparent" /><Setter Property="BorderThickness" Value="0" /><Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ItemContainerStyle"><Setter.Value><Style TargetType="ListBoxItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" /><Setter Property="Padding" Value="0" /><Setter Property="Margin" Value="0,0,0,5" />
|
||||
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="ListBoxItem"><Border x:Name="Item" CornerRadius="9" BorderThickness="1" BorderBrush="Transparent" Background="Transparent" Padding="12,14"><ContentPresenter /></Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Item" Property="Background" Value="#21262E" /></Trigger>
|
||||
<Trigger Property="IsSelected" Value="True"><Setter TargetName="Item" Property="Background" Value="#26372E" /><Setter TargetName="Item" Property="BorderBrush" Value="#466452" /></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate></Setter.Value></Setter>
|
||||
</Style></Setter.Value></Setter>
|
||||
</Style>
|
||||
<Style TargetType="CheckBox"><Setter Property="Foreground" Value="{StaticResource Ink}" /><Setter Property="FontSize" Value="14" /><Setter Property="Margin" Value="0,8" /><Setter Property="Cursor" Value="Hand" /></Style>
|
||||
<Style TargetType="ScrollViewer"><Setter Property="VerticalScrollBarVisibility" Value="Auto" /><Setter Property="HorizontalScrollBarVisibility" Value="Disabled" /></Style>
|
||||
<Style TargetType="ToolTip"><Setter Property="Background" Value="#29312D" /><Setter Property="Foreground" Value="{StaticResource Ink}" /><Setter Property="Padding" Value="10,6" /></Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Interop;
|
||||
using QSafe.Core;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private Mutex? single;
|
||||
private bool ownsMutex;
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
bool smoke = e.Args.Contains("--smoke-test");
|
||||
bool demo = smoke || e.Args.Contains("--demo");
|
||||
var path = demo ? Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")) : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kusoft", "QSafe");
|
||||
single = new Mutex(false, "Local\\QSafe-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(path)))[..20]);
|
||||
try { ownsMutex = single.WaitOne(0); } catch (AbandonedMutexException) { ownsMutex = true; }
|
||||
if (!ownsMutex) { MessageBox.Show("QSafe уже запущен. Откройте существующее окно.", "QSafe"); Shutdown(); return; }
|
||||
try
|
||||
{
|
||||
var store = new LocalStore(path);
|
||||
int connectionIndex = Array.IndexOf(e.Args, "--connection");
|
||||
if (!demo && connectionIndex >= 0)
|
||||
{
|
||||
if (connectionIndex + 1 >= e.Args.Length) throw new InvalidDataException("Укажите личный файл локального подключения.");
|
||||
store.Configure(Connection.Parse(LocalStore.ReadBounded(e.Args[connectionIndex + 1], 65536)));
|
||||
}
|
||||
if (!demo && e.Args.Contains("--configure-only")) { Shutdown(); return; }
|
||||
MainWindow = new MainWindow(store, demo); MainWindow.Show();
|
||||
if (smoke)
|
||||
{
|
||||
var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
timer.Tick += (_, _) => { timer.Stop(); MainWindow.Close(); }; timer.Start();
|
||||
}
|
||||
}
|
||||
catch { MessageBox.Show("Не удалось открыть локальное хранилище или защищённые настройки. Данные не изменены. Проверьте доступ к папке %LOCALAPPDATA%\\Kusoft\\QSafe.", "QSafe", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(1); }
|
||||
}
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
if (ownsMutex) single?.ReleaseMutex(); single?.Dispose(); base.OnExit(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static class NativeWindow
|
||||
{
|
||||
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
|
||||
[DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint affinity);
|
||||
public static void Style(Window window)
|
||||
{
|
||||
window.SourceInitialized += (_, _) =>
|
||||
{
|
||||
var handle = new WindowInteropHelper(window).Handle; int dark = 1;
|
||||
DwmSetWindowAttribute(handle, 20, ref dark, sizeof(int));
|
||||
// Demo contains only synthetic credentials and can be captured for interface review.
|
||||
SetProtection(window, !(Application.Current.MainWindow is MainWindow main && main.IsDemo));
|
||||
};
|
||||
}
|
||||
public static void SetProtection(Window window, bool enabled)
|
||||
{
|
||||
var handle = new WindowInteropHelper(window).Handle;
|
||||
if (handle != IntPtr.Zero) SetWindowDisplayAffinity(handle, enabled ? 0x11u : 0u);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,225 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Microsoft.Win32;
|
||||
using QSafe.Core;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
public class AppDialog : Window
|
||||
{
|
||||
public StackPanel Body { get; } = new();
|
||||
public StackPanel Actions { get; } = new() { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||
public bool Forced { get; private set; }
|
||||
public bool Accepted { get; private set; }
|
||||
public AppDialog(Window owner, string title, string subtitle = "", double width = 600)
|
||||
{
|
||||
Owner = owner; Style = (Style)Application.Current.FindResource(typeof(Window)); Title = title + " — QSafe"; Width = width; SizeToContent = SizeToContent.Height;
|
||||
MaxHeight = Math.Max(600, owner.ActualHeight - 40); MinHeight = 240; WindowStartupLocation = WindowStartupLocation.CenterOwner; ResizeMode = ResizeMode.NoResize; ShowInTaskbar = false;
|
||||
NativeWindow.Style(this);
|
||||
var root = new Grid { Margin = new Thickness(28) };
|
||||
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
var heading = new StackPanel(); heading.Children.Add(Ui.Text(title, 25, "#F0F2F6", FontWeights.SemiBold));
|
||||
if (subtitle.Length > 0) { var hint = Ui.Text(subtitle, 13, "#969EAD"); hint.Margin = new Thickness(0, 10, 0, 0); heading.Children.Add(hint); }
|
||||
heading.Margin = new Thickness(0, 0, 0, 12); root.Children.Add(heading);
|
||||
var scroll = new ScrollViewer { Content = Body, MaxHeight = Math.Max(310, owner.ActualHeight - 260), Padding = new Thickness(0, 0, 12, 3) }; Grid.SetRow(scroll, 1); root.Children.Add(scroll);
|
||||
Actions.Margin = new Thickness(0, 23, 0, 0); Grid.SetRow(Actions, 2); root.Children.Add(Actions); Content = root;
|
||||
PreviewKeyDown += (_, e) => { if (e.Key == Key.Escape) { Close(); e.Handled = true; } };
|
||||
}
|
||||
public void Accept() { Accepted = true; DialogResult = true; }
|
||||
public void ForceClose() { Forced = true; Close(); }
|
||||
public void AddCancel(string title = "Отмена") { var button = Ui.Button(title, Close, "Quiet"); button.Margin = new Thickness(0, 0, 10, 0); Actions.Children.Add(button); }
|
||||
}
|
||||
|
||||
public sealed class SecretInput : Grid
|
||||
{
|
||||
private readonly PasswordBox password;
|
||||
private readonly TextBox text;
|
||||
private bool revealed;
|
||||
public string Value => revealed ? text.Text : password.Password;
|
||||
public SecretInput(string value, string label)
|
||||
{
|
||||
ColumnDefinitions.Add(new ColumnDefinition()); ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
password = new PasswordBox { Password = value }; text = new TextBox { Visibility = Visibility.Collapsed };
|
||||
System.Windows.Automation.AutomationProperties.SetName(password, label); System.Windows.Automation.AutomationProperties.SetName(text, label + " (показано)");
|
||||
Children.Add(password); Children.Add(text);
|
||||
var reveal = Ui.IconButton("\uE890", "Показать / скрыть «" + label + "»", () =>
|
||||
{
|
||||
if (revealed) { password.Password = text.Text; text.Clear(); } else { text.Text = password.Password; password.Clear(); }
|
||||
revealed = !revealed; text.Visibility = revealed ? Visibility.Visible : Visibility.Collapsed; password.Visibility = revealed ? Visibility.Collapsed : Visibility.Visible;
|
||||
}); Grid.SetColumn(reveal, 1); Children.Add(reveal);
|
||||
}
|
||||
public void Set(string value) { if (revealed) text.Text = value; else password.Password = value; }
|
||||
}
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private bool Confirm(string title, string explanation)
|
||||
{
|
||||
var dialog = new AppDialog(this, title, explanation, 530); dialog.AddCancel(); dialog.Actions.Children.Add(Ui.Button("Продолжить", dialog.Accept, "Primary"));
|
||||
return dialog.ShowDialog() == true;
|
||||
}
|
||||
private void AddEntry()
|
||||
{
|
||||
if (key == null || busy) return;
|
||||
var dialog = new AppDialog(this, "Новая карточка", "Выберите, что хотите сохранить.", 630);
|
||||
var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition()); grid.ColumnDefinitions.Add(new ColumnDefinition()); string? kind = null;
|
||||
for (int i = 0; i < Templates.Kinds.Length; i++)
|
||||
{
|
||||
int index = i; if (i % 2 == 0) grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
var button = Ui.Button(Templates.Titles[i], () => { kind = Templates.Kinds[index]; dialog.Accept(); }); button.Margin = new Thickness(0, 7, 10, 3); button.Padding = new Thickness(13, 16, 13, 16); button.HorizontalContentAlignment = HorizontalAlignment.Left;
|
||||
var label = Ui.Text(Templates.Titles[i], 13); label.Margin = new Thickness(12, 0, 0, 0); label.VerticalAlignment = VerticalAlignment.Center;
|
||||
button.Content = Ui.Row(Ui.Tile(Templates.Glyphs[i], Templates.Colors[i], 34), label); Grid.SetColumn(button, i % 2); Grid.SetRow(button, i / 2); grid.Children.Add(button);
|
||||
}
|
||||
dialog.Body.Children.Add(grid); dialog.AddCancel();
|
||||
if (dialog.ShowDialog() == true && kind != null && key != null) EditEntry(Templates.Create(kind), true);
|
||||
}
|
||||
private void EditEntry(Entry original, bool isNew = false)
|
||||
{
|
||||
if (key == null || busy) return;
|
||||
var draft = Vault.Copy(original);
|
||||
var dialog = new AppDialog(this, isNew ? "Добавить карточку" : "Изменить карточку", Templates.Titles[Templates.Index(draft.Category)], 650);
|
||||
var title = Ui.Input(dialog.Body, "Название", draft.Title); title.MaxLength = 500;
|
||||
var star = new CheckBox { Content = "В избранном", IsChecked = draft.Star, Margin = new Thickness(0, 15, 0, 0) }; dialog.Body.Children.Add(star);
|
||||
var fieldPanel = new StackPanel(); dialog.Body.Children.Add(fieldPanel);
|
||||
var readers = new List<Func<Field>>();
|
||||
void RenderFields()
|
||||
{
|
||||
fieldPanel.Children.Clear(); readers.Clear();
|
||||
for (int i = 0; i < draft.Fields.Count; i++)
|
||||
{
|
||||
int index = i; var field = draft.Fields[i]; var group = new StackPanel();
|
||||
var line = new DockPanel(); var delete = Ui.IconButton("\uE74D", "Удалить поле «" + field.Name + "»", () => { CaptureFields(); draft.Fields.RemoveAt(index); RenderFields(); }); DockPanel.SetDock(delete, Dock.Right); line.Children.Add(delete);
|
||||
var name = new TextBox { Text = field.Name, FontSize = 12, Padding = new Thickness(9, 7, 9, 7), Margin = new Thickness(0, 0, 8, 0) }; System.Windows.Automation.AutomationProperties.SetName(name, "Название поля " + (i + 1)); line.Children.Add(name); group.Children.Add(line);
|
||||
FrameworkElement editor; Func<string> read;
|
||||
if (field.Secret)
|
||||
{
|
||||
var secret = new SecretInput(field.Value, field.Name); editor = secret; read = () => secret.Value;
|
||||
if (field.Type == "password")
|
||||
{
|
||||
var generate = Ui.Button("Сгенерировать пароль", () => ShowGenerator(secret.Set), "Quiet"); generate.FontSize = 11; generate.HorizontalAlignment = HorizontalAlignment.Left; generate.Padding = new Thickness(0, 8, 10, 0); generate.Margin = new Thickness(0, 0, 0, 4);
|
||||
editor.Margin = new Thickness(0, 7, 0, 0); group.Children.Add(editor); group.Children.Add(generate);
|
||||
}
|
||||
else { editor.Margin = new Thickness(0, 7, 0, 0); group.Children.Add(editor); }
|
||||
}
|
||||
else
|
||||
{
|
||||
var input = new TextBox { Text = field.Value, Margin = new Thickness(0, 7, 0, 0) }; System.Windows.Automation.AutomationProperties.SetName(input, field.Name); editor = input; read = () => input.Text; group.Children.Add(input);
|
||||
}
|
||||
readers.Add(() => { var f = Vault.Copy(field); f.Name = name.Text.Trim(); f.Value = read(); return f; });
|
||||
var card = Ui.Box(group, "#181D23", 9, 12); card.Margin = new Thickness(0, 15, 0, 0); fieldPanel.Children.Add(card);
|
||||
}
|
||||
}
|
||||
void CaptureFields() => draft.Fields = readers.Select(read => read()).ToList();
|
||||
RenderFields();
|
||||
var extras = Ui.Row(Ui.Button("+ Текстовое поле", () => { CaptureFields(); draft.Fields.Add(new Field { Name = "Новое поле" }); RenderFields(); }, "Quiet"), Ui.Button("+ Секретное поле", () => { CaptureFields(); draft.Fields.Add(new Field { Name = "Секретное поле", Type = "secret" }); RenderFields(); }, "Quiet")); extras.Margin = new Thickness(0, 9, 0, 0); dialog.Body.Children.Add(extras);
|
||||
var tags = Ui.Input(dialog.Body, "Метки через запятую", string.Join(", ", draft.Tags));
|
||||
var notes = Ui.Input(dialog.Body, "Заметка", draft.Notes, true);
|
||||
var error = Ui.Text("", 12, "#FFADAD"); error.Margin = new Thickness(0, 10, 0, 0); dialog.Body.Children.Add(error);
|
||||
void Capture()
|
||||
{
|
||||
CaptureFields(); draft.Title = title.Text.Trim(); draft.Star = star.IsChecked == true; draft.Notes = notes.Text;
|
||||
draft.Tags = tags.Text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct().ToList();
|
||||
}
|
||||
void Save()
|
||||
{
|
||||
if (key == null || dialog.Forced) return;
|
||||
Capture();
|
||||
if (draft.Title.Length == 0) { error.Text = "Укажите название карточки."; title.Focus(); return; }
|
||||
if (draft.Fields.Any(f => f.Name.Length == 0)) { error.Text = "Укажите название каждого поля."; return; }
|
||||
try
|
||||
{
|
||||
var next = Vault.Copy(data!); var current = next.Entries.Find(e => e.Id == draft.Id);
|
||||
if (current != null && current.Rev != draft.Rev) { draft.Id = Guid.NewGuid().ToString(); draft.Title += " · моя версия (конфликт)"; }
|
||||
Vault.Update(next, draft); store.Save(next, key); data = next; selected = draft; dialog.Accept(); filter = "all"; Search.Clear(); BuildNavigation(); RefreshCards(); SetStatus("Карточка сохранена и зашифрована.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException) { error.Text = "Не удалось сохранить: " + ex.Message; }
|
||||
}
|
||||
dialog.Closing += (_, e) =>
|
||||
{
|
||||
if (dialog.Accepted || dialog.Forced || key == null) return;
|
||||
Capture();
|
||||
if (JsonSerializer.Serialize(draft, Vault.Json) != JsonSerializer.Serialize(original, Vault.Json) && !Confirm("Закрыть без сохранения?", "В карточке есть несохранённые изменения.")) e.Cancel = true;
|
||||
};
|
||||
dialog.AddCancel(); dialog.Actions.Children.Add(Ui.Button("Сохранить карточку", Save, "Primary"));
|
||||
dialog.PreviewKeyDown += (_, e) => { if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control) { Save(); e.Handled = true; } };
|
||||
dialog.Loaded += (_, _) => title.Focus(); dialog.ShowDialog();
|
||||
}
|
||||
|
||||
private void ShowGenerator(Action<string>? use = null)
|
||||
{
|
||||
var owner = OwnedWindows.Cast<Window>().LastOrDefault(w => w.IsVisible) ?? this;
|
||||
new PasswordGeneratorDialog(owner, use ?? CopySecret, use != null).ShowDialog();
|
||||
}
|
||||
|
||||
private void ShowSettings()
|
||||
{
|
||||
if (busy) return;
|
||||
var dialog = new AppDialog(this, "Настройки", "Подключение, резервные копии и защита хранилища.", 640);
|
||||
dialog.Body.Children.Add(Ui.Text("СИНХРОНИЗАЦИЯ С PI", 10, "#A5E4BC", FontWeights.SemiBold));
|
||||
dialog.Body.Children.Add(Ui.Text("База доступна только по локальному IP Pi и только в указанном домашнем Wi-Fi. Вне дома приложение работает автономно.", 12, "#969EAD"));
|
||||
var url = Ui.Input(dialog.Body, "Локальный HTTPS-адрес Pi", store.Config.Url);
|
||||
var homeSsid = Ui.Input(dialog.Body, "Домашний Wi-Fi (точное имя SSID)", store.Config.HomeWifiSsid);
|
||||
var certificate = Ui.Input(dialog.Body, "SHA-256 локального сертификата Pi", store.Config.CertificateSha256);
|
||||
Ui.Label(dialog.Body, "Ключ подключения"); var token = new SecretInput(store.Config.Token, "Ключ подключения"); dialog.Body.Children.Add(token);
|
||||
var auto = new CheckBox { Content = "Синхронизировать открытое хранилище раз в минуту", IsChecked = store.Config.AutoSync, Margin = new Thickness(0, 15, 0, 10) }; dialog.Body.Children.Add(auto);
|
||||
var error = Ui.Text("", 12, "#FFADAD"); dialog.Body.Children.Add(error);
|
||||
var connection = Ui.Button("Выбрать файл connection.qsafe", () =>
|
||||
{
|
||||
var file = new OpenFileDialog { Title = "Личный файл подключения к Pi", Filter = "Файл подключения QSafe (*.qsafe)|*.qsafe" };
|
||||
if (file.ShowDialog(dialog) != true) return;
|
||||
try { var config = Connection.Parse(LocalStore.ReadBounded(file.FileName, 65536)); url.Text = config.Url; homeSsid.Text = config.HomeWifiSsid; certificate.Text = config.CertificateSha256; token.Set(config.Token); error.Text = "Локальное подключение прочитано. Нажмите «Сохранить»."; }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or JsonException or ArgumentException) { error.Text = ex.Message; }
|
||||
}); connection.HorizontalAlignment = HorizontalAlignment.Left; connection.Margin = new Thickness(0, 12, 0, 12); dialog.Body.Children.Add(connection);
|
||||
string syncText = store.Config.LastSync is { } last ? "Последняя синхронизация: " + last.ToLocalTime().ToString("dd.MM.yyyy HH:mm") : "Синхронизация ещё не выполнялась";
|
||||
dialog.Body.Children.Add(Ui.Text(syncText, 12, "#969EAD"));
|
||||
Action? after = null;
|
||||
if (key != null)
|
||||
{
|
||||
var sync = Ui.Button("Синхронизировать сейчас", () => { if (!SaveSettings()) return; after = () => _ = Synchronize(); dialog.Accept(); }); sync.IsEnabled = !IsDemo; sync.HorizontalAlignment = HorizontalAlignment.Left; sync.Margin = new Thickness(0, 14, 0, 0); dialog.Body.Children.Add(sync);
|
||||
dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("ДАННЫЕ", 10, "#A5E4BC", FontWeights.SemiBold));
|
||||
foreach (var (label, action) in new (string, Action)[] { ("Экспорт зашифрованной базы .qsafe", ExportVault), ("Восстановить из файла .qsafe", ImportVault), ("Импортировать SafeInCloud XML", ImportXml) })
|
||||
{
|
||||
var button = Ui.Button(label, () => { after = action; dialog.Accept(); }, "Quiet"); button.HorizontalAlignment = HorizontalAlignment.Left; button.Margin = new Thickness(0, 6, 0, 0); dialog.Body.Children.Add(button);
|
||||
}
|
||||
}
|
||||
dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("ЗАЩИТА", 10, "#A5E4BC", FontWeights.SemiBold));
|
||||
var protections = Ui.Text("Автоблокировка через 2 минуты бездействия, при сворачивании и блокировке Windows. Буфер обмена очищается через 30 секунд. Ключ подключения защищён учётной записью Windows. Мастер-пароль не сохраняется.", 12, "#969EAD"); protections.Margin = new Thickness(0, 12, 0, 0); protections.LineHeight = 20; dialog.Body.Children.Add(protections);
|
||||
dialog.Body.Children.Add(Ui.Rule()); dialog.Body.Children.Add(Ui.Text("QSafe для Windows · 1.0.4\nЛокальные резервные копии: последние 10 сохранений.\nWindows Hello, TOTP и автозаполнение браузеров пока недоступны.", 12, "#788493"));
|
||||
bool SaveSettings()
|
||||
{
|
||||
if (IsDemo) { error.Text = "Подключение к серверу недоступно в демо."; return false; }
|
||||
try
|
||||
{
|
||||
if (token.Value.Length > 0)
|
||||
{
|
||||
var next = Vault.Copy(store.Config); next.Url = url.Text.Trim(); next.HomeWifiSsid = homeSsid.Text; next.CertificateSha256 = certificate.Text.Trim(); next.Token = token.Value; next.AutoSync = auto.IsChecked == true; store.Configure(next);
|
||||
}
|
||||
else if (store.Connected) throw new InvalidDataException("Для изменения подключения нужен ключ. Текущее подключение сохранено.");
|
||||
else { store.Config.AutoSync = auto.IsChecked == true; store.SaveConfig(); }
|
||||
SetStatus("Настройки сохранены."); return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or ArgumentException) { error.Text = ex.Message; return false; }
|
||||
}
|
||||
dialog.AddCancel("Закрыть"); dialog.Actions.Children.Add(Ui.Button("Сохранить", () => { if (SaveSettings()) dialog.Accept(); }, "Primary"));
|
||||
if (dialog.ShowDialog() == true && !dialog.Forced) after?.Invoke();
|
||||
}
|
||||
|
||||
private async void ImportXml()
|
||||
{
|
||||
if (key == null || busy) return;
|
||||
var file = new OpenFileDialog { Title = "XML-экспорт SafeInCloud", Filter = "XML SafeInCloud (*.xml)|*.xml" };
|
||||
if (file.ShowDialog(this) != true) return;
|
||||
ImportPreview? preview = null;
|
||||
await Work("Читаем XML локально…", async () => { var current = data!; preview = await Task.Run(() => XmlImport.Preview(LocalStore.ReadBounded(file.FileName, 24 * 1024 * 1024), current)); });
|
||||
if (preview == null || key == null) return;
|
||||
if (!Confirm("Импорт из SafeInCloud", $"Добавится карточек: {preview.Added}. Дубликатов: {preview.Duplicates}. Пропущено: {preview.Skipped}. Вложений сохранится: {preview.Attachments}.\n\nXML содержит открытые пароли. После проверки переноса удалите экспорт с устройства.")) return;
|
||||
await Work("Сохраняем импортированные карточки…", () =>
|
||||
{
|
||||
store.Save(preview.Data, key); data = preview.Data; filter = "all"; Search.Clear(); BuildNavigation(); RefreshCards(); SetStatus($"Импортировано карточек: {preview.Added}. Вложения сохранены в зашифрованной базе."); return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using QSafe.Core;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
/// <summary>Reads the actual WLAN association; adapter/profile names and VPN adapters are not SSIDs.</summary>
|
||||
public sealed class HomeWifiNetwork : ILanNetwork
|
||||
{
|
||||
public static readonly HomeWifiNetwork Instance = new();
|
||||
[DllImport("wlanapi.dll")] private static extern uint WlanOpenHandle(uint version, IntPtr reserved, out uint negotiated, out IntPtr handle);
|
||||
[DllImport("wlanapi.dll")] private static extern uint WlanCloseHandle(IntPtr handle, IntPtr reserved);
|
||||
[DllImport("wlanapi.dll")] private static extern uint WlanQueryInterface(IntPtr handle, ref Guid guid, uint opcode, IntPtr reserved, out uint size, out IntPtr data, out uint valueType);
|
||||
[DllImport("wlanapi.dll")] private static extern void WlanFreeMemory(IntPtr data);
|
||||
public LanBinding Current(Connection connection)
|
||||
{
|
||||
if (WlanOpenHandle(2, IntPtr.Zero, out _, out var handle) != 0) throw Offline();
|
||||
try
|
||||
{
|
||||
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces().Where(a => a.NetworkInterfaceType == NetworkInterfaceType.Wireless80211 && a.OperationalStatus == OperationalStatus.Up))
|
||||
{
|
||||
if (!Guid.TryParse(adapter.Id, out var guid)) continue;
|
||||
IntPtr raw = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
// WLAN_CONNECTION_ATTRIBUTES: state, mode, WCHAR profile[256], DOT11_SSID.
|
||||
if (WlanQueryInterface(handle, ref guid, 7, IntPtr.Zero, out var size, out raw, out _) != 0 || size < 556 || Marshal.ReadInt32(raw) != 1) continue;
|
||||
int length = Marshal.ReadInt32(raw, 520); if (length is < 1 or > 32) continue;
|
||||
var ssidBytes = new byte[length]; Marshal.Copy(IntPtr.Add(raw, 524), ssidBytes, 0, length);
|
||||
string ssid = new UTF8Encoding(false, true).GetString(ssidBytes);
|
||||
if (ssid != connection.HomeWifiSsid) continue;
|
||||
var properties = adapter.GetIPProperties(); int index = properties.GetIPv4Properties().Index;
|
||||
foreach (var address in properties.UnicastAddresses.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork))
|
||||
{
|
||||
var binding = new LanBinding(ssid, true, address.Address, address.PrefixLength, index);
|
||||
try { LanPolicy.Check(connection, binding); return binding; } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
finally { if (raw != IntPtr.Zero) WlanFreeMemory(raw); }
|
||||
}
|
||||
}
|
||||
finally { WlanCloseHandle(handle, IntPtr.Zero); }
|
||||
throw Offline();
|
||||
}
|
||||
private static IOException Offline() => new("Автономный режим: домашний Wi-Fi не подтверждён. Синхронизация не выполняется. Если Windows скрывает SSID, проверьте разрешение доступа к имени Wi-Fi.");
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using QSafe.Core;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
public sealed class LocalStore
|
||||
{
|
||||
public string DirectoryPath { get; }
|
||||
private string VaultPath => Path.Combine(DirectoryPath, "vault.qsafe");
|
||||
private string BasePath => Path.Combine(DirectoryPath, "synced.qsafe");
|
||||
private string ConfigPath => Path.Combine(DirectoryPath, "connection.enc");
|
||||
public bool Exists => File.Exists(VaultPath);
|
||||
public Connection Config { get; private set; } = new();
|
||||
public bool Connected => Config.Token.Length > 0;
|
||||
public LocalStore(string path)
|
||||
{
|
||||
DirectoryPath = path; Directory.CreateDirectory(path);
|
||||
if (File.Exists(ConfigPath))
|
||||
{
|
||||
var plain = DeviceProtection.Unprotect(File.ReadAllBytes(ConfigPath));
|
||||
try { Config = JsonSerializer.Deserialize<Connection>(plain, Vault.Json) ?? new(); }
|
||||
finally { CryptographicOperations.ZeroMemory(plain); }
|
||||
}
|
||||
}
|
||||
public static byte[] ReadBounded(string path, int limit = VaultCrypto.MaxBytes)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
if (stream.Length > limit) throw new InvalidDataException("Файл слишком большой.");
|
||||
using var output = new MemoryStream(); stream.CopyTo(output);
|
||||
if (output.Length > limit) throw new InvalidDataException("Файл слишком большой.");
|
||||
return output.ToArray();
|
||||
}
|
||||
public byte[] Read() => ReadBounded(VaultPath);
|
||||
public byte[]? ReadBase() => File.Exists(BasePath) ? ReadBounded(BasePath) : null;
|
||||
public static void AtomicWrite(string path, byte[] bytes)
|
||||
{
|
||||
var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 8192, FileOptions.WriteThrough)) { stream.Write(bytes); stream.Flush(true); }
|
||||
File.Move(temp, path, true);
|
||||
}
|
||||
finally { if (File.Exists(temp)) File.Delete(temp); }
|
||||
}
|
||||
public void Save(VaultData data, VaultKey key) => SaveEnvelope(Vault.Encrypt(data, key));
|
||||
public void SaveEnvelope(byte[] envelope)
|
||||
{
|
||||
VaultCrypto.Validate(envelope);
|
||||
if (Exists)
|
||||
{
|
||||
var backup = Path.Combine(DirectoryPath, "backups"); Directory.CreateDirectory(backup);
|
||||
AtomicWrite(Path.Combine(backup, DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fffffff") + ".qsafe"), Read());
|
||||
foreach (var old in Directory.GetFiles(backup, "*.qsafe").OrderDescending().Skip(10)) File.Delete(old);
|
||||
}
|
||||
AtomicWrite(VaultPath, envelope);
|
||||
}
|
||||
public void SaveConfig()
|
||||
{
|
||||
var plain = JsonSerializer.SerializeToUtf8Bytes(Config, Vault.Json);
|
||||
try { AtomicWrite(ConfigPath, DeviceProtection.Protect(plain)); } finally { CryptographicOperations.ZeroMemory(plain); }
|
||||
}
|
||||
public void Configure(Connection config)
|
||||
{
|
||||
config.Validate();
|
||||
if (Config.Url != config.Url || Config.Token != config.Token)
|
||||
{
|
||||
config.Etag = ""; config.LastSync = null;
|
||||
// Clear the ancestor before storing the new connection: a crash cannot use a wrong ancestor.
|
||||
if (File.Exists(BasePath)) File.Delete(BasePath);
|
||||
}
|
||||
Config = config; SaveConfig();
|
||||
}
|
||||
public void MarkSynced(byte[] envelope)
|
||||
{
|
||||
AtomicWrite(BasePath, envelope); Config.Etag = VaultCrypto.Hash(envelope); Config.LastSync = DateTimeOffset.Now; SaveConfig();
|
||||
}
|
||||
public void ResetSync()
|
||||
{
|
||||
if (File.Exists(BasePath)) File.Delete(BasePath);
|
||||
Config.Etag = ""; Config.LastSync = null; SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeviceProtection
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)] private struct Blob { public int Length; public IntPtr Data; }
|
||||
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)] private static extern bool CryptProtectData(ref Blob input, string description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out Blob output);
|
||||
[DllImport("crypt32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)] private static extern bool CryptUnprotectData(ref Blob input, IntPtr description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out Blob output);
|
||||
[DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory);
|
||||
public static byte[] Protect(byte[] bytes) => Transform(bytes, true);
|
||||
public static byte[] Unprotect(byte[] bytes) => Transform(bytes, false);
|
||||
private static byte[] Transform(byte[] bytes, bool encrypt)
|
||||
{
|
||||
var input = new Blob { Length = bytes.Length, Data = Marshal.AllocHGlobal(bytes.Length) };
|
||||
Blob output = default;
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, input.Data, bytes.Length);
|
||||
bool ok = encrypt ? CryptProtectData(ref input, "QSafe connection", IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output) : CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output);
|
||||
if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error(), "Не удалось защитить настройки учётной записью Windows.");
|
||||
var result = new byte[output.Length]; Marshal.Copy(output.Data, result, 0, result.Length); return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int i = 0; i < input.Length; i++) Marshal.WriteByte(input.Data, i, 0);
|
||||
Marshal.FreeHGlobal(input.Data);
|
||||
if (output.Data != IntPtr.Zero) { for (int i = 0; i < output.Length; i++) Marshal.WriteByte(output.Data, i, 0); LocalFree(output.Data); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<Window x:Class="QSafe.Desktop.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="QSafe — личное хранилище" Width="1380" Height="880" MinWidth="1100" MinHeight="720" WindowStartupLocation="CenterScreen" Background="{StaticResource Canvas}" Foreground="{StaticResource Ink}" FontFamily="Segoe UI" FontSize="14">
|
||||
<Grid Background="{StaticResource Canvas}" TextElement.Foreground="{StaticResource Ink}" TextOptions.TextFormattingMode="Display" TextOptions.TextRenderingMode="ClearType">
|
||||
<Grid.RowDefinitions><RowDefinition Height="58" /><RowDefinition Height="*" /><RowDefinition Height="36" /></Grid.RowDefinitions>
|
||||
<Border BorderBrush="{StaticResource Line}" BorderThickness="0,0,0,1" Padding="24,0">
|
||||
<DockPanel>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Width="30" Height="30" CornerRadius="9" Background="{StaticResource Accent}" Margin="0,0,11,0"><TextBlock Text="Q" Foreground="#14271B" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center" /></Border>
|
||||
<TextBlock Text="QSafe" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DemoBadge" Text="ДЕМО" FontSize="10" Foreground="{StaticResource Accent}" Margin="14,0,0,0" VerticalAlignment="Center" Visibility="Collapsed" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<TextBlock Text="ВАШИ СЕКРЕТЫ. ВАШ КОНТРОЛЬ." FontSize="10" Foreground="{StaticResource Muted}" VerticalAlignment="Center" Margin="0,0,24,0" />
|
||||
<Button x:Name="LockButton" Content="" FontFamily="Segoe MDL2 Assets" Style="{StaticResource Quiet}" ToolTip="Заблокировать · Ctrl+L" Click="LockClick" Width="40" Visibility="Collapsed" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Grid x:Name="Welcome" Grid.Row="1" />
|
||||
<Grid x:Name="Workspace" Grid.Row="1" Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="222" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<Border Background="#15181D" BorderBrush="{StaticResource Line}" BorderThickness="0,0,1,0" Padding="14,22,14,16">
|
||||
<DockPanel LastChildFill="True">
|
||||
<StackPanel DockPanel.Dock="Bottom">
|
||||
<StackPanel x:Name="Tools" Margin="0,12,0,16" />
|
||||
<Border Background="#1D2722" CornerRadius="10" Padding="13">
|
||||
<StackPanel><TextBlock Text="● Локально зашифровано" Foreground="{StaticResource Accent}" FontSize="12" FontWeight="SemiBold" /><TextBlock Text="AES-256-GCM" Foreground="{StaticResource Muted}" FontSize="11" Margin="13,7,0,0" /></StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<ScrollViewer><StackPanel><TextBlock Text="МОЁ ХРАНИЛИЩЕ" FontSize="10" FontWeight="SemiBold" Foreground="{StaticResource Muted}" Margin="12,0,0,13" /><StackPanel x:Name="Navigation" /></StackPanel></ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.RowDefinitions><RowDefinition Height="118" /><RowDefinition Height="*" /></Grid.RowDefinitions>
|
||||
<Grid Margin="26,22,26,16">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="260" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<StackPanel VerticalAlignment="Center"><TextBlock x:Name="PageTitle" Text="Все карточки" FontSize="27" FontWeight="SemiBold" /><TextBlock x:Name="PageSubtitle" Foreground="{StaticResource Muted}" FontSize="12" Margin="0,7,0,0" /></StackPanel>
|
||||
<Grid Grid.Column="1" VerticalAlignment="Center" Margin="0,0,14,0">
|
||||
<TextBox x:Name="Search" Padding="36,12,40,12" TextChanged="SearchChanged" ToolTip="Поиск · Ctrl+F" />
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" Foreground="{StaticResource Muted}" VerticalAlignment="Center" Margin="12,0,0,0" IsHitTestVisible="False" />
|
||||
<TextBlock x:Name="SearchHint" Text="Найти карточку" Foreground="{StaticResource Muted}" VerticalAlignment="Center" Margin="36,0,0,0" IsHitTestVisible="False" />
|
||||
<Button Content="×" Width="30" Padding="0" HorizontalAlignment="Right" Style="{StaticResource Quiet}" Click="ClearSearchClick" ToolTip="Очистить поиск" />
|
||||
</Grid>
|
||||
<Button Grid.Column="2" Content="+ Добавить" Style="{StaticResource Primary}" VerticalAlignment="Center" Click="AddClick" Padding="18,13" ToolTip="Новая карточка · Ctrl+N" />
|
||||
</Grid>
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="334" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<Border BorderBrush="{StaticResource Line}" BorderThickness="0,1,1,0" Background="#15181D" Padding="12,14">
|
||||
<Grid><ListBox x:Name="Cards" SelectionChanged="CardSelected" /><StackPanel x:Name="ListEmpty" Visibility="Collapsed" VerticalAlignment="Center" Margin="20"><TextBlock Text="Ничего не найдено" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center" /><TextBlock Text="Попробуйте другой запрос или добавьте новую карточку." Foreground="{StaticResource Muted}" TextAlignment="Center" Margin="0,10,0,0" /></StackPanel></Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="1" BorderBrush="{StaticResource Line}" BorderThickness="0,1,0,0"><ScrollViewer><StackPanel x:Name="Details" Margin="30,28,30,30" /></ScrollViewer></Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Border Grid.Row="2" BorderBrush="{StaticResource Line}" BorderThickness="0,1,0,0" Background="#15181D" Padding="20,0">
|
||||
<DockPanel><TextBlock Text="QSafe 1.0.4" Foreground="{StaticResource Muted}" FontSize="11" DockPanel.Dock="Right" VerticalAlignment="Center" /><TextBlock x:Name="Status" Text="Готово к работе" Foreground="{StaticResource Muted}" FontSize="11" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" TextWrapping="NoWrap" /></DockPanel>
|
||||
</Border>
|
||||
<Border x:Name="BusyOverlay" Grid.Row="1" Background="#E8101216" Visibility="Collapsed">
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Width="340"><TextBlock x:Name="BusyText" Text="Открываем хранилище…" FontSize="19" TextAlignment="Center" /><ProgressBar Height="3" Margin="0,22,0,0" IsIndeterminate="True" Foreground="{StaticResource Accent}" Background="#28352D" BorderThickness="0" /></StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Win32;
|
||||
using QSafe.Core;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private LocalStore store;
|
||||
private readonly string originalDirectory;
|
||||
private VaultKey? key;
|
||||
private VaultData? data;
|
||||
private Entry? selected;
|
||||
private string filter = "all";
|
||||
private bool busy, deferredLock, closed;
|
||||
private DateTime lastInput = DateTime.UtcNow;
|
||||
private DateTime lastAutoSync = DateTime.UtcNow;
|
||||
private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly DispatcherTimer clipboardTimer = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
private string? clipboardMarker;
|
||||
private readonly Dictionary<string, Button> navButtons = [];
|
||||
public bool IsDemo { get; private set; }
|
||||
|
||||
public MainWindow(LocalStore localStore, bool demo)
|
||||
{
|
||||
store = localStore; originalDirectory = demo ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kusoft", "QSafe") : localStore.DirectoryPath; IsDemo = demo;
|
||||
InitializeComponent(); NativeWindow.Style(this);
|
||||
System.Windows.Automation.AutomationProperties.SetName(Search, "Поиск карточек");
|
||||
InputManager.Current.PreProcessInput += Activity;
|
||||
PreviewKeyDown += Shortcut;
|
||||
StateChanged += (_, _) => { if (WindowState == WindowState.Minimized) Lock(); };
|
||||
SystemEvents.SessionSwitch += SessionChanged;
|
||||
SystemEvents.PowerModeChanged += PowerChanged;
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
if (key != null && DateTime.UtcNow - lastInput > TimeSpan.FromMinutes(2)) Lock();
|
||||
if (key != null && !busy && OwnedWindows.Count == 0 && store.Connected && store.Config.AutoSync && DateTime.UtcNow - lastAutoSync > TimeSpan.FromMinutes(1)) _ = Synchronize(false);
|
||||
};
|
||||
clipboardTimer.Tick += (_, _) => ClearClipboard(); timer.Start();
|
||||
Closing += (_, e) =>
|
||||
{
|
||||
if (busy) { e.Cancel = true; Lock(); SetStatus("Дождитесь завершения операции перед закрытием."); return; }
|
||||
closed = true; lifetime.Cancel(); timer.Stop(); Lock(); ClearClipboard();
|
||||
InputManager.Current.PreProcessInput -= Activity; SystemEvents.SessionSwitch -= SessionChanged; SystemEvents.PowerModeChanged -= PowerChanged;
|
||||
};
|
||||
if (demo) StartDemo(); else ShowWelcome();
|
||||
}
|
||||
|
||||
private void Activity(object sender, PreProcessInputEventArgs e)
|
||||
{
|
||||
if (e.StagingItem.Input is KeyEventArgs or MouseButtonEventArgs or MouseWheelEventArgs || e.StagingItem.Input is MouseEventArgs { RoutedEvent: var ev } && ev == Mouse.MouseMoveEvent)
|
||||
lastInput = DateTime.UtcNow;
|
||||
}
|
||||
private void SessionChanged(object sender, SessionSwitchEventArgs e) { if (e.Reason is SessionSwitchReason.SessionLock or SessionSwitchReason.SessionLogoff or SessionSwitchReason.RemoteDisconnect) Dispatcher.BeginInvoke(Lock); }
|
||||
private void PowerChanged(object sender, PowerModeChangedEventArgs e) { if (e.Mode == PowerModes.Suspend) Dispatcher.Invoke(Lock); }
|
||||
private void Shortcut(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (Keyboard.Modifiers != ModifierKeys.Control) return;
|
||||
if (e.Key == Key.L) { Lock(); e.Handled = true; return; }
|
||||
if (key == null || busy || OwnedWindows.Count > 0) return;
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.F: Search.Focus(); Search.SelectAll(); break;
|
||||
case Key.N: AddEntry(); break;
|
||||
case Key.G: ShowGenerator(); break;
|
||||
default: return;
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
private void SetStatus(string text, bool error = false)
|
||||
{
|
||||
Status.Text = text; Status.Foreground = Ui.Brush(error ? "#FFADAD" : "#A2ADAA");
|
||||
}
|
||||
private async Task Work(string message, Func<Task> action)
|
||||
{
|
||||
if (busy) return;
|
||||
busy = true; BusyText.Text = message; BusyOverlay.Visibility = Visibility.Visible; LockButton.IsEnabled = false;
|
||||
try { await action(); }
|
||||
catch (OperationCanceledException) { SetStatus("Операция отменена."); }
|
||||
catch (CryptographicException) { SetStatus("Неверный мастер-пароль или повреждённый файл. Данные не изменены.", true); }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or System.Text.Json.JsonException or System.Xml.XmlException or System.Net.Http.HttpRequestException or ArgumentException or System.ComponentModel.Win32Exception or UnauthorizedAccessException)
|
||||
{
|
||||
SetStatus(ex is System.Net.Http.HttpRequestException ? "Не удалось подключиться к серверу. Проверьте интернет и адрес в настройках." : ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
busy = false; BusyOverlay.Visibility = Visibility.Collapsed; LockButton.IsEnabled = true;
|
||||
if (deferredLock) { deferredLock = false; Lock(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowWelcome(string mode = "unlock", byte[]? import = null)
|
||||
{
|
||||
NativeWindow.SetProtection(this, !IsDemo);
|
||||
Welcome.Children.Clear(); Welcome.Visibility = Visibility.Visible; Workspace.Visibility = Visibility.Collapsed; LockButton.Visibility = Visibility.Collapsed;
|
||||
DemoBadge.Visibility = IsDemo ? Visibility.Visible : Visibility.Collapsed;
|
||||
var layout = new Grid { MaxWidth = 1030, Margin = new Thickness(50, 36, 50, 36), VerticalAlignment = VerticalAlignment.Center };
|
||||
layout.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
layout.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(430) });
|
||||
var hero = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 58, 0) };
|
||||
var eyebrow = Ui.Text("ЛИЧНОЕ ЦИФРОВОЕ ПРОСТРАНСТВО", 10, "#A5E4BC", FontWeights.SemiBold); hero.Children.Add(eyebrow);
|
||||
var heading = Ui.Text("Всё важное.\nПод вашим\nконтролем.", 49, "#F0F2F6", FontWeights.SemiBold); heading.LineHeight = 56; heading.Margin = new Thickness(0, 20, 0, 22); hero.Children.Add(heading);
|
||||
hero.Children.Add(Ui.Text("Пароли, карты и заметки — в одном\nзашифрованном хранилище на вашем ПК.", 16, "#969EAD"));
|
||||
foreach (var (glyph, text) in new[] { ("\uE72E", "Один мастер-пароль для всех секретов"), ("\uE8B7", "Общая база с QSafe на Android"), ("\uE753", "Синхронизация с вашим сервером") })
|
||||
{
|
||||
var t = Ui.Text(text, 13, "#B6BECA"); t.Margin = new Thickness(12, 0, 0, 0);
|
||||
var row = Ui.Row(Ui.Glyph(glyph, 16, "#A5E4BC"), t); row.Margin = new Thickness(0, 21, 0, 0); hero.Children.Add(row);
|
||||
}
|
||||
layout.Children.Add(hero);
|
||||
var form = new StackPanel();
|
||||
bool existing = store.Exists;
|
||||
bool create = mode == "create", remote = mode == "remote", restore = import != null;
|
||||
form.Children.Add(Ui.Tile("\uE72E", "#A5E4BC", 52));
|
||||
var title = Ui.Text(create ? "Новое хранилище" : restore ? "Открыть резервную копию" : remote ? "Подключить хранилище" : existing ? "С возвращением" : "Добро пожаловать", 25, "#F0F2F6", FontWeights.SemiBold); title.Margin = new Thickness(0, 23, 0, 10); form.Children.Add(title);
|
||||
form.Children.Add(Ui.Text(create ? "Придумайте мастер-пароль от 12 символов. Он понадобится на всех устройствах." : restore || remote ? "Введите мастер-пароль существующей базы." : existing ? "Введите мастер-пароль, чтобы открыть свои карточки." : "Начните с чистого листа или подключите существующую базу QSafe.", 13, "#969EAD"));
|
||||
if (existing || create || restore || remote)
|
||||
{
|
||||
Ui.Label(form, "Мастер-пароль");
|
||||
var password = new PasswordBox(); form.Children.Add(password); System.Windows.Automation.AutomationProperties.SetName(password, "Мастер-пароль");
|
||||
PasswordBox? confirm = null;
|
||||
if (create) { Ui.Label(form, "Повторите мастер-пароль"); confirm = new PasswordBox(); form.Children.Add(confirm); System.Windows.Automation.AutomationProperties.SetName(confirm, "Повторите мастер-пароль"); }
|
||||
var caps = Ui.Text("", 11, "#F3D684"); caps.Margin = new Thickness(0, 7, 0, 0); form.Children.Add(caps);
|
||||
password.PreviewKeyUp += (_, _) => caps.Text = Keyboard.IsKeyToggled(Key.CapsLock) ? "Включён Caps Lock" : "";
|
||||
async void Open()
|
||||
{
|
||||
var pass = password.Password.ToCharArray(); var repeated = confirm?.Password;
|
||||
password.Clear(); confirm?.Clear();
|
||||
try
|
||||
{
|
||||
if (create && (pass.Length < 12 || new string(pass) != repeated)) { SetStatus("Нужен пароль от 12 символов и совпадающее подтверждение.", true); return; }
|
||||
await Work(remote ? "Загружаем зашифрованную базу…" : "Открываем хранилище…", async () =>
|
||||
{
|
||||
byte[]? blob = import;
|
||||
if (remote) { using var client = new SyncClient(store.Config, HomeWifiNetwork.Instance); blob = await client.Download(lifetime.Token); }
|
||||
VaultKey? nextKey = null;
|
||||
try
|
||||
{
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
nextKey = create ? VaultCrypto.Create(pass) : VaultCrypto.Unlock(blob ?? store.Read(), pass);
|
||||
return create ? new VaultData() : Vault.Read(blob ?? store.Read(), nextKey);
|
||||
});
|
||||
if (deferredLock) return;
|
||||
if (create) store.Save(result, nextKey!);
|
||||
else if (blob != null) { store.SaveEnvelope(blob); if (remote) store.MarkSynced(blob); else store.ResetSync(); }
|
||||
key = nextKey; nextKey = null; data = result; filter = "all"; selected = null; ShowVault(); SetStatus("Хранилище открыто. Ctrl+F — поиск · Ctrl+N — новая карточка · Ctrl+L — блокировка");
|
||||
}
|
||||
finally { nextKey?.Dispose(); }
|
||||
});
|
||||
if (key != null && store.Connected && store.Config.AutoSync && !remote) await Synchronize(false);
|
||||
}
|
||||
finally { Array.Clear(pass); }
|
||||
}
|
||||
var open = Ui.Button(create ? "Создать хранилище →" : "Разблокировать →", Open, "Primary"); open.Margin = new Thickness(0, 12, 0, 0); form.Children.Add(open);
|
||||
password.KeyDown += (_, e) => { if (e.Key == Key.Enter) Open(); }; if (confirm != null) confirm.KeyDown += (_, e) => { if (e.Key == Key.Enter) Open(); };
|
||||
if (!existing || restore || remote) { var back = Ui.Button("Назад", () => ShowWelcome(), "Quiet"); back.Margin = new Thickness(0, 9, 0, 0); form.Children.Add(back); }
|
||||
Dispatcher.BeginInvoke(() => password.Focus());
|
||||
}
|
||||
else
|
||||
{
|
||||
var createButton = Ui.Button("Создать хранилище →", () => ShowWelcome("create"), "Primary"); createButton.Margin = new Thickness(0, 25, 0, 10); form.Children.Add(createButton);
|
||||
var connect = Ui.Button("Подключить Pi и синхронизировать", () =>
|
||||
{
|
||||
ShowSettings(); if (store.Connected) ShowWelcome("remote");
|
||||
}); connect.Margin = new Thickness(0, 0, 0, 10); form.Children.Add(connect);
|
||||
form.Children.Add(Ui.Button("Открыть файл .qsafe", ImportVault, "Quiet"));
|
||||
}
|
||||
if (!existing && mode == "unlock" && import == null)
|
||||
{
|
||||
form.Children.Add(Ui.Rule()); form.Children.Add(Ui.Button("Посмотреть демо", StartDemo, "Quiet"));
|
||||
}
|
||||
var note = Ui.Text("Мастер-пароль остаётся только у вас.\nВосстановить забытый пароль невозможно.", 11, "#788493"); note.TextAlignment = TextAlignment.Center; note.Margin = new Thickness(0, 22, 0, 0); form.Children.Add(note);
|
||||
var box = Ui.Box(form, "#181C22", 20, 32); Grid.SetColumn(box, 1); layout.Children.Add(box); Welcome.Children.Add(new ScrollViewer { Content = layout });
|
||||
}
|
||||
|
||||
private void StartDemo()
|
||||
{
|
||||
IsDemo = true;
|
||||
if (!store.DirectoryPath.Contains("QSafe-Demo", StringComparison.Ordinal)) store = new LocalStore(Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")));
|
||||
key?.Dispose(); key = VaultCrypto.Create("QSafe public demo password 2026"); data = new VaultData(); filter = "all";
|
||||
foreach (var (title, kind, login, url, tags, star) in new[]
|
||||
{
|
||||
("GitHub", "login", "andrey@example.test", "https://github.com", "Работа", true),
|
||||
("Яндекс Почта", "email", "andrey@example.test", "https://mail.yandex.ru", "Личное", true),
|
||||
("Домашний Wi-Fi", "wifi", "Home Network", "", "Дом", false),
|
||||
("Банковская карта", "bank", "АНДРЕЙ", "", "Личное", true),
|
||||
("Домашний сервер", "server", "andrey", "", "Дом", false),
|
||||
("Figma", "login", "andrey@example.test", "https://figma.com", "Работа", false),
|
||||
("Важная заметка", "note", "", "", "Личное", false)
|
||||
})
|
||||
{
|
||||
var e = Templates.Create(kind); e.Title = title; e.Star = star; e.Tags = [tags];
|
||||
foreach (var f in e.Fields) f.Value = f.Type switch { "password" => "Demo-Only-Password-2026!", "website" => url, "login" or "email" => login, "card" => "4000 0000 0000 0002", "pin" => "123", "date" => "12/29", _ => login };
|
||||
if (kind == "server") e.Fields[0].Value = "raspberrypi.local";
|
||||
e.Notes = kind == "note" ? "Здесь можно хранить идеи, инструкции и другую важную информацию. Эта карточка демонстрационная." : "Демонстрационная карточка. Здесь нет настоящих паролей.";
|
||||
data.Entries.Add(e);
|
||||
}
|
||||
selected = data.Entries[0]; store.Save(data, key); ShowVault(); SetStatus("Демо: все карточки вымышлены. Изменения сохраняются во временной папке.");
|
||||
}
|
||||
|
||||
private void ShowVault()
|
||||
{
|
||||
NativeWindow.SetProtection(this, !IsDemo);
|
||||
Welcome.Children.Clear(); Welcome.Visibility = Visibility.Collapsed; Workspace.Visibility = Visibility.Visible; LockButton.Visibility = Visibility.Visible;
|
||||
DemoBadge.Visibility = IsDemo ? Visibility.Visible : Visibility.Collapsed;
|
||||
BuildNavigation(); RefreshCards();
|
||||
}
|
||||
private void BuildNavigation()
|
||||
{
|
||||
Navigation.Children.Clear(); Tools.Children.Clear(); navButtons.Clear();
|
||||
void Nav(string id, string glyph, string title, int count)
|
||||
{
|
||||
var button = Ui.Button(title, () => { filter = id; selected = null; BuildNavigation(); RefreshCards(); }, "Quiet");
|
||||
button.Padding = new Thickness(12, 11, 10, 11); button.Margin = new Thickness(0, 0, 0, 3); button.HorizontalContentAlignment = HorizontalAlignment.Stretch;
|
||||
if (filter == id) { button.Background = Ui.Brush("#293A30"); button.BorderBrush = Ui.Brush("#344B3D"); }
|
||||
var content = new DockPanel(); var icon = Ui.Glyph(glyph, 16, filter == id ? "#A5E4BC" : "#8E99A9"); icon.Margin = new Thickness(0, 0, 12, 0); DockPanel.SetDock(icon, Dock.Left); content.Children.Add(icon);
|
||||
var number = Ui.Text(count.ToString(), 11, "#8E99A9"); number.Margin = new Thickness(8, 0, 0, 0); number.VerticalAlignment = VerticalAlignment.Center; DockPanel.SetDock(number, Dock.Right); content.Children.Add(number);
|
||||
var label = Ui.Text(title, 11.5, filter == id ? "#C5EBD2" : "#B7C0CE"); label.TextWrapping = TextWrapping.NoWrap; label.TextTrimming = TextTrimming.CharacterEllipsis; label.VerticalAlignment = VerticalAlignment.Center; content.Children.Add(label); button.Content = content; button.ToolTip = title; Navigation.Children.Add(button); navButtons[id] = button;
|
||||
}
|
||||
var visible = data!.Entries.Where(e => !e.Deleted).ToList();
|
||||
Nav("all", "\uE8A9", "Все карточки", visible.Count); Nav("stars", "\uE734", "Избранное", visible.Count(e => e.Star));
|
||||
var caption = Ui.Text("КАТЕГОРИИ", 10, "#788493", FontWeights.SemiBold); caption.Margin = new Thickness(12, 23, 0, 12); Navigation.Children.Add(caption);
|
||||
for (int i = 0; i < Templates.Kinds.Length; i++)
|
||||
{
|
||||
var kind = Templates.Kinds[i]; if (kind == "custom" && visible.All(e => e.Category != "custom")) continue;
|
||||
Nav(kind, Templates.Glyphs[i], Templates.Titles[i], visible.Count(e => e.Category == kind));
|
||||
}
|
||||
Nav("trash", "\uE74D", "Корзина", data.Entries.Count(e => e.Deleted));
|
||||
var gen = Ui.Button("Генератор паролей", () => ShowGenerator(), "Quiet"); gen.FontSize = 12; gen.HorizontalContentAlignment = HorizontalAlignment.Left; gen.ToolTip = "Ctrl+G"; Tools.Children.Add(gen);
|
||||
var settings = Ui.Button("Настройки и синхронизация", ShowSettings, "Quiet"); settings.FontSize = 12; settings.HorizontalContentAlignment = HorizontalAlignment.Left; Tools.Children.Add(settings);
|
||||
if (IsDemo) { var exit = Ui.Button("Выйти из демо", () => { Lock(); store = new LocalStore(originalDirectory); IsDemo = false; ShowWelcome(); }, "Quiet"); exit.FontSize = 12; Tools.Children.Add(exit); }
|
||||
}
|
||||
|
||||
private void RefreshCards()
|
||||
{
|
||||
if (data == null) return;
|
||||
string query = Search.Text.Trim(); SearchHint.Visibility = query.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
PageTitle.Text = filter switch { "all" => "Все карточки", "stars" => "Избранное", "trash" => "Корзина", _ => Templates.Titles[Templates.Index(filter)] };
|
||||
var entries = data.Entries.Where(e => filter == "trash" ? e.Deleted : !e.Deleted)
|
||||
.Where(e => filter is "all" or "trash" || filter == "stars" && e.Star || e.Category == filter)
|
||||
.Where(e => string.Join(' ', new[] { e.Title, string.Join(' ', e.Tags) }.Concat(e.Fields.Where(f => !f.Secret).Select(f => f.Value))).Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(e => e.Star).ThenBy(e => e.Title, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
PageSubtitle.Text = $"{entries.Count} карточек · Только вы имеете доступ к содержимому";
|
||||
var selectedId = selected?.Id; Cards.Items.Clear();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
int i = Templates.Index(entry.Category);
|
||||
var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); grid.ColumnDefinitions.Add(new ColumnDefinition()); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
grid.Children.Add(Ui.Tile(Templates.Glyphs[i], Templates.Colors[i], 40));
|
||||
var texts = new StackPanel { Margin = new Thickness(12, 0, 6, 0), VerticalAlignment = VerticalAlignment.Center };
|
||||
var title = Ui.Text(entry.Title, 14, "#E7EBF2", FontWeights.SemiBold); title.TextWrapping = TextWrapping.NoWrap; title.TextTrimming = TextTrimming.CharacterEllipsis; texts.Children.Add(title);
|
||||
var sub = Ui.Text(entry.Subtitle, 11, "#8996A8"); sub.Margin = new Thickness(0, 5, 0, 0); sub.TextWrapping = TextWrapping.NoWrap; sub.TextTrimming = TextTrimming.CharacterEllipsis; texts.Children.Add(sub); Grid.SetColumn(texts, 1); grid.Children.Add(texts);
|
||||
if (entry.Star) { var star = Ui.Glyph("\uE735", 12, "#D6C27D"); Grid.SetColumn(star, 2); grid.Children.Add(star); }
|
||||
var item = new ListBoxItem { Content = grid, Tag = entry }; System.Windows.Automation.AutomationProperties.SetName(item, entry.Title); Cards.Items.Add(item);
|
||||
if (entry.Id == selectedId) Cards.SelectedItem = item;
|
||||
}
|
||||
if (Cards.SelectedItem == null && Cards.Items.Count > 0) Cards.SelectedIndex = 0;
|
||||
ListEmpty.Visibility = entries.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (entries.Count == 0) { selected = null; RenderDetail(); }
|
||||
}
|
||||
private void RenderDetail()
|
||||
{
|
||||
Details.Children.Clear(); var entry = selected;
|
||||
if (entry == null)
|
||||
{
|
||||
var empty = new StackPanel { Margin = new Thickness(15, 100, 15, 0), HorizontalAlignment = HorizontalAlignment.Center };
|
||||
empty.Children.Add(Ui.Tile("\uE8A5", "#A5E4BC", 64)); var heading = Ui.Text(data!.Entries.All(e => e.Deleted) ? "Ваше личное хранилище" : "Выберите карточку", 23, "#E7EBF2", FontWeights.SemiBold); heading.Margin = new Thickness(0, 24, 0, 12); heading.TextAlignment = TextAlignment.Center; empty.Children.Add(heading);
|
||||
var hint = Ui.Text("Сохраните первый пароль, карту или заметку.\nВсё содержимое будет зашифровано на этом устройстве.", 13, "#969EAD"); hint.TextAlignment = TextAlignment.Center; empty.Children.Add(hint);
|
||||
var add = Ui.Button("+ Добавить карточку", AddEntry, "Primary"); add.Margin = new Thickness(0, 24, 0, 0); empty.Children.Add(add); Details.Children.Add(empty); return;
|
||||
}
|
||||
int index = Templates.Index(entry.Category);
|
||||
var category = Ui.Text(Templates.Titles[index].ToUpperInvariant(), 10, "#829186", FontWeights.SemiBold); category.Margin = new Thickness(0, 0, 0, 22); Details.Children.Add(category);
|
||||
var header = new DockPanel(); var tile = Ui.Tile(Templates.Glyphs[index], Templates.Colors[index], 58); tile.Margin = new Thickness(0, 0, 16, 0); DockPanel.SetDock(tile, Dock.Left); header.Children.Add(tile);
|
||||
var actions = Ui.Row(Ui.IconButton(entry.Star ? "\uE735" : "\uE734", "В избранное", () => ChangeEntry(e => e.Star = !e.Star)));
|
||||
if (!entry.Deleted) { var edit = Ui.Button("Изменить", () => EditEntry(entry)); edit.Margin = new Thickness(6, 0, 0, 0); edit.FontSize = 12; edit.Padding = new Thickness(12, 8, 12, 8); actions.Children.Add(edit); }
|
||||
DockPanel.SetDock(actions, Dock.Right); header.Children.Add(actions);
|
||||
var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; var title = Ui.Text(entry.Title, 25, "#F0F2F6", FontWeights.SemiBold); title.TextTrimming = TextTrimming.CharacterEllipsis; title.TextWrapping = TextWrapping.NoWrap; titles.Children.Add(title);
|
||||
var subtitle = Ui.Text(entry.Subtitle, 12, "#969EAD"); subtitle.Margin = new Thickness(0, 7, 0, 0); titles.Children.Add(subtitle); header.Children.Add(titles); Details.Children.Add(header);
|
||||
if (entry.Tags.Count > 0)
|
||||
{
|
||||
var tags = new WrapPanel { Margin = new Thickness(0, 22, 0, 3) };
|
||||
foreach (var tag in entry.Tags) { var chip = Ui.Box(Ui.Text(tag, 11, "#B0BFC3"), "#20272D", 5, 5); chip.Padding = new Thickness(9, 4, 9, 4); chip.Margin = new Thickness(0, 0, 7, 5); tags.Children.Add(chip); } Details.Children.Add(tags);
|
||||
}
|
||||
Details.Children.Add(Ui.Rule());
|
||||
if (entry.Deleted)
|
||||
{
|
||||
var restore = Ui.Button("Восстановить из корзины", () => { ChangeEntry(e => e.Deleted = false); SetStatus("Карточка восстановлена."); }, "Primary"); restore.Margin = new Thickness(0, 0, 0, 20); Details.Children.Add(restore);
|
||||
}
|
||||
foreach (var f in entry.Fields)
|
||||
{
|
||||
var row = new Grid(); row.ColumnDefinitions.Add(new ColumnDefinition()); row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
var text = new StackPanel { Margin = new Thickness(0, 0, 12, 0) }; text.Children.Add(Ui.Text(f.Name, 11, "#8F9CAB"));
|
||||
var value = Ui.Text(f.Secret && f.Value.Length > 0 ? "••••••••••••••••" : f.Value.Length == 0 ? "Не заполнено" : f.Value, 15, f.Secret ? "#D7E5DC" : "#E1E6EE"); value.FontFamily = new FontFamily(f.Secret ? "Cascadia Mono, Consolas" : "Segoe UI"); value.Margin = new Thickness(0, 9, 0, 0); text.Children.Add(value); row.Children.Add(text);
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||||
if (f.Secret)
|
||||
{
|
||||
bool visible = false; var reveal = Ui.IconButton("\uE890", "Показать / скрыть «" + f.Name + "»", () => { visible = !visible; value.Text = visible ? f.Value : "••••••••••••••••"; }); buttons.Children.Add(reveal);
|
||||
}
|
||||
var copy = Ui.IconButton("\uE8C8", "Скопировать «" + f.Name + "»", () => CopySecret(f.Value)); copy.IsEnabled = f.Value.Length > 0; buttons.Children.Add(copy); Grid.SetColumn(buttons, 1); row.Children.Add(buttons);
|
||||
var box = Ui.Box(row, "#191E25", 10, 16); box.Margin = new Thickness(0, 0, 0, 10); Details.Children.Add(box);
|
||||
}
|
||||
if (entry.Notes.Length > 0)
|
||||
{
|
||||
Ui.Label(Details, "ЗАМЕТКА"); var note = Ui.Text(entry.Notes, 13, "#AFB9C7"); note.LineHeight = 21; Details.Children.Add(note);
|
||||
}
|
||||
if (entry.Extra?.ContainsKey("attachments") == true) { var hint = Ui.Text("Вложения сохранены в зашифрованной базе. Их просмотр пока недоступен.", 11, "#788493"); hint.Margin = new Thickness(0, 18, 0, 0); Details.Children.Add(hint); }
|
||||
Details.Children.Add(Ui.Rule());
|
||||
string modified; try { modified = DateTimeOffset.FromUnixTimeMilliseconds(entry.Modified).LocalDateTime.ToString("dd MMMM yyyy, HH:mm"); } catch (ArgumentOutOfRangeException) { modified = "дата неизвестна"; }
|
||||
var footer = new DockPanel();
|
||||
if (!entry.Deleted) { var delete = Ui.IconButton("\uE74D", "В корзину", () => { if (Confirm("Переместить карточку в корзину?", "Её можно будет восстановить в разделе «Корзина».")) ChangeEntry(e => e.Deleted = true); }); DockPanel.SetDock(delete, Dock.Right); footer.Children.Add(delete); }
|
||||
var time = Ui.Text("Изменено " + modified, 10, "#788493"); time.VerticalAlignment = VerticalAlignment.Center; footer.Children.Add(time); Details.Children.Add(footer);
|
||||
}
|
||||
|
||||
private void ChangeEntry(Action<Entry> change)
|
||||
{
|
||||
if (selected == null || key == null || busy) return;
|
||||
try
|
||||
{
|
||||
var next = Vault.Copy(data!); var entry = Vault.Copy(selected); change(entry); Vault.Update(next, entry); store.Save(next, key); data = next; selected = entry; BuildNavigation(); RefreshCards(); SetStatus("Изменения сохранены и зашифрованы.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or CryptographicException) { SetStatus("Не удалось сохранить изменения: " + ex.Message, true); }
|
||||
}
|
||||
|
||||
public void CopySecret(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
clipboardMarker = Guid.NewGuid().ToString("N"); var clip = new DataObject(); clip.SetText(value); clip.SetData("QSafe.Clipboard.Owner", clipboardMarker);
|
||||
clip.SetData("CanIncludeInClipboardHistory", new MemoryStream(BitConverter.GetBytes(0)));
|
||||
clip.SetData("CanUploadToCloudClipboard", new MemoryStream(BitConverter.GetBytes(0)));
|
||||
Clipboard.SetDataObject(clip, true); clipboardTimer.Stop(); clipboardTimer.Interval = TimeSpan.FromSeconds(30); clipboardTimer.Start(); SetStatus("Скопировано. Буфер обмена будет очищен через 30 секунд.");
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException) { SetStatus("Буфер обмена занят другим приложением. Повторите копирование.", true); }
|
||||
}
|
||||
private void ClearClipboard()
|
||||
{
|
||||
clipboardTimer.Stop();
|
||||
try { if (clipboardMarker != null && Clipboard.GetData("QSafe.Clipboard.Owner") as string == clipboardMarker) Clipboard.Clear(); }
|
||||
catch (System.Runtime.InteropServices.COMException) { clipboardTimer.Interval = TimeSpan.FromSeconds(1); clipboardTimer.Start(); return; }
|
||||
clipboardMarker = null;
|
||||
}
|
||||
private void Lock()
|
||||
{
|
||||
foreach (Window window in OwnedWindows.Cast<Window>().ToArray()) { if (window is AppDialog dialog) dialog.ForceClose(); else window.Close(); }
|
||||
ClearClipboard(); data = null; Details.Children.Clear(); Cards.Items.Clear(); Welcome.Children.Clear(); Search.Clear(); selected = null;
|
||||
Workspace.Visibility = Visibility.Collapsed; LockButton.Visibility = Visibility.Collapsed;
|
||||
if (busy) { deferredLock = true; data = null; Welcome.Visibility = Visibility.Collapsed; BusyText.Text = "Блокируем хранилище…"; return; }
|
||||
key?.Dispose(); key = null; data = null;
|
||||
if (!closed) { ShowWelcome(); SetStatus("Хранилище заблокировано."); }
|
||||
}
|
||||
|
||||
private async Task Synchronize(bool manual = true)
|
||||
{
|
||||
if (key == null || busy || IsDemo) { if (IsDemo) SetStatus("Синхронизация недоступна в демо."); return; }
|
||||
if (!store.Connected) { if (manual) ShowSettings(); return; }
|
||||
lastAutoSync = DateTime.UtcNow;
|
||||
await Work("Синхронизируем зашифрованную базу…", async () =>
|
||||
{
|
||||
var sessionKey = key!;
|
||||
using var client = new SyncClient(store.Config, HomeWifiNetwork.Instance);
|
||||
var result = await Task.Run(() => client.Sync(store.Read(), store.ReadBase(), sessionKey, lifetime.Token));
|
||||
store.SaveEnvelope(result.Envelope); store.MarkSynced(result.Envelope);
|
||||
if (!deferredLock) { data = result.Data; BuildNavigation(); RefreshCards(); }
|
||||
SetStatus(result.Conflicts > 0 ? $"Синхронизировано. Конфликтов: {result.Conflicts}; обе версии сохранены отдельными карточками." : "Все изменения синхронизированы · " + DateTime.Now.ToString("HH:mm"));
|
||||
});
|
||||
}
|
||||
|
||||
private void ImportVault()
|
||||
{
|
||||
var file = new OpenFileDialog { Title = "Открыть зашифрованную базу QSafe", Filter = "Хранилище QSafe (*.qsafe)|*.qsafe", CheckFileExists = true };
|
||||
if (file.ShowDialog(this) != true) return;
|
||||
try
|
||||
{
|
||||
var blob = LocalStore.ReadBounded(file.FileName); VaultCrypto.Validate(blob);
|
||||
if (store.Exists && !Confirm("Восстановить другую базу?", "Текущая зашифрованная база останется в локальной папке backups. Подключение к Pi нужно проверить перед синхронизацией.")) return;
|
||||
Lock(); ShowWelcome("restore", blob);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { SetStatus(ex.Message, true); }
|
||||
}
|
||||
private void ExportVault()
|
||||
{
|
||||
var file = new SaveFileDialog { Title = "Экспорт зашифрованной базы", Filter = "Хранилище QSafe (*.qsafe)|*.qsafe", FileName = "QSafe-" + DateTime.Now.ToString("yyyy-MM-dd") + ".qsafe" };
|
||||
if (file.ShowDialog(this) != true) return;
|
||||
try { LocalStore.AtomicWrite(file.FileName, store.Read()); SetStatus("Зашифрованная резервная копия сохранена. Для открытия нужен мастер-пароль."); }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { SetStatus(ex.Message, true); }
|
||||
}
|
||||
|
||||
private void SearchChanged(object sender, TextChangedEventArgs e) { if (data != null) RefreshCards(); }
|
||||
private void CardSelected(object sender, SelectionChangedEventArgs e) { if (Cards.SelectedItem is ListBoxItem { Tag: Entry entry }) { selected = entry; RenderDetail(); } }
|
||||
private void LockClick(object sender, RoutedEventArgs e) => Lock();
|
||||
private void AddClick(object sender, RoutedEventArgs e) => AddEntry();
|
||||
private void ClearSearchClick(object sender, RoutedEventArgs e) { Search.Clear(); Search.Focus(); }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Automation;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using QSafe.Core;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
public sealed class PasswordGeneratorDialog : AppDialog
|
||||
{
|
||||
private readonly TextBox output;
|
||||
private readonly Slider length;
|
||||
private readonly TextBlock lengthLabel, hint;
|
||||
private readonly CheckBox symbols, digits;
|
||||
private readonly int[] sizes = [24, 12, 8];
|
||||
private PasswordMode mode;
|
||||
private bool updating;
|
||||
|
||||
public PasswordGeneratorDialog(Window owner, Action<string> use, bool forField = true)
|
||||
: base(owner, "Генератор паролей", "Выберите тип и длину, затем используйте подходящий вариант.", 620)
|
||||
{
|
||||
Ui.Label(Body, "Сгенерированный пароль");
|
||||
output = new TextBox { IsReadOnly = true, IsUndoEnabled = false, FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 22, TextWrapping = TextWrapping.Wrap, MinHeight = 96, Margin = new Thickness(0, 0, 0, 8) };
|
||||
AutomationProperties.SetName(output, "Сгенерированный пароль"); Body.Children.Add(output);
|
||||
Ui.Label(Body, "Тип пароля");
|
||||
var choices = new WrapPanel(); Body.Children.Add(choices);
|
||||
var titles = new[] { "Сложный", "Только цифры", "Запоминающийся" };
|
||||
var radios = new List<RadioButton>();
|
||||
for (int i = 0; i < titles.Length; i++)
|
||||
{
|
||||
var selectedMode = (PasswordMode)i;
|
||||
var radio = new RadioButton { Content = titles[i], GroupName = "PasswordMode", Foreground = Ui.Brush("#F0F2F6"), FontSize = 14, Padding = new Thickness(3, 8, 0, 8), Margin = new Thickness(0, 0, 18, 0), VerticalContentAlignment = VerticalAlignment.Center };
|
||||
AutomationProperties.SetName(radio, titles[i]); radios.Add(radio); choices.Children.Add(radio);
|
||||
radio.Checked += (_, _) => { mode = selectedMode; Configure(); };
|
||||
}
|
||||
lengthLabel = Ui.Text("", 14); lengthLabel.Margin = new Thickness(0, 18, 0, 12); Body.Children.Add(lengthLabel);
|
||||
length = new Slider { IsSnapToTickEnabled = true, TickFrequency = 1, SmallChange = 1, LargeChange = 4, MinHeight = 30 };
|
||||
AutomationProperties.SetName(length, "Длина пароля"); Body.Children.Add(length);
|
||||
symbols = new CheckBox { Content = "Специальные символы (!@#…)", IsChecked = true, Margin = new Thickness(0, 18, 0, 12) }; Body.Children.Add(symbols);
|
||||
digits = new CheckBox { Content = "Добавлять цифры (0–9)", IsChecked = true, Margin = new Thickness(0, 0, 0, 12) }; Body.Children.Add(digits);
|
||||
hint = Ui.Text("", 12, "#969EAD"); Body.Children.Add(hint);
|
||||
length.ValueChanged += (_, _) => { if (!updating) { sizes[(int)mode] = (int)length.Value; Generate(); } };
|
||||
symbols.Checked += (_, _) => Generate(); symbols.Unchecked += (_, _) => Generate();
|
||||
digits.Checked += (_, _) => Generate(); digits.Unchecked += (_, _) => Generate();
|
||||
var regenerate = Ui.Button("Другой вариант", Generate); regenerate.Margin = new Thickness(0, 16, 0, 0); Body.Children.Add(regenerate);
|
||||
AddCancel();
|
||||
Actions.Children.Add(Ui.Button(forField ? "Использовать пароль" : "Скопировать пароль", () => { use(output.Text); Accept(); }, "Primary"));
|
||||
Closed += (_, _) => output.Clear();
|
||||
radios[0].IsChecked = true;
|
||||
}
|
||||
|
||||
private void Configure()
|
||||
{
|
||||
updating = true;
|
||||
// Reset both bounds first so switching between words and characters never coerces a saved value.
|
||||
length.Minimum = 0; length.Maximum = 128;
|
||||
length.Minimum = mode == PasswordMode.Complex ? 16 : mode == PasswordMode.Digits ? 4 : 2;
|
||||
length.Maximum = mode == PasswordMode.Memorable ? 12 : 64;
|
||||
length.Value = sizes[(int)mode];
|
||||
symbols.Visibility = mode != PasswordMode.Digits ? Visibility.Visible : Visibility.Collapsed;
|
||||
digits.Visibility = mode == PasswordMode.Memorable ? Visibility.Visible : Visibility.Collapsed;
|
||||
hint.Text = mode switch
|
||||
{
|
||||
PasswordMode.Complex => "Заглавные и строчные латинские буквы, цифры. Похожие символы исключены.",
|
||||
PasswordMode.Digits => "Цифры 0–9, в том числе в начале. Короткий код легче подобрать.",
|
||||
_ => "От 2 случайных английских слов. Цифры и знаки размещаются случайно перед словами, между ними или в конце. Слова остаются целыми."
|
||||
};
|
||||
updating = false; Generate();
|
||||
}
|
||||
|
||||
private void Generate()
|
||||
{
|
||||
int size = sizes[(int)mode];
|
||||
output.Text = PasswordGenerator.Generate(mode, size, symbols.IsChecked == true, digits.IsChecked == true);
|
||||
lengthLabel.Text = mode == PasswordMode.Memorable ? $"Слов: {size} · символов: {output.Text.Length}" : $"Длина: {size} {(mode == PasswordMode.Digits ? "цифр" : "символов")}";
|
||||
AutomationProperties.SetHelpText(length, lengthLabel.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<AssemblyName>QSafe</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets/QSafe.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../QSafe.Core/QSafe.Core.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Automation;
|
||||
|
||||
namespace QSafe.Desktop;
|
||||
|
||||
public static class Ui
|
||||
{
|
||||
public static SolidColorBrush Brush(string color) => (SolidColorBrush)new BrushConverter().ConvertFromString(color)!;
|
||||
public static TextBlock Text(string text, double size = 14, string color = "#F0F2F6", FontWeight? weight = null) => new() { Text = text, FontSize = size, Foreground = Brush(color), FontWeight = weight ?? FontWeights.Normal, TextWrapping = TextWrapping.Wrap };
|
||||
public static TextBlock Glyph(string glyph, double size = 18, string color = "#969EAD") => new() { Text = glyph, FontFamily = new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets"), FontSize = size, Foreground = Brush(color), VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
public static Button Button(string text, Action action, string? style = null)
|
||||
{
|
||||
var b = new Button { Content = text, MinHeight = 38 };
|
||||
AutomationProperties.SetName(b, text);
|
||||
if (style != null) b.Style = (Style)Application.Current.FindResource(style);
|
||||
b.Click += (_, _) => action(); return b;
|
||||
}
|
||||
public static Button IconButton(string glyph, string label, Action action)
|
||||
{
|
||||
var b = Button(label, action, "Quiet"); b.Content = Glyph(glyph, 17); b.ToolTip = label; b.Width = 38; b.Padding = new Thickness(8); return b;
|
||||
}
|
||||
public static Border Tile(string glyph, string color, double size = 42) => new() { Width = size, Height = size, CornerRadius = new CornerRadius(size / 3), Background = Brush("#252C32"), Child = Glyph(glyph, size * .46, color) };
|
||||
public static Border Box(UIElement child, string background = "#171A20", double radius = 12, double padding = 20) => new() { Child = child, Background = Brush(background), CornerRadius = new CornerRadius(radius), Padding = new Thickness(padding), BorderBrush = Brush("#2C313B"), BorderThickness = new Thickness(1) };
|
||||
public static void Label(Panel panel, string text) { var label = Text(text, 12, "#969EAD", FontWeights.SemiBold); label.Margin = new Thickness(0, 18, 0, 7); panel.Children.Add(label); }
|
||||
public static TextBox Input(Panel panel, string label, string value = "", bool multiline = false)
|
||||
{
|
||||
Label(panel, label);
|
||||
var input = new TextBox { Text = value, AcceptsReturn = multiline, TextWrapping = multiline ? TextWrapping.Wrap : TextWrapping.NoWrap, MinHeight = multiline ? 90 : 42, MaxHeight = multiline ? 180 : 48, VerticalScrollBarVisibility = multiline ? ScrollBarVisibility.Auto : ScrollBarVisibility.Hidden };
|
||||
AutomationProperties.SetName(input, label); input.SpellCheck.IsEnabled = false; panel.Children.Add(input); return input;
|
||||
}
|
||||
public static StackPanel Row(params UIElement[] children)
|
||||
{
|
||||
var row = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
foreach (var c in children) row.Children.Add(c); return row;
|
||||
}
|
||||
public static Border Rule() => new() { Height = 1, Background = Brush("#2C313B"), Margin = new Thickness(0, 16, 0, 16) };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Kusoft.QSafe" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false" /></requestedPrivileges></security></trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application></compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,23 @@
|
||||
import ru.kusoft.qsafe.VaultCrypto;
|
||||
import java.nio.file.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Uses the actual Android crypto implementation, with synthetic data only. */
|
||||
public class InteropFixture {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path directory=Paths.get(args[1]);Files.createDirectories(directory);
|
||||
char[] password="Публичный тест 🔐 QSafe 2026".toCharArray();
|
||||
if(args[0].equals("write")) {
|
||||
String json="{\"format\":1,\"id\":\"interop-vault\",\"futureRoot\":{\"a\":1},\"entries\":[{\"id\":\"interop-card\",\"rev\":\"java-revision\",\"title\":\"Привет из Android 🔐\",\"notes\":\"Строка 1\\nСтрока 2\",\"fields\":[{\"name\":\"Пароль\",\"type\":\"password\",\"value\":\"Секрет & < >\",\"futureField\":true}],\"tags\":[\"Тест\"],\"attachments\":[{\"name\":\"test.txt\",\"data\":\"YWJj\"}],\"safeInCloudXml\":\"<card title='old'/>\"}]}";
|
||||
try(VaultCrypto.Key key=VaultCrypto.create(password)) { Files.write(directory.resolve("java.qsafe"),VaultCrypto.encrypt(json.getBytes(StandardCharsets.UTF_8),key)); }
|
||||
System.out.println("PASS Java generated Android-compatible fixture");
|
||||
} else {
|
||||
byte[] bytes=Files.readAllBytes(directory.resolve("dotnet.qsafe"));
|
||||
try(VaultCrypto.Key key=VaultCrypto.unlock(bytes,password)) {
|
||||
String json=new String(VaultCrypto.decrypt(bytes,key),StandardCharsets.UTF_8);
|
||||
if(!json.contains("dotnet-revision")||!json.contains("attachments")||!json.contains("futureField"))throw new AssertionError("Lost data");
|
||||
System.out.println("PASS Android Java decrypts Windows envelope with Unicode password and preserved metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using QSafe.Core;
|
||||
using QSafe.Desktop;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int passed;
|
||||
private static void Assert(bool condition, string message = "Assertion failed") { if (!condition) throw new Exception(message); }
|
||||
private static void Reject<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception("Expected " + typeof(T).Name); }
|
||||
private static async Task RejectAsync<T>(Func<Task> action) where T : Exception { try { await action(); } catch (T) { return; } throw new Exception("Expected " + typeof(T).Name); }
|
||||
private static void Test(string title, Action action) { action(); passed++; Console.WriteLine("PASS " + title); }
|
||||
private static async Task TestAsync(string title, Func<Task> action) { await action(); passed++; Console.WriteLine("PASS " + title); }
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.FirstOrDefault() == "--lan-probe")
|
||||
{
|
||||
var config = Connection.Parse(File.ReadAllBytes(args[1]));
|
||||
var binding = HomeWifiNetwork.Instance.Current(config); LanPolicy.Check(config, binding);
|
||||
Console.WriteLine($"PASS home Wi-Fi confirmed: {binding.Ssid}, {binding.Address}/{binding.PrefixLength}, interface {binding.InterfaceIndex}");
|
||||
using var client = new SyncClient(config, HomeWifiNetwork.Instance);
|
||||
var encrypted = client.Download().GetAwaiter().GetResult(); VaultCrypto.Validate(encrypted);
|
||||
Console.WriteLine($"PASS pinned LAN TLS and authenticated encrypted vault download ({encrypted.Length} bytes); no plaintext read, no writes");
|
||||
return 0;
|
||||
}
|
||||
var artifact = Path.GetFullPath(args.FirstOrDefault() ?? "artifacts/windows-qa"); Directory.CreateDirectory(artifact);
|
||||
CoreTests(artifact).GetAwaiter().GetResult(); DesktopTests(artifact);
|
||||
Console.WriteLine($"RESULT {passed} checks passed"); return 0;
|
||||
}
|
||||
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
|
||||
}
|
||||
private static async Task CoreTests(string artifact)
|
||||
{
|
||||
using var key = VaultCrypto.Create("Long synthetic test password 🔐");
|
||||
var original = new VaultData(); var card = Templates.Create("login"); card.Title = "Test"; card.Fields[1].Value = "Secret"; original.Entries.Add(card);
|
||||
var blob = Vault.Encrypt(original, key);
|
||||
Test("AES-GCM roundtrip and random nonce", () => { Assert(Vault.Read(blob, key).Entries[0].Fields[1].Value == "Secret"); Assert(!blob.SequenceEqual(Vault.Encrypt(original, key))); });
|
||||
Test("Wrong password and tampering rejected", () => { Reject<CryptographicException>(() => VaultCrypto.Unlock(blob, "wrong")); var corrupt = blob.ToArray(); corrupt[^1] ^= 1; Reject<CryptographicException>(() => Vault.Read(corrupt, key)); });
|
||||
Test("Envelope bounds and iteration ceiling checked before KDF", () => { Reject<InvalidDataException>(() => VaultCrypto.Validate(new byte[8])); var corrupt = blob.ToArray(); Array.Fill(corrupt, (byte)127, 8, 4); Reject<InvalidDataException>(() => VaultCrypto.Unlock(corrupt, "wrong")); });
|
||||
Test("Key disposal clears bytes", () => { var temporary = VaultCrypto.Create("test password"); temporary.Dispose(); Assert(temporary.Bytes.All(b => b == 0)); });
|
||||
Test("Password generator character groups, options and bounds", () =>
|
||||
{
|
||||
for (int i = 0; i < 150; i++) { var p = VaultCrypto.Generate(32); Assert(p.Length == 32 && p.Any(char.IsUpper) && p.Any(char.IsLower) && p.Any(char.IsDigit) && p.Any(c => !char.IsLetterOrDigit(c))); Assert(VaultCrypto.Generate(16, false).All(char.IsLetterOrDigit)); }
|
||||
Reject<ArgumentOutOfRangeException>(() => VaultCrypto.Generate(8));
|
||||
});
|
||||
Test("Password modes enforce lengths, digits, words and symbol choice", () =>
|
||||
{
|
||||
foreach (int size in new[] { 4, 12, 64, 128 })
|
||||
for (int i = 0; i < 50; i++) { var p = PasswordGenerator.Generate(PasswordMode.Digits, size); Assert(p.Length == size && p.All(c => c is >= '0' and <= '9')); }
|
||||
foreach (int size in new[] { 2, 3, 8, 12 })
|
||||
foreach (bool symbols in new[] { true, false }) foreach (bool digits in new[] { true, false })
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var p = PasswordGenerator.Generate(PasswordMode.Memorable, size, symbols, digits);
|
||||
Assert(Regex.Matches(p, "[A-Z]?[a-z]+").Count == size && p.Any(char.IsDigit) == digits && p.Any(c => !char.IsLetterOrDigit(c)) == symbols);
|
||||
Assert(WholeWords(p, size), "Words must stay whole");
|
||||
if (digits) Assert(p.Count(char.IsDigit) == 2);
|
||||
}
|
||||
Assert(PasswordGenerator.Generate(PasswordMode.Complex, 16, false).All(char.IsLetterOrDigit));
|
||||
foreach (var (mode, size) in new[] { (PasswordMode.Complex, 15), (PasswordMode.Complex, 129), (PasswordMode.Digits, 3), (PasswordMode.Digits, 129), (PasswordMode.Memorable, 1), (PasswordMode.Memorable, 13) })
|
||||
Reject<ArgumentOutOfRangeException>(() => PasswordGenerator.Generate(mode, size));
|
||||
});
|
||||
Test("Memorable digits and signs reach every word boundary in either order", () =>
|
||||
{
|
||||
var digitPositions = new bool[3]; var signPositions = new bool[3];
|
||||
bool signBeforeDigit = false, signAfterDigit = false;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
var p = PasswordGenerator.Generate(PasswordMode.Memorable, 2);
|
||||
var words = Regex.Matches(p, "[A-Z][a-z]+"); Assert(words.Count == 2 && WholeWords(p, 2));
|
||||
string[] boundaries = [p[..words[0].Index], p[(words[0].Index + words[0].Length)..words[1].Index], p[(words[1].Index + words[1].Length)..]];
|
||||
for (int slot = 0; slot < 3; slot++)
|
||||
{
|
||||
digitPositions[slot] |= boundaries[slot].Any(char.IsDigit);
|
||||
signPositions[slot] |= boundaries[slot].Any(c => !char.IsLetterOrDigit(c));
|
||||
}
|
||||
int sign = p.IndexOfAny("!@#$%&*+-=?".ToCharArray());
|
||||
for (int j = 0; j < p.Length; j++) if (char.IsDigit(p[j])) { signBeforeDigit |= sign < j; signAfterDigit |= sign > j; }
|
||||
}
|
||||
Assert(digitPositions.All(p => p) && signPositions.All(p => p) && signBeforeDigit && signAfterDigit);
|
||||
});
|
||||
Test("Malformed vaults and duplicate entry IDs rejected", () =>
|
||||
{
|
||||
Reject<InvalidDataException>(() => Vault.Parse("{}"u8.ToArray())); var d = Vault.Copy(original); d.Entries.Add(Vault.Copy(card)); Reject<InvalidDataException>(() => Vault.Parse(Vault.Bytes(d)));
|
||||
Reject<InvalidDataException>(() => Vault.Parse("{\"format\":1,\"id\":\"x\",\"entries\":[{\"fields\":[]}]}"u8.ToArray()));
|
||||
Reject<InvalidDataException>(() => Vault.Parse("{\"format\":1,\"id\":\"x\",\"entries\":[null]}"u8.ToArray()));
|
||||
});
|
||||
Test("All eight templates use compatible secret types", () => { foreach (string kind in Templates.Kinds) { var e = Templates.Create(kind); Assert(e.Category == kind); Assert(e.Fields.All(f => f.Type != "password" || f.Secret)); } Assert(new Field { Type = "unknown" }.Secret); });
|
||||
var local = Vault.Copy(original); var remote = Vault.Copy(original);
|
||||
Test("Independent edits merge", () => { var extra = Templates.Create("note"); Vault.Update(local, extra); var changed = Vault.Copy(card); changed.Notes = "remote"; Vault.Update(remote, changed); var m = Vault.Merge(original, local, remote); Assert(m.Conflicts == 0 && m.Data.Entries.Count == 2 && m.Data.Entries.Single(e => e.Id == card.Id).Notes == "remote"); });
|
||||
Test("Concurrent edits preserve both revisions", () => { var a = Vault.Copy(original); var b = Vault.Copy(original); var ea = Vault.Copy(card); ea.Notes = "A"; Vault.Update(a, ea); var eb = Vault.Copy(card); eb.Notes = "B"; Vault.Update(b, eb); var m = Vault.Merge(original, a, b); Assert(m.Conflicts == 1 && m.Data.Entries.Count == 2 && m.Data.Entries.Select(e => e.Notes).Order().SequenceEqual(new[] { "A", "B" })); });
|
||||
Test("Delete/edit conflict retains edited card", () => { var a = Vault.Copy(original); var b = Vault.Copy(original); var ea = Vault.Copy(card); ea.Deleted = true; Vault.Update(a, ea); var eb = Vault.Copy(card); eb.Notes = "keep"; Vault.Update(b, eb); var m = Vault.Merge(original, a, b); Assert(m.Conflicts == 1 && m.Data.Entries.Single().Notes == "keep" && !m.Data.Entries.Single().Deleted); });
|
||||
Test("Different vaults cannot merge", () => Reject<InvalidDataException>(() => Vault.Merge(null, original, new VaultData())));
|
||||
Test("XML import retains all metadata and deduplicates", () =>
|
||||
{
|
||||
var xml = Encoding.UTF8.GetBytes("<database><label id='2' name='Работа'/><card title='Mail' star='true'><field name='Логин' type='login'>user</field><field name='Пароль' type='password'>a&b</field><notes>note</notes><label_id>2</label_id><file name='a.txt'>YWJj</file><history><x/></history></card><card deleted='true'/><card template='true'/></database>");
|
||||
var p = XmlImport.Preview(xml, new VaultData()); Assert(p.Added == 1 && p.Skipped == 2 && p.Attachments == 1 && p.Data.Entries[0].Fields[1].Value == "a&b" && p.Data.Entries[0].Tags.Single() == "Работа");
|
||||
Assert(XmlImport.Preview(xml, p.Data).Duplicates == 1);
|
||||
p.Data.Entries[0].Extra!.Remove("safeInCloudFingerprint"); Assert(XmlImport.Preview(xml, p.Data).Duplicates == 1);
|
||||
var round = Vault.Read(Vault.Encrypt(p.Data, key), key); Assert(round.Entries[0].Extra!["safeInCloudXml"].GetString()!.Contains("<history>"));
|
||||
});
|
||||
Test("XML rejects XXE, DTD and foreign roots", () => { Reject<System.Xml.XmlException>(() => XmlImport.Preview(Encoding.UTF8.GetBytes("<!DOCTYPE database [<!ENTITY x SYSTEM 'file:///C:/Windows/win.ini'>]><database><card><notes>&x;</notes></card></database>"), original)); Reject<InvalidDataException>(() => XmlImport.Preview("<other/>"u8.ToArray(), original)); });
|
||||
Test("Connection rejects HTTP, userinfo, fragments and malformed tokens", () =>
|
||||
{
|
||||
foreach (var url in new[] { "http://example.test/", "https://user@example.test/", "https://example.test/?x=1", "https://example.test/#fragment" }) Reject<InvalidDataException>(() => new Connection { Url = url, Token = new string('a', 43) }.Validate());
|
||||
Reject<InvalidDataException>(() => Connection.Parse("{\"url\":null,\"token\":null}"u8.ToArray()));
|
||||
});
|
||||
var config = TestConnection();
|
||||
var home = new FakeHomeNetwork();
|
||||
var handler = new FakeServer(); using var client = new SyncClient(config, home, handler);
|
||||
await TestAsync("Vault transport is never reached outside home Wi-Fi or local subnet", async () =>
|
||||
{
|
||||
foreach (var binding in new[] { new LanBinding("Other Wi-Fi", true, IPAddress.Parse("192.168.0.61"), 24, 3), new LanBinding("gen", false, IPAddress.Parse("192.168.0.61"), 24, 3), new LanBinding("gen", true, IPAddress.Parse("192.168.1.61"), 24, 3) })
|
||||
{
|
||||
home.Binding = binding; int before = handler.Requests; await RejectAsync<IOException>(() => client.Download()); Assert(handler.Requests == before);
|
||||
}
|
||||
home.Binding = FakeHomeNetwork.Home;
|
||||
Assert(!LanPolicy.PrivateIpv4(IPAddress.Parse("8.8.8.8")) && !LanPolicy.PrivateIpv4(IPAddress.Parse("127.0.0.1")) && !LanPolicy.PrivateIpv4(IPAddress.Parse("169.254.1.1")) && !LanPolicy.PrivateIpv4(IPAddress.IPv6Loopback));
|
||||
});
|
||||
Test("Public vault URLs and hostnames rejected without DNS or transport", () =>
|
||||
{
|
||||
foreach (var url in new[] { "https://argus.kusoft.xyz/qsafe/", "https://8.8.8.8/", "https://localhost/", "https://192.168.0.185.evil.test/", "http://192.168.0.185/", "https://192.168.0.185/?x=1" })
|
||||
{ var c = TestConnection(); c.Url = url; Reject<InvalidDataException>(c.Validate); }
|
||||
var missing = TestConnection(); missing.HomeWifiSsid = ""; Reject<InvalidDataException>(missing.Validate);
|
||||
missing = TestConnection(); missing.CertificateSha256 = ""; Reject<InvalidDataException>(missing.Validate);
|
||||
});
|
||||
await TestAsync("Sync creates remote using If-None-Match and ciphertext only", async () => { var result = await client.Sync(blob, null, key); Assert(handler.Blob!.SequenceEqual(blob) && handler.LastCondition == "*" && result.Data.Id == original.Id); });
|
||||
config.Etag = VaultCrypto.Hash(blob);
|
||||
var changedLocal = Vault.Copy(original); var edit = Vault.Copy(card); edit.Notes = "local change"; Vault.Update(changedLocal, edit); var changedBlob = Vault.Encrypt(changedLocal, key);
|
||||
await TestAsync("Sync upload uses If-Match and confirmed SHA-256", async () => { await client.Sync(changedBlob, blob, key); Assert(handler.Blob!.SequenceEqual(changedBlob) && handler.LastCondition == '"' + VaultCrypto.Hash(blob) + '"'); });
|
||||
config.Etag = VaultCrypto.Hash(changedBlob);
|
||||
await TestAsync("Sync remote-only change downloaded without upload", async () => { handler.Blob = blob; var puts = handler.Puts; var r = await client.Sync(changedBlob, changedBlob, key); Assert(r.Envelope.SequenceEqual(blob) && handler.Puts == puts); });
|
||||
await TestAsync("Sync concurrent changes preserve both entries", async () =>
|
||||
{
|
||||
var r = Vault.Copy(original); var e = Vault.Copy(card); e.Notes = "remote change"; Vault.Update(r, e); handler.Blob = Vault.Encrypt(r, key); config.Etag = VaultCrypto.Hash(blob);
|
||||
var merged = await client.Sync(changedBlob, blob, key); Assert(merged.Conflicts == 1 && merged.Data.Entries.Count == 2);
|
||||
});
|
||||
await TestAsync("Sync detects disappeared remote and never recreates it", async () => { handler.Blob = null; var puts = handler.Puts; await RejectAsync<IOException>(() => client.Sync(blob, blob, key)); Assert(handler.Puts == puts); });
|
||||
await TestAsync("Sync rejects corrupted remote without uploading", async () => { handler.Blob = blob.ToArray(); handler.Blob[^1] ^= 1; var puts = handler.Puts; await RejectAsync<CryptographicException>(() => client.Sync(changedBlob, blob, key)); Assert(handler.Puts == puts); });
|
||||
await TestAsync("Sync validates response checksum", async () => { handler.Blob = blob; handler.BadHash = true; await RejectAsync<InvalidDataException>(() => client.Sync(blob, null, key)); handler.BadHash = false; });
|
||||
await TestAsync("Sync retries conditional-write conflicts with fresh GET", async () => { handler.Blob = blob; config.Etag = VaultCrypto.Hash(blob); handler.ConflictOnce = true; var r = await client.Sync(changedBlob, blob, key); Assert(r.Envelope.SequenceEqual(changedBlob) && !handler.ConflictOnce); });
|
||||
await TestAsync("Leaving home between GET and PUT blocks upload", async () =>
|
||||
{
|
||||
handler.Blob = blob; config.Etag = VaultCrypto.Hash(blob); int before = handler.Requests, puts = handler.Puts;
|
||||
handler.AfterGet = () => home.Binding = FakeHomeNetwork.Home with { Ssid = "Away" };
|
||||
try { await RejectAsync<IOException>(() => client.Sync(changedBlob, blob, key)); Assert(handler.Requests == before + 1 && handler.Puts == puts); }
|
||||
finally { handler.AfterGet = null; home.Binding = FakeHomeNetwork.Home; }
|
||||
});
|
||||
Test("LAN HTTP handler disables proxy, redirects and certificate downloads", () =>
|
||||
{
|
||||
var method = typeof(SyncClient).GetMethod("CreateHandler", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
using var transport = (SocketsHttpHandler)method.Invoke(client, null)!;
|
||||
Assert(!transport.UseProxy && !transport.AllowAutoRedirect && transport.SslOptions.CertificateChainPolicy!.DisableCertificateDownloads && transport.SslOptions.CertificateRevocationCheckMode == System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck && transport.ConnectCallback != null);
|
||||
});
|
||||
await TestAsync("Bounded reads reject oversized streams", async () => { using var stream = new MemoryStream(new byte[20]); await RejectAsync<InvalidDataException>(() => SyncClient.Bounded(stream, 10)); });
|
||||
if (File.Exists(Path.Combine(artifact, "java.qsafe")))
|
||||
{
|
||||
Test("Actual Android Java envelope opens in Windows", () =>
|
||||
{
|
||||
var javaBlob = File.ReadAllBytes(Path.Combine(artifact, "java.qsafe")); using var javaKey = VaultCrypto.Unlock(javaBlob, "Публичный тест 🔐 QSafe 2026"); var d = Vault.Read(javaBlob, javaKey);
|
||||
Assert(d.Entries.Single().Title == "Привет из Android 🔐" && d.Extra!["futureRoot"].GetProperty("a").GetInt32() == 1 && d.Entries.Single().Fields.Single().Extra!["futureField"].GetBoolean());
|
||||
d.Entries[0].Rev = "dotnet-revision"; File.WriteAllBytes(Path.Combine(artifact, "dotnet.qsafe"), Vault.Encrypt(d, javaKey));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void DesktopTests(string artifact)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "QSafe-Tests", Guid.NewGuid().ToString("N")); var store = new LocalStore(path);
|
||||
Test("Windows DPAPI seals credentials for current user", () => { var secret = Encoding.UTF8.GetBytes("synthetic test token"); var sealedBytes = DeviceProtection.Protect(secret); Assert(!sealedBytes.SequenceEqual(secret)); Assert(DeviceProtection.Unprotect(sealedBytes).SequenceEqual(secret)); });
|
||||
Test("Atomic persistence keeps encrypted history and reloads config", () =>
|
||||
{
|
||||
using var key = VaultCrypto.Create("Test password for desktop"); var d = new VaultData(); for (int i = 0; i < 14; i++) { d.Entries.Add(new Entry { Title = "card " + i }); store.Save(d, key); }
|
||||
Assert(Directory.GetFiles(Path.Combine(path, "backups"), "*.qsafe").Length == 10); Assert(Vault.Read(store.Read(), key).Entries.Count == 14);
|
||||
var config = TestConnection(); config.Token = new string('b', 43); store.Configure(config); store.MarkSynced(store.Read()); var loaded = new LocalStore(path); Assert(loaded.Config.Etag == VaultCrypto.Hash(store.Read()) && loaded.Connected); Assert(!Encoding.UTF8.GetString(File.ReadAllBytes(Path.Combine(path, "connection.enc"))).Contains(new string('b', 43)));
|
||||
});
|
||||
Test("Changing server resets ancestor and ETag", () => { var config = TestConnection(); config.Url = "https://192.168.0.186:18443/qsafe/"; store.Configure(config); Assert(store.ReadBase() == null && store.Config.Etag == ""); });
|
||||
Test("WPF resources and synthetic demo render at desktop and minimum size", () =>
|
||||
{
|
||||
var app = new App(); app.InitializeComponent();
|
||||
var demoStore = new LocalStore(Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")));
|
||||
var main = new MainWindow(demoStore, true); app.MainWindow = main;
|
||||
var search = (TextBox)main.FindName("Search"); var cards = (ListBox)main.FindName("Cards");
|
||||
Test("Search finds non-secret fields and excludes passwords", () =>
|
||||
{
|
||||
search.Text = "GITHUB.COM"; Assert(cards.Items.Count == 1);
|
||||
search.Text = "Demo-Only-Password"; Assert(cards.Items.Count == 0);
|
||||
search.Clear(); search.Text = "GitHub"; search.Clear();
|
||||
});
|
||||
Test("Background operations do not reset the inactivity timer", () =>
|
||||
{
|
||||
var field = typeof(MainWindow).GetField("lastInput", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var old = DateTime.UtcNow.AddSeconds(-80); field.SetValue(main, old);
|
||||
var work = typeof(MainWindow).GetMethod("Work", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
((Task)work.Invoke(main, ["Test", (Func<Task>)(() => Task.CompletedTask)])!).GetAwaiter().GetResult(); Assert((DateTime)field.GetValue(main)! == old);
|
||||
});
|
||||
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-desktop.png"), 1360, 810);
|
||||
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-desktop-compact.png"), 1080, 660);
|
||||
var label = ((StackPanel)main.FindName("Details")).Children.OfType<TextBlock>().First().Text; Assert(label.Contains("САЙТЫ"));
|
||||
// Calls the application's lock logic in-process; no OS input or user vault is involved.
|
||||
search.Text = "GitHub";
|
||||
typeof(MainWindow).GetMethod("Lock", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(main, null);
|
||||
Assert(cards.Items.Count == 0 && ((StackPanel)main.FindName("Details")).Children.Count == 0);
|
||||
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-unlock.png"), 1360, 810);
|
||||
Test("Generator preview, mode lengths, cancel and explicit use", () =>
|
||||
{
|
||||
main.Show(); main.Hide();
|
||||
string? used = null, expected = null; Exception? failure = null;
|
||||
var generator = new PasswordGeneratorDialog(main, value => used = value);
|
||||
var preview = Descendants<TextBox>(generator.Body).Single();
|
||||
var slider = Descendants<Slider>(generator.Body).Single();
|
||||
var modes = Descendants<RadioButton>(generator.Body).ToArray();
|
||||
generator.Loaded += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Assert(preview.Text.Length == 24 && used == null);
|
||||
slider.Value = 32; Assert(preview.Text.Length == 32);
|
||||
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-complex.png"), 620, 680);
|
||||
modes[1].IsChecked = true; slider.Value = 4; Assert(preview.Text.Length == 4 && preview.Text.All(char.IsDigit));
|
||||
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-digits.png"), 620, 680);
|
||||
modes[2].IsChecked = true; slider.Value = 12; Assert(Regex.Matches(preview.Text, "[A-Z]?[a-z]+").Count == 12);
|
||||
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-memorable.png"), 620, 680);
|
||||
Assert(slider.Minimum == 2); slider.Value = 2;
|
||||
var symbols = Descendants<CheckBox>(generator.Body).Single(c => ((string)c.Content).StartsWith("Специальные"));
|
||||
var digits = Descendants<CheckBox>(generator.Body).Single(c => ((string)c.Content).StartsWith("Добавлять цифры"));
|
||||
Assert(symbols.Visibility == Visibility.Visible && digits.Visibility == Visibility.Visible);
|
||||
Assert(WholeWords(preview.Text, 2) && preview.Text.Count(char.IsDigit) == 2 && preview.Text.Count(c => !char.IsLetterOrDigit(c)) == 1);
|
||||
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-two-words.png"), 620, 680);
|
||||
digits.IsChecked = false; Assert(!preview.Text.Any(char.IsDigit));
|
||||
symbols.IsChecked = false; Assert(Regex.IsMatch(preview.Text, @"^[A-Z][a-z]+[A-Z][a-z]+$"));
|
||||
digits.IsChecked = true; symbols.IsChecked = true;
|
||||
modes[0].IsChecked = true; Assert(slider.Minimum == 16 && slider.Value == 32 && preview.Text.Length == 32);
|
||||
modes[1].IsChecked = true; Assert(slider.Minimum == 4 && slider.Value == 4);
|
||||
modes[2].IsChecked = true; Assert(slider.Value == 2);
|
||||
expected = preview.Text; Assert(used == null);
|
||||
generator.Actions.Children.OfType<Button>().Single(b => (string)b.Content == "Использовать пароль").RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
|
||||
}
|
||||
catch (Exception ex) { failure = ex; generator.Close(); }
|
||||
};
|
||||
Assert(generator.ShowDialog() == true); if (failure != null) throw failure;
|
||||
Assert(used == expected && preview.Text == "");
|
||||
used = null; var canceled = new PasswordGeneratorDialog(main, value => used = value);
|
||||
canceled.Loaded += (_, _) => canceled.Close(); canceled.ShowDialog(); Assert(used == null);
|
||||
});
|
||||
main.Close();
|
||||
});
|
||||
}
|
||||
private static void Render(FrameworkElement visual, string path, int width, int height)
|
||||
{
|
||||
visual.Measure(new Size(width, height)); visual.Arrange(new Rect(0, 0, width, height)); visual.UpdateLayout();
|
||||
var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); target.Render(visual);
|
||||
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(target)); using var stream = File.Create(path); encoder.Save(stream);
|
||||
}
|
||||
|
||||
private static IEnumerable<T> Descendants<T>(DependencyObject parent) where T : DependencyObject
|
||||
{
|
||||
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T match) yield return match;
|
||||
foreach (var item in Descendants<T>(child)) yield return item;
|
||||
}
|
||||
}
|
||||
private static bool WholeWords(string value, int count)
|
||||
{
|
||||
const string gap = @"[0-9!@#$%&*+\-=?]*";
|
||||
return Regex.IsMatch(value, "^" + gap + "[A-Z][a-z]+(?:" + gap + "[A-Z][a-z]+){" + (count - 1) + "}" + gap + "$");
|
||||
}
|
||||
|
||||
private sealed class FakeServer : HttpMessageHandler
|
||||
{
|
||||
public byte[]? Blob; public int Puts, Requests; public string LastCondition = ""; public bool BadHash, ConflictOnce; public Action? AfterGet;
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Requests++; Assert(request.RequestUri!.AbsoluteUri == "https://192.168.0.185:18443/qsafe/v1/vault"); Assert(request.Headers.Authorization?.Scheme == "Bearer");
|
||||
if (request.Method == HttpMethod.Get)
|
||||
{
|
||||
if (Blob == null) return new(HttpStatusCode.NotFound);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Blob) }; response.Headers.ETag = new EntityTagHeaderValue('"' + (BadHash ? new string('0', 64) : VaultCrypto.Hash(Blob)) + '"'); AfterGet?.Invoke(); return response;
|
||||
}
|
||||
Puts++; LastCondition = request.Headers.Contains("If-Match") ? request.Headers.GetValues("If-Match").Single() : request.Headers.GetValues("If-None-Match").Single();
|
||||
if (ConflictOnce) { ConflictOnce = false; return new(HttpStatusCode.PreconditionFailed); }
|
||||
if ((Blob == null && LastCondition != "*") || (Blob != null && LastCondition != '"' + VaultCrypto.Hash(Blob) + '"')) return new(HttpStatusCode.PreconditionFailed);
|
||||
Blob = await request.Content!.ReadAsByteArrayAsync(ct); var result = new HttpResponseMessage(HttpStatusCode.OK); result.Headers.ETag = new EntityTagHeaderValue('"' + VaultCrypto.Hash(Blob) + '"'); return result;
|
||||
}
|
||||
}
|
||||
private static Connection TestConnection() => new() { Url = "https://192.168.0.185:18443/qsafe/", Token = new string('a', 43), HomeWifiSsid = "gen", CertificateSha256 = new string('a', 64) };
|
||||
private sealed class FakeHomeNetwork : ILanNetwork
|
||||
{
|
||||
public static readonly LanBinding Home = new("gen", true, IPAddress.Parse("192.168.0.61"), 24, 3);
|
||||
public LanBinding Binding = Home;
|
||||
public LanBinding Current(Connection connection) => Binding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net10.0-windows</TargetFramework><OutputType>Exe</OutputType><UseWPF>true</UseWPF></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../QSafe.Desktop/QSafe.Desktop.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
QSafe для Windows 1.0.4
|
||||
|
||||
Запустите QSafe.exe. Программа работает без установки и не требует отдельно устанавливать .NET.
|
||||
Сборка предназначена для Windows x64. Выпуск и запуск проверены на текущем Windows-компьютере.
|
||||
|
||||
Если у вас уже есть QSafe на Android:
|
||||
1. Выберите «Подключить Pi и синхронизировать».
|
||||
2. В настройках выберите обновлённый личный connection-lan.qsafe. Он содержит локальный адрес Pi, домашний SSID и отпечаток сертификата. Проверьте параметры и сохраните.
|
||||
3. Введите мастер-пароль существующей базы и нажмите «Разблокировать».
|
||||
Создавать новую базу для подключения к существующей не нужно.
|
||||
|
||||
Если начинаете с нуля, нажмите «Создать хранилище» и дважды введите мастер-пароль от 12 символов.
|
||||
Чтобы посмотреть интерфейс, выберите «Посмотреть демо»: используются только вымышленные карточки во временной папке.
|
||||
|
||||
Основные действия:
|
||||
• Ctrl+F — поиск по названию, меткам и несекретным полям.
|
||||
• Ctrl+N — новая карточка, Ctrl+G — генератор паролей.
|
||||
• Ctrl+S — сохранить карточку в редакторе, Ctrl+L — заблокировать базу.
|
||||
• Глаз показывает секретное поле, значок копирования копирует значение на 30 секунд.
|
||||
• Удалённые карточки можно восстановить в корзине.
|
||||
• Настройки и синхронизация — подключение Pi, импорт XML SafeInCloud и резервные копии.
|
||||
|
||||
Локальные данные: %LOCALAPPDATA%\Kusoft\QSafe
|
||||
Зашифрованная база: vault.qsafe. Последние 10 зашифрованных сохранений: backups.
|
||||
Ключ подключения хранится в connection.enc под защитой вашей учётной записи Windows.
|
||||
Мастер-пароль и ключ шифрования не сохраняются на диск. Без мастер-пароля восстановить базу нельзя.
|
||||
|
||||
Автоблокировка: 2 минуты бездействия, сворачивание, блокировка сеанса или переход в сон.
|
||||
Автосинхронизация работает раз в минуту, пока хранилище разблокировано. После закрытия программы фоновых процессов нет.
|
||||
Синхронизация базы разрешена только внутри домашнего Wi-Fi с точным совпадением SSID. Pi должен находиться в той же локальной подсети. Ручная синхронизация и восстановление подчиняются этому же ограничению.
|
||||
Вне домашнего Wi-Fi приложение работает автономно с локальной базой. Соединений с базой через интернет, VPN, прокси и публичный DNS нет. При потере Wi-Fi соединение не переключается на другое подключение.
|
||||
Адрес Pi в текущей домашней сети: https://192.168.0.185:18443/qsafe/. Публичный маршрут базы на Аргусе закрыт. Обновления Android продолжают скачиваться через Аргус.
|
||||
При одновременном редактировании сохраняются обе версии. Параметры шифрования и формат совместимы с QSafe Android.
|
||||
|
||||
В первой версии отсутствуют Windows Hello, автозаполнение браузеров, смена мастер-пароля, TOTP и просмотр вложений.
|
||||
Вложения и неизвестные метаданные сохраняются при редактировании и синхронизации.
|
||||
Программа не проходила независимый аудит безопасности. EXE не подписан сертификатом издателя.
|
||||
Проверены запреты сетевых запросов вне домашней сети и загрузка зашифрованной базы с Pi внутри Wi-Fi gen. Мастер-пароль и открытые карточки при проверке не использовались.
|
||||
@@ -0,0 +1,44 @@
|
||||
# QSafe для Windows
|
||||
|
||||
Нативный WPF-клиент на .NET 10 с тёмным интерфейсом, поиском, избранным, восемью шаблонами карточек, корзиной, редактором произвольных полей, генератором паролей и синхронизацией с существующим Pi. Исходники в `QSafe.Core` и `QSafe.Desktop`, проверки в `QSafe.Tests`.
|
||||
|
||||
Готовая переносимая версия: `../artifacts/QSafe-Windows-1.0.4/QSafe.exe`. Отдельная установка .NET не нужна. ZIP содержит EXE и краткую инструкцию. Сборка x64 проверена на текущей Windows; других архитектур в поставке нет.
|
||||
|
||||
Генератор в редакторе карточки показывает пароль открыто до применения. Доступны сложный пароль (16–64 символа, специальные символы по выбору), цифровой код (4–64 цифры) и запоминающийся пароль (2–12 случайных английских слов с отдельными переключателями цифр и специальных знаков; цифры и знаки случайно размещаются перед словами, между ними или в конце, сохраняя слова целыми). Длина меняется ползунком и отдельно запоминается для каждого режима до закрытия окна. Для слов показывается также итоговое число символов. **Другой вариант** обновляет результат, **Использовать пароль** подставляет его в поле, **Отмена** сохраняет старое значение. Самостоятельный генератор из бокового меню использует те же настройки и явную кнопку копирования с прежней очисткой буфера. Список из 256 слов общий с Android; все режимы используют криптографическую случайность и работают без сети.
|
||||
|
||||
## Совместимость и данные
|
||||
|
||||
Формат `QSAFE01!` полностью повторяет Android: big-endian число итераций, 16 байт соли, 12 байт nonce, 40-байтный аутентифицируемый заголовок, AES-256-GCM с 16-байтным тегом. PBKDF2-HMAC-SHA256 использует 600 000 итераций. Межплатформенная проверка запускает фактический `app/src/main/java/ru/kusoft/qsafe/VaultCrypto.java` и обменивается файлами в обе стороны с Unicode-паролем.
|
||||
|
||||
Дополнительные JSON-поля сохраняются на уровне базы, карточек и полей. Вложения и XML-источник импорта остаются внутри зашифрованной базы. Секреты и заметки не участвуют в поиске. Импорт XML запрещает DTD и внешние сущности; до записи пользователь получает счётчики импорта и подтверждает перенос.
|
||||
|
||||
Хранилище находится в `%LOCALAPPDATA%\Kusoft\QSafe`; атомарная запись использует временный файл с `Flush(true)` и замену файла. Перед записью создаётся зашифрованная резервная копия, хранятся последние 10. Доступ к базе одновременно ограничен одним процессом для данного каталога. `connection.enc` защищён Windows DPAPI для текущего пользователя. Пароль и мастер-ключ не записываются на диск. Управляемая память не позволяет гарантировать стирание всех копий строк.
|
||||
|
||||
База доступна только по буквальному частному IPv4 непосредственно в подсети физического домашнего Wi-Fi. Точное имя SSID проверяется через Windows WLAN API перед каждым GET/PUT и повторно при создании сокета. Сокет привязывается к IP и индексу Wi-Fi-интерфейса (IP_UNICAST_IF); прокси, DNS и перенаправления исключены. Вне дома приложение работает с локальными данными. Ручная синхронизация и восстановление не обходят ограничение.
|
||||
|
||||
HTTPS проверяет имя/IP, срок действия и точный SHA-256 локального сертификата Pi из личного файла подключения. Загрузки сертификатов и онлайн-проверки отзыва отключены для этого закреплённого локального сертификата. До замены локальной базы проверяются SHA-256, GCM-тег, JSON и идентификатор хранилища. PUT требует `If-Match` или `If-None-Match`; при HTTP 412 выполняется до трёх попыток с новым GET. Трёхстороннее объединение сохраняет конкурентные правки и конфликты удаления.
|
||||
|
||||
Pi: `https://192.168.0.185:18443/qsafe/`, домашний SSID `gen`. Локальный файл `.private/connection-lan.qsafe` содержит также ключ доступа и не входит в поставку. Старые файлы с внешним адресом отвергаются до сетевых запросов. Одноразовая настройка Windows без открытия базы: `QSafe.exe --connection <личный-файл> --configure-only`. Для Android импортируйте обновлённый личный файл в настройках подключения. Публичный путь базы на Аргусе закрыт; обновления APK через Аргус сохранены.
|
||||
|
||||
## Сборка
|
||||
|
||||
Нужны Windows и .NET SDK 10. Java 17 необязательна для сборки, нужна для межплатформенной проверки.
|
||||
|
||||
```powershell
|
||||
.\windows\build.ps1
|
||||
```
|
||||
|
||||
Скрипт запускает проверки, Java interoperability при доступном JDK, публикует self-contained EXE, выполняет проверочный запуск с автоматическим выходом и формирует ZIP и SHA-256. Для быстрой пересборки без тестов предусмотрен `-SkipTests`.
|
||||
|
||||
```powershell
|
||||
dotnet run --project windows/QSafe.Tests -c Release -- artifacts/windows-qa
|
||||
dotnet run --project windows/QSafe.Desktop -- --demo
|
||||
```
|
||||
|
||||
Тесты создают только синтетические данные в `%TEMP%\QSafe-Tests` и `%TEMP%\QSafe-Demo`. WPF-рендеры создаются внутри тестового процесса без ввода в Windows и без открытия рабочего хранилища. `--smoke-test` запускает отдельную временную демонстрацию и автоматически закрывает приложение через 2 секунды.
|
||||
|
||||
## Ограничения первой версии
|
||||
|
||||
Автосинхронизация работает только при открытом разблокированном приложении раз в минуту и только внутри домашнего Wi-Fi. При неизвестном SSID, отсутствии разрешения Windows на его чтение или другой сети приложение остаётся автономным. Вложения сохраняются, но не просматриваются. Нет Windows Hello, TOTP, смены мастер-пароля, интеграции с браузерами и автообновления Windows. Снимки обычных окон исключаются из поддерживаемого системного захвата через `SetWindowDisplayAffinity`; это не защита от всех способов захвата. Очистка буфера работает, пока процесс запущен, и не затирает новое содержимое другого приложения. EXE не подписан. Независимого аудита не было. На рабочем Pi проверено только чтение зашифрованной базы по локальному TLS; мастер-пароль и открытые данные пользователя не использовались.
|
||||
|
||||
API: [AES-GCM .NET](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.aesgcm.encrypt), [Windows DPAPI](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata).
|
||||
@@ -0,0 +1,42 @@
|
||||
param([switch]$SkipTests)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoPath = Split-Path -Parent $PSScriptRoot
|
||||
$qaPath = Join-Path $repoPath 'artifacts/windows-qa'
|
||||
$releaseVersion = ([xml](Get-Content -Raw (Join-Path $PSScriptRoot 'Directory.Build.props'))).Project.PropertyGroup.Version
|
||||
$outputPath = Join-Path $repoPath "artifacts/QSafe-Windows-$releaseVersion"
|
||||
$buildPath = Join-Path $repoPath 'artifacts/windows-lan-build'
|
||||
New-Item -ItemType Directory -Force $qaPath | Out-Null
|
||||
Push-Location $repoPath
|
||||
try {
|
||||
if (-not $SkipTests) {
|
||||
if ((Get-Command javac -ErrorAction SilentlyContinue) -and (Get-Command java -ErrorAction SilentlyContinue)) {
|
||||
New-Item -ItemType Directory -Force (Join-Path $qaPath 'java') | Out-Null
|
||||
& javac -encoding UTF-8 -d "$qaPath/java" app/src/main/java/ru/kusoft/qsafe/VaultCrypto.java windows/QSafe.Tests/InteropFixture.java
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Java fixture compilation failed' }
|
||||
& java -cp "$qaPath/java" InteropFixture write $qaPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Java fixture failed' }
|
||||
}
|
||||
& dotnet restore windows/QSafe.Tests -p:UseArtifactsOutput=true "-p:ArtifactsPath=$buildPath" --source "$repoPath/.gradle" --nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Local dependency restore failed' }
|
||||
& dotnet run --project windows/QSafe.Tests -c Release --no-restore -p:UseArtifactsOutput=true "-p:ArtifactsPath=$buildPath" -- $qaPath | Tee-Object -FilePath "$qaPath/tests.log"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows tests failed' }
|
||||
if ((Get-Command java -ErrorAction SilentlyContinue) -and (Test-Path "$qaPath/java/InteropFixture.class")) {
|
||||
& java -cp "$qaPath/java" InteropFixture verify $qaPath | Tee-Object -FilePath "$qaPath/java-interop.log"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Android interop failed' }
|
||||
}
|
||||
}
|
||||
& dotnet restore windows/QSafe.Desktop -r win-x64 -p:UseArtifactsOutput=true "-p:ArtifactsPath=$buildPath" --source "$repoPath/.gradle" --nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Local runtime restore failed' }
|
||||
& dotnet publish windows/QSafe.Desktop/QSafe.Desktop.csproj -c Release -r win-x64 --self-contained true --no-restore -p:UseArtifactsOutput=true "-p:ArtifactsPath=$buildPath" -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -o $outputPath --nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Publish failed' }
|
||||
Copy-Item -LiteralPath windows/QUICKSTART.txt -Destination "$outputPath/ПРОЧИТАЙТЕ.txt"
|
||||
$smokeProcess = Start-Process -FilePath "$outputPath/QSafe.exe" -ArgumentList '--smoke-test' -WindowStyle Hidden -PassThru
|
||||
if (-not $smokeProcess.WaitForExit(15000)) { $smokeProcess.Kill(); throw 'Startup smoke test timed out' }
|
||||
if ($smokeProcess.ExitCode -ne 0) { throw "Startup smoke test failed: $($smokeProcess.ExitCode)" }
|
||||
'PASS published self-contained executable starts and exits cleanly with synthetic demo' | Set-Content -LiteralPath "$qaPath/startup.log"
|
||||
$zipPath = Join-Path $repoPath "artifacts/QSafe-Windows-$releaseVersion.zip"
|
||||
Compress-Archive -Path "$outputPath/*" -DestinationPath $zipPath -Force
|
||||
Get-FileHash -LiteralPath "$outputPath/QSafe.exe", $zipPath -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLowerInvariant()) $([System.IO.Path]::GetFileName($_.Path))" } | Set-Content -LiteralPath (Join-Path $repoPath "artifacts/QSafe-Windows-$releaseVersion.sha256")
|
||||
Write-Output "Ready: $outputPath/QSafe.exe"
|
||||
}
|
||||
finally { Pop-Location }
|
||||
Reference in New Issue
Block a user