310 lines
28 KiB
C#
310 lines
28 KiB
C#
using System.IO;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using QSafe.Core;
|
|
using QSafe.Desktop;
|
|
|
|
internal static class Program
|
|
{
|
|
private static int passed;
|
|
private static void Assert(bool condition, string message = "Assertion failed") { if (!condition) throw new Exception(message); }
|
|
private static void Reject<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception("Expected " + typeof(T).Name); }
|
|
private static async Task RejectAsync<T>(Func<Task> action) where T : Exception { try { await action(); } catch (T) { return; } throw new Exception("Expected " + typeof(T).Name); }
|
|
private static void Test(string title, Action action) { action(); passed++; Console.WriteLine("PASS " + title); }
|
|
private static async Task TestAsync(string title, Func<Task> action) { await action(); passed++; Console.WriteLine("PASS " + title); }
|
|
|
|
[STAThread]
|
|
private static int Main(string[] args)
|
|
{
|
|
try
|
|
{
|
|
if (args.FirstOrDefault() == "--lan-probe")
|
|
{
|
|
var config = Connection.Parse(File.ReadAllBytes(args[1]));
|
|
var binding = HomeWifiNetwork.Instance.Current(config); LanPolicy.Check(config, binding);
|
|
Console.WriteLine($"PASS home Wi-Fi confirmed: {binding.Ssid}, {binding.Address}/{binding.PrefixLength}, interface {binding.InterfaceIndex}");
|
|
using var client = new SyncClient(config, HomeWifiNetwork.Instance);
|
|
var encrypted = client.Download().GetAwaiter().GetResult(); VaultCrypto.Validate(encrypted);
|
|
Console.WriteLine($"PASS pinned LAN TLS and authenticated encrypted vault download ({encrypted.Length} bytes); no plaintext read, no writes");
|
|
return 0;
|
|
}
|
|
var artifact = Path.GetFullPath(args.FirstOrDefault() ?? "artifacts/windows-qa"); Directory.CreateDirectory(artifact);
|
|
CoreTests(artifact).GetAwaiter().GetResult(); DesktopTests(artifact);
|
|
Console.WriteLine($"RESULT {passed} checks passed"); return 0;
|
|
}
|
|
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
|
|
}
|
|
private static async Task CoreTests(string artifact)
|
|
{
|
|
using var key = VaultCrypto.Create("Long synthetic test password 🔐");
|
|
var original = new VaultData(); var card = Templates.Create("login"); card.Title = "Test"; card.Fields[1].Value = "Secret"; original.Entries.Add(card);
|
|
var blob = Vault.Encrypt(original, key);
|
|
Test("AES-GCM roundtrip and random nonce", () => { Assert(Vault.Read(blob, key).Entries[0].Fields[1].Value == "Secret"); Assert(!blob.SequenceEqual(Vault.Encrypt(original, key))); });
|
|
Test("Wrong password and tampering rejected", () => { Reject<CryptographicException>(() => VaultCrypto.Unlock(blob, "wrong")); var corrupt = blob.ToArray(); corrupt[^1] ^= 1; Reject<CryptographicException>(() => Vault.Read(corrupt, key)); });
|
|
Test("Envelope bounds and iteration ceiling checked before KDF", () => { Reject<InvalidDataException>(() => VaultCrypto.Validate(new byte[8])); var corrupt = blob.ToArray(); Array.Fill(corrupt, (byte)127, 8, 4); Reject<InvalidDataException>(() => VaultCrypto.Unlock(corrupt, "wrong")); });
|
|
Test("Key disposal clears bytes", () => { var temporary = VaultCrypto.Create("test password"); temporary.Dispose(); Assert(temporary.Bytes.All(b => b == 0)); });
|
|
Test("Password generator character groups, options and bounds", () =>
|
|
{
|
|
for (int i = 0; i < 150; i++) { var p = VaultCrypto.Generate(32); Assert(p.Length == 32 && p.Any(char.IsUpper) && p.Any(char.IsLower) && p.Any(char.IsDigit) && p.Any(c => !char.IsLetterOrDigit(c))); Assert(VaultCrypto.Generate(16, false).All(char.IsLetterOrDigit)); }
|
|
Reject<ArgumentOutOfRangeException>(() => VaultCrypto.Generate(8));
|
|
});
|
|
Test("Password modes enforce lengths, digits, words and symbol choice", () =>
|
|
{
|
|
foreach (int size in new[] { 4, 12, 64, 128 })
|
|
for (int i = 0; i < 50; i++) { var p = PasswordGenerator.Generate(PasswordMode.Digits, size); Assert(p.Length == size && p.All(c => c is >= '0' and <= '9')); }
|
|
foreach (int size in new[] { 2, 3, 8, 12 })
|
|
foreach (bool symbols in new[] { true, false }) foreach (bool digits in new[] { true, false })
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
var p = PasswordGenerator.Generate(PasswordMode.Memorable, size, symbols, digits);
|
|
Assert(Regex.Matches(p, "[A-Z]?[a-z]+").Count == size && p.Any(char.IsDigit) == digits && p.Any(c => !char.IsLetterOrDigit(c)) == symbols);
|
|
Assert(WholeWords(p, size), "Words must stay whole");
|
|
if (digits) Assert(p.Count(char.IsDigit) == 2);
|
|
}
|
|
Assert(PasswordGenerator.Generate(PasswordMode.Complex, 16, false).All(char.IsLetterOrDigit));
|
|
foreach (var (mode, size) in new[] { (PasswordMode.Complex, 15), (PasswordMode.Complex, 129), (PasswordMode.Digits, 3), (PasswordMode.Digits, 129), (PasswordMode.Memorable, 1), (PasswordMode.Memorable, 13) })
|
|
Reject<ArgumentOutOfRangeException>(() => PasswordGenerator.Generate(mode, size));
|
|
});
|
|
Test("Memorable digits and signs reach every word boundary in either order", () =>
|
|
{
|
|
var digitPositions = new bool[3]; var signPositions = new bool[3];
|
|
bool signBeforeDigit = false, signAfterDigit = false;
|
|
for (int i = 0; i < 1000; i++)
|
|
{
|
|
var p = PasswordGenerator.Generate(PasswordMode.Memorable, 2);
|
|
var words = Regex.Matches(p, "[A-Z][a-z]+"); Assert(words.Count == 2 && WholeWords(p, 2));
|
|
string[] boundaries = [p[..words[0].Index], p[(words[0].Index + words[0].Length)..words[1].Index], p[(words[1].Index + words[1].Length)..]];
|
|
for (int slot = 0; slot < 3; slot++)
|
|
{
|
|
digitPositions[slot] |= boundaries[slot].Any(char.IsDigit);
|
|
signPositions[slot] |= boundaries[slot].Any(c => !char.IsLetterOrDigit(c));
|
|
}
|
|
int sign = p.IndexOfAny("!@#$%&*+-=?".ToCharArray());
|
|
for (int j = 0; j < p.Length; j++) if (char.IsDigit(p[j])) { signBeforeDigit |= sign < j; signAfterDigit |= sign > j; }
|
|
}
|
|
Assert(digitPositions.All(p => p) && signPositions.All(p => p) && signBeforeDigit && signAfterDigit);
|
|
});
|
|
Test("Malformed vaults and duplicate entry IDs rejected", () =>
|
|
{
|
|
Reject<InvalidDataException>(() => Vault.Parse("{}"u8.ToArray())); var d = Vault.Copy(original); d.Entries.Add(Vault.Copy(card)); Reject<InvalidDataException>(() => Vault.Parse(Vault.Bytes(d)));
|
|
Reject<InvalidDataException>(() => Vault.Parse("{\"format\":1,\"id\":\"x\",\"entries\":[{\"fields\":[]}]}"u8.ToArray()));
|
|
Reject<InvalidDataException>(() => Vault.Parse("{\"format\":1,\"id\":\"x\",\"entries\":[null]}"u8.ToArray()));
|
|
});
|
|
Test("All eight templates use compatible secret types", () => { foreach (string kind in Templates.Kinds) { var e = Templates.Create(kind); Assert(e.Category == kind); Assert(e.Fields.All(f => f.Type != "password" || f.Secret)); } Assert(new Field { Type = "unknown" }.Secret); });
|
|
var local = Vault.Copy(original); var remote = Vault.Copy(original);
|
|
Test("Independent edits merge", () => { var extra = Templates.Create("note"); Vault.Update(local, extra); var changed = Vault.Copy(card); changed.Notes = "remote"; Vault.Update(remote, changed); var m = Vault.Merge(original, local, remote); Assert(m.Conflicts == 0 && m.Data.Entries.Count == 2 && m.Data.Entries.Single(e => e.Id == card.Id).Notes == "remote"); });
|
|
Test("Concurrent edits preserve both revisions", () => { var a = Vault.Copy(original); var b = Vault.Copy(original); var ea = Vault.Copy(card); ea.Notes = "A"; Vault.Update(a, ea); var eb = Vault.Copy(card); eb.Notes = "B"; Vault.Update(b, eb); var m = Vault.Merge(original, a, b); Assert(m.Conflicts == 1 && m.Data.Entries.Count == 2 && m.Data.Entries.Select(e => e.Notes).Order().SequenceEqual(new[] { "A", "B" })); });
|
|
Test("Delete/edit conflict retains edited card", () => { var a = Vault.Copy(original); var b = Vault.Copy(original); var ea = Vault.Copy(card); ea.Deleted = true; Vault.Update(a, ea); var eb = Vault.Copy(card); eb.Notes = "keep"; Vault.Update(b, eb); var m = Vault.Merge(original, a, b); Assert(m.Conflicts == 1 && m.Data.Entries.Single().Notes == "keep" && !m.Data.Entries.Single().Deleted); });
|
|
Test("Different vaults cannot merge", () => Reject<InvalidDataException>(() => Vault.Merge(null, original, new VaultData())));
|
|
Test("XML import retains all metadata and deduplicates", () =>
|
|
{
|
|
var xml = Encoding.UTF8.GetBytes("<database><label id='2' name='Работа'/><card title='Mail' star='true'><field name='Логин' type='login'>user</field><field name='Пароль' type='password'>a&b</field><notes>note</notes><label_id>2</label_id><file name='a.txt'>YWJj</file><history><x/></history></card><card deleted='true'/><card template='true'/></database>");
|
|
var p = XmlImport.Preview(xml, new VaultData()); Assert(p.Added == 1 && p.Skipped == 2 && p.Attachments == 1 && p.Data.Entries[0].Fields[1].Value == "a&b" && p.Data.Entries[0].Tags.Single() == "Работа");
|
|
Assert(XmlImport.Preview(xml, p.Data).Duplicates == 1);
|
|
p.Data.Entries[0].Extra!.Remove("safeInCloudFingerprint"); Assert(XmlImport.Preview(xml, p.Data).Duplicates == 1);
|
|
var round = Vault.Read(Vault.Encrypt(p.Data, key), key); Assert(round.Entries[0].Extra!["safeInCloudXml"].GetString()!.Contains("<history>"));
|
|
});
|
|
Test("XML rejects XXE, DTD and foreign roots", () => { Reject<System.Xml.XmlException>(() => XmlImport.Preview(Encoding.UTF8.GetBytes("<!DOCTYPE database [<!ENTITY x SYSTEM 'file:///C:/Windows/win.ini'>]><database><card><notes>&x;</notes></card></database>"), original)); Reject<InvalidDataException>(() => XmlImport.Preview("<other/>"u8.ToArray(), original)); });
|
|
Test("Connection rejects HTTP, userinfo, fragments and malformed tokens", () =>
|
|
{
|
|
foreach (var url in new[] { "http://example.test/", "https://user@example.test/", "https://example.test/?x=1", "https://example.test/#fragment" }) Reject<InvalidDataException>(() => new Connection { Url = url, Token = new string('a', 43) }.Validate());
|
|
Reject<InvalidDataException>(() => Connection.Parse("{\"url\":null,\"token\":null}"u8.ToArray()));
|
|
});
|
|
var config = TestConnection();
|
|
var home = new FakeHomeNetwork();
|
|
var handler = new FakeServer(); using var client = new SyncClient(config, home, handler);
|
|
await TestAsync("Vault transport is never reached outside home Wi-Fi or local subnet", async () =>
|
|
{
|
|
foreach (var binding in new[] { new LanBinding("Other Wi-Fi", true, IPAddress.Parse("192.168.0.61"), 24, 3), new LanBinding("gen", false, IPAddress.Parse("192.168.0.61"), 24, 3), new LanBinding("gen", true, IPAddress.Parse("192.168.1.61"), 24, 3) })
|
|
{
|
|
home.Binding = binding; int before = handler.Requests; await RejectAsync<IOException>(() => client.Download()); Assert(handler.Requests == before);
|
|
}
|
|
home.Binding = FakeHomeNetwork.Home;
|
|
Assert(!LanPolicy.PrivateIpv4(IPAddress.Parse("8.8.8.8")) && !LanPolicy.PrivateIpv4(IPAddress.Parse("127.0.0.1")) && !LanPolicy.PrivateIpv4(IPAddress.Parse("169.254.1.1")) && !LanPolicy.PrivateIpv4(IPAddress.IPv6Loopback));
|
|
});
|
|
Test("Public vault URLs and hostnames rejected without DNS or transport", () =>
|
|
{
|
|
foreach (var url in new[] { "https://argus.kusoft.xyz/qsafe/", "https://8.8.8.8/", "https://localhost/", "https://192.168.0.185.evil.test/", "http://192.168.0.185/", "https://192.168.0.185/?x=1" })
|
|
{ var c = TestConnection(); c.Url = url; Reject<InvalidDataException>(c.Validate); }
|
|
var missing = TestConnection(); missing.HomeWifiSsid = ""; Reject<InvalidDataException>(missing.Validate);
|
|
missing = TestConnection(); missing.CertificateSha256 = ""; Reject<InvalidDataException>(missing.Validate);
|
|
});
|
|
await TestAsync("Sync creates remote using If-None-Match and ciphertext only", async () => { var result = await client.Sync(blob, null, key); Assert(handler.Blob!.SequenceEqual(blob) && handler.LastCondition == "*" && result.Data.Id == original.Id); });
|
|
config.Etag = VaultCrypto.Hash(blob);
|
|
var changedLocal = Vault.Copy(original); var edit = Vault.Copy(card); edit.Notes = "local change"; Vault.Update(changedLocal, edit); var changedBlob = Vault.Encrypt(changedLocal, key);
|
|
await TestAsync("Sync upload uses If-Match and confirmed SHA-256", async () => { await client.Sync(changedBlob, blob, key); Assert(handler.Blob!.SequenceEqual(changedBlob) && handler.LastCondition == '"' + VaultCrypto.Hash(blob) + '"'); });
|
|
config.Etag = VaultCrypto.Hash(changedBlob);
|
|
await TestAsync("Sync remote-only change downloaded without upload", async () => { handler.Blob = blob; var puts = handler.Puts; var r = await client.Sync(changedBlob, changedBlob, key); Assert(r.Envelope.SequenceEqual(blob) && handler.Puts == puts); });
|
|
await TestAsync("Sync concurrent changes preserve both entries", async () =>
|
|
{
|
|
var r = Vault.Copy(original); var e = Vault.Copy(card); e.Notes = "remote change"; Vault.Update(r, e); handler.Blob = Vault.Encrypt(r, key); config.Etag = VaultCrypto.Hash(blob);
|
|
var merged = await client.Sync(changedBlob, blob, key); Assert(merged.Conflicts == 1 && merged.Data.Entries.Count == 2);
|
|
});
|
|
await TestAsync("Sync detects disappeared remote and never recreates it", async () => { handler.Blob = null; var puts = handler.Puts; await RejectAsync<IOException>(() => client.Sync(blob, blob, key)); Assert(handler.Puts == puts); });
|
|
await TestAsync("Sync rejects corrupted remote without uploading", async () => { handler.Blob = blob.ToArray(); handler.Blob[^1] ^= 1; var puts = handler.Puts; await RejectAsync<CryptographicException>(() => client.Sync(changedBlob, blob, key)); Assert(handler.Puts == puts); });
|
|
await TestAsync("Sync validates response checksum", async () => { handler.Blob = blob; handler.BadHash = true; await RejectAsync<InvalidDataException>(() => client.Sync(blob, null, key)); handler.BadHash = false; });
|
|
await TestAsync("Sync retries conditional-write conflicts with fresh GET", async () => { handler.Blob = blob; config.Etag = VaultCrypto.Hash(blob); handler.ConflictOnce = true; var r = await client.Sync(changedBlob, blob, key); Assert(r.Envelope.SequenceEqual(changedBlob) && !handler.ConflictOnce); });
|
|
await TestAsync("Leaving home between GET and PUT blocks upload", async () =>
|
|
{
|
|
handler.Blob = blob; config.Etag = VaultCrypto.Hash(blob); int before = handler.Requests, puts = handler.Puts;
|
|
handler.AfterGet = () => home.Binding = FakeHomeNetwork.Home with { Ssid = "Away" };
|
|
try { await RejectAsync<IOException>(() => client.Sync(changedBlob, blob, key)); Assert(handler.Requests == before + 1 && handler.Puts == puts); }
|
|
finally { handler.AfterGet = null; home.Binding = FakeHomeNetwork.Home; }
|
|
});
|
|
Test("LAN HTTP handler disables proxy, redirects and certificate downloads", () =>
|
|
{
|
|
var method = typeof(SyncClient).GetMethod("CreateHandler", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
using var transport = (SocketsHttpHandler)method.Invoke(client, null)!;
|
|
Assert(!transport.UseProxy && !transport.AllowAutoRedirect && transport.SslOptions.CertificateChainPolicy!.DisableCertificateDownloads && transport.SslOptions.CertificateRevocationCheckMode == System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck && transport.ConnectCallback != null);
|
|
});
|
|
await TestAsync("Bounded reads reject oversized streams", async () => { using var stream = new MemoryStream(new byte[20]); await RejectAsync<InvalidDataException>(() => SyncClient.Bounded(stream, 10)); });
|
|
if (File.Exists(Path.Combine(artifact, "java.qsafe")))
|
|
{
|
|
Test("Actual Android Java envelope opens in Windows", () =>
|
|
{
|
|
var javaBlob = File.ReadAllBytes(Path.Combine(artifact, "java.qsafe")); using var javaKey = VaultCrypto.Unlock(javaBlob, "Публичный тест 🔐 QSafe 2026"); var d = Vault.Read(javaBlob, javaKey);
|
|
Assert(d.Entries.Single().Title == "Привет из Android 🔐" && d.Extra!["futureRoot"].GetProperty("a").GetInt32() == 1 && d.Entries.Single().Fields.Single().Extra!["futureField"].GetBoolean());
|
|
d.Entries[0].Rev = "dotnet-revision"; File.WriteAllBytes(Path.Combine(artifact, "dotnet.qsafe"), Vault.Encrypt(d, javaKey));
|
|
});
|
|
}
|
|
}
|
|
|
|
private static void DesktopTests(string artifact)
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), "QSafe-Tests", Guid.NewGuid().ToString("N")); var store = new LocalStore(path);
|
|
Test("Windows DPAPI seals credentials for current user", () => { var secret = Encoding.UTF8.GetBytes("synthetic test token"); var sealedBytes = DeviceProtection.Protect(secret); Assert(!sealedBytes.SequenceEqual(secret)); Assert(DeviceProtection.Unprotect(sealedBytes).SequenceEqual(secret)); });
|
|
Test("Atomic persistence keeps encrypted history and reloads config", () =>
|
|
{
|
|
using var key = VaultCrypto.Create("Test password for desktop"); var d = new VaultData(); for (int i = 0; i < 14; i++) { d.Entries.Add(new Entry { Title = "card " + i }); store.Save(d, key); }
|
|
Assert(Directory.GetFiles(Path.Combine(path, "backups"), "*.qsafe").Length == 10); Assert(Vault.Read(store.Read(), key).Entries.Count == 14);
|
|
var config = TestConnection(); config.Token = new string('b', 43); store.Configure(config); store.MarkSynced(store.Read()); var loaded = new LocalStore(path); Assert(loaded.Config.Etag == VaultCrypto.Hash(store.Read()) && loaded.Connected); Assert(!Encoding.UTF8.GetString(File.ReadAllBytes(Path.Combine(path, "connection.enc"))).Contains(new string('b', 43)));
|
|
});
|
|
Test("Changing server resets ancestor and ETag", () => { var config = TestConnection(); config.Url = "https://192.168.0.186:18443/qsafe/"; store.Configure(config); Assert(store.ReadBase() == null && store.Config.Etag == ""); });
|
|
Test("WPF resources and synthetic demo render at desktop and minimum size", () =>
|
|
{
|
|
var app = new App(); app.InitializeComponent();
|
|
var demoStore = new LocalStore(Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")));
|
|
var main = new MainWindow(demoStore, true); app.MainWindow = main;
|
|
var search = (TextBox)main.FindName("Search"); var cards = (ListBox)main.FindName("Cards");
|
|
Test("Search finds non-secret fields and excludes passwords", () =>
|
|
{
|
|
search.Text = "GITHUB.COM"; Assert(cards.Items.Count == 1);
|
|
search.Text = "Demo-Only-Password"; Assert(cards.Items.Count == 0);
|
|
search.Clear(); search.Text = "GitHub"; search.Clear();
|
|
});
|
|
Test("Background operations do not reset the inactivity timer", () =>
|
|
{
|
|
var field = typeof(MainWindow).GetField("lastInput", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
var old = DateTime.UtcNow.AddSeconds(-80); field.SetValue(main, old);
|
|
var work = typeof(MainWindow).GetMethod("Work", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
((Task)work.Invoke(main, ["Test", (Func<Task>)(() => Task.CompletedTask)])!).GetAwaiter().GetResult(); Assert((DateTime)field.GetValue(main)! == old);
|
|
});
|
|
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-desktop.png"), 1360, 810);
|
|
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-desktop-compact.png"), 1080, 660);
|
|
var label = ((StackPanel)main.FindName("Details")).Children.OfType<TextBlock>().First().Text; Assert(label.Contains("САЙТЫ"));
|
|
// Calls the application's lock logic in-process; no OS input or user vault is involved.
|
|
search.Text = "GitHub";
|
|
typeof(MainWindow).GetMethod("Lock", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(main, null);
|
|
Assert(cards.Items.Count == 0 && ((StackPanel)main.FindName("Details")).Children.Count == 0);
|
|
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-unlock.png"), 1360, 810);
|
|
Test("Generator preview, mode lengths, cancel and explicit use", () =>
|
|
{
|
|
main.Show(); main.Hide();
|
|
string? used = null, expected = null; Exception? failure = null;
|
|
var generator = new PasswordGeneratorDialog(main, value => used = value);
|
|
var preview = Descendants<TextBox>(generator.Body).Single();
|
|
var slider = Descendants<Slider>(generator.Body).Single();
|
|
var modes = Descendants<RadioButton>(generator.Body).ToArray();
|
|
generator.Loaded += (_, _) =>
|
|
{
|
|
try
|
|
{
|
|
Assert(preview.Text.Length == 24 && used == null);
|
|
slider.Value = 32; Assert(preview.Text.Length == 32);
|
|
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-complex.png"), 620, 680);
|
|
modes[1].IsChecked = true; slider.Value = 4; Assert(preview.Text.Length == 4 && preview.Text.All(char.IsDigit));
|
|
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-digits.png"), 620, 680);
|
|
modes[2].IsChecked = true; slider.Value = 12; Assert(Regex.Matches(preview.Text, "[A-Z]?[a-z]+").Count == 12);
|
|
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-memorable.png"), 620, 680);
|
|
Assert(slider.Minimum == 2); slider.Value = 2;
|
|
var symbols = Descendants<CheckBox>(generator.Body).Single(c => ((string)c.Content).StartsWith("Специальные"));
|
|
var digits = Descendants<CheckBox>(generator.Body).Single(c => ((string)c.Content).StartsWith("Добавлять цифры"));
|
|
Assert(symbols.Visibility == Visibility.Visible && digits.Visibility == Visibility.Visible);
|
|
Assert(WholeWords(preview.Text, 2) && preview.Text.Count(char.IsDigit) == 2 && preview.Text.Count(c => !char.IsLetterOrDigit(c)) == 1);
|
|
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-two-words.png"), 620, 680);
|
|
digits.IsChecked = false; Assert(!preview.Text.Any(char.IsDigit));
|
|
symbols.IsChecked = false; Assert(Regex.IsMatch(preview.Text, @"^[A-Z][a-z]+[A-Z][a-z]+$"));
|
|
digits.IsChecked = true; symbols.IsChecked = true;
|
|
modes[0].IsChecked = true; Assert(slider.Minimum == 16 && slider.Value == 32 && preview.Text.Length == 32);
|
|
modes[1].IsChecked = true; Assert(slider.Minimum == 4 && slider.Value == 4);
|
|
modes[2].IsChecked = true; Assert(slider.Value == 2);
|
|
expected = preview.Text; Assert(used == null);
|
|
generator.Actions.Children.OfType<Button>().Single(b => (string)b.Content == "Использовать пароль").RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
|
|
}
|
|
catch (Exception ex) { failure = ex; generator.Close(); }
|
|
};
|
|
Assert(generator.ShowDialog() == true); if (failure != null) throw failure;
|
|
Assert(used == expected && preview.Text == "");
|
|
used = null; var canceled = new PasswordGeneratorDialog(main, value => used = value);
|
|
canceled.Loaded += (_, _) => canceled.Close(); canceled.ShowDialog(); Assert(used == null);
|
|
});
|
|
main.Close();
|
|
});
|
|
}
|
|
private static void Render(FrameworkElement visual, string path, int width, int height)
|
|
{
|
|
visual.Measure(new Size(width, height)); visual.Arrange(new Rect(0, 0, width, height)); visual.UpdateLayout();
|
|
var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); target.Render(visual);
|
|
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(target)); using var stream = File.Create(path); encoder.Save(stream);
|
|
}
|
|
|
|
private static IEnumerable<T> Descendants<T>(DependencyObject parent) where T : DependencyObject
|
|
{
|
|
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
|
{
|
|
var child = VisualTreeHelper.GetChild(parent, i);
|
|
if (child is T match) yield return match;
|
|
foreach (var item in Descendants<T>(child)) yield return item;
|
|
}
|
|
}
|
|
private static bool WholeWords(string value, int count)
|
|
{
|
|
const string gap = @"[0-9!@#$%&*+\-=?]*";
|
|
return Regex.IsMatch(value, "^" + gap + "[A-Z][a-z]+(?:" + gap + "[A-Z][a-z]+){" + (count - 1) + "}" + gap + "$");
|
|
}
|
|
|
|
private sealed class FakeServer : HttpMessageHandler
|
|
{
|
|
public byte[]? Blob; public int Puts, Requests; public string LastCondition = ""; public bool BadHash, ConflictOnce; public Action? AfterGet;
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
Requests++; Assert(request.RequestUri!.AbsoluteUri == "https://192.168.0.185:18443/qsafe/v1/vault"); Assert(request.Headers.Authorization?.Scheme == "Bearer");
|
|
if (request.Method == HttpMethod.Get)
|
|
{
|
|
if (Blob == null) return new(HttpStatusCode.NotFound);
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Blob) }; response.Headers.ETag = new EntityTagHeaderValue('"' + (BadHash ? new string('0', 64) : VaultCrypto.Hash(Blob)) + '"'); AfterGet?.Invoke(); return response;
|
|
}
|
|
Puts++; LastCondition = request.Headers.Contains("If-Match") ? request.Headers.GetValues("If-Match").Single() : request.Headers.GetValues("If-None-Match").Single();
|
|
if (ConflictOnce) { ConflictOnce = false; return new(HttpStatusCode.PreconditionFailed); }
|
|
if ((Blob == null && LastCondition != "*") || (Blob != null && LastCondition != '"' + VaultCrypto.Hash(Blob) + '"')) return new(HttpStatusCode.PreconditionFailed);
|
|
Blob = await request.Content!.ReadAsByteArrayAsync(ct); var result = new HttpResponseMessage(HttpStatusCode.OK); result.Headers.ETag = new EntityTagHeaderValue('"' + VaultCrypto.Hash(Blob) + '"'); return result;
|
|
}
|
|
}
|
|
private static Connection TestConnection() => new() { Url = "https://192.168.0.185:18443/qsafe/", Token = new string('a', 43), HomeWifiSsid = "gen", CertificateSha256 = new string('a', 64) };
|
|
private sealed class FakeHomeNetwork : ILanNetwork
|
|
{
|
|
public static readonly LanBinding Home = new("gen", true, IPAddress.Parse("192.168.0.61"), 24, 3);
|
|
public LanBinding Binding = Home;
|
|
public LanBinding Current(Connection connection) => Binding;
|
|
}
|
|
}
|