Initial commit: QSafe Android 1.1.0 and Windows 1.0.4

This commit is contained in:
Курнат Андрей
2026-11-01 15:28:12 +03:00
commit 9845944bb8
91 changed files with 6496 additions and 0 deletions
+11
View File
@@ -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>
+49
View File
@@ -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));
}
}
+52
View File
@@ -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));
}
}
}
+6
View File
@@ -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>
+154
View File
@@ -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("База на сервере меняется. Повторите синхронизацию.");
+155
View File
@@ -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;
}
}
+87
View File
@@ -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);
}
}
+51
View File
@@ -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; }
}
}
+83
View File
@@ -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>
+68
View File
@@ -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

+225
View File
@@ -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;
});
}
}
+49
View File
@@ -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.");
}
+116
View File
@@ -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); }
}
}
}
+61
View File
@@ -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="&#xE72E;" 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="&#xE721;" 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>
+400
View File
@@ -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>
+39
View File
@@ -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) };
}
+6
View File
@@ -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>
+23
View File
@@ -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");
}
}
}
}
+309
View File
@@ -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&amp;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;
}
}
+4
View File
@@ -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>
+38
View File
@@ -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. Мастер-пароль и открытые карточки при проверке не использовались.
+44
View File
@@ -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).
+42
View File
@@ -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 }