releases/tests/Paca.Tests/Security/SecurityVaultPipelineTests.cs

1166 lines
46 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.IO;
using System.Security.Cryptography;
using System.Text;
using Paca.App.ViewModels;
using Paca.Browser.Security;
using Paca.Core.Security;
using Xunit;
namespace Paca.Tests.Security;
/// <summary>
/// 51차: OS 생체 인증(Windows Hello, Touch ID, DPAPI) 및 고보안 자격증명 금고 기능 전수 검증.
/// </summary>
public class Milestone51WindowsHelloAndCredentialVaultTests
{
private readonly string _testDir;
public Milestone51WindowsHelloAndCredentialVaultTests()
{
_testDir = Path.Combine(Path.GetTempPath(), "paca_vault_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_testDir);
}
private void Cleanup()
{
try
{
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, true);
}
catch { }
}
#region 1. OS Biometric & Mock Authenticator Tests
[Fact]
public void WindowsHello_가용성_조회_및_플랫폼_지원_확인()
{
var auth = new WindowsHelloAuthenticator();
var availability = auth.CheckAvailability();
Assert.NotNull(auth.BiometricProviderName);
Assert.Contains("Windows Hello", auth.BiometricProviderName);
if (OperatingSystem.IsWindows())
{
Assert.True(auth.IsHardwareEnclaveSupported);
Assert.True(availability is BiometricAvailability.Available or BiometricAvailability.NotConfigured);
}
else
{
Assert.Equal(BiometricAvailability.UnsupportedPlatform, availability);
}
}
[Fact]
public async Task MockBiometricAuthenticator_성공_실패_취소_지연_시뮬레이션()
{
var mock = new MockBiometricAuthenticator
{
Availability = BiometricAvailability.Available,
ResponseStatus = BiometricAuthStatus.Success
};
var result = await mock.AuthenticateAsync("테스트 인증");
Assert.True(result.IsSuccess);
Assert.Equal(BiometricAuthStatus.Success, result.Status);
Assert.Equal(1, mock.AuthenticateCallCount);
Assert.Equal("테스트 인증", mock.LastReasonPrompt);
// 취소 시뮬레이션
mock.ResponseStatus = BiometricAuthStatus.Canceled;
var cancelResult = await mock.AuthenticateAsync("취소 테스트");
Assert.False(cancelResult.IsSuccess);
Assert.Equal(BiometricAuthStatus.Canceled, cancelResult.Status);
// 하드웨어 부재 시뮬레이션
mock.Availability = BiometricAvailability.DeviceNotPresent;
var noHwResult = await mock.AuthenticateAsync("하드웨어 부재 테스트");
Assert.False(noHwResult.IsSuccess);
Assert.Equal(BiometricAuthStatus.HardwareUnavailable, noHwResult.Status);
}
[Fact]
public async Task MockBiometricAuthenticator_연속_실패_후_성공_시뮬레이션()
{
var mock = new MockBiometricAuthenticator
{
Availability = BiometricAvailability.Available,
FailuresBeforeSuccess = 2 // 2회 실패 후 3회차에 성공
};
var res1 = await mock.AuthenticateAsync("1차 시도");
Assert.False(res1.IsSuccess);
Assert.Equal(BiometricAuthStatus.BiometricsFailed, res1.Status);
var res2 = await mock.AuthenticateAsync("2차 시도");
Assert.False(res2.IsSuccess);
Assert.Equal(BiometricAuthStatus.BiometricsFailed, res2.Status);
var res3 = await mock.AuthenticateAsync("3차 시도");
Assert.True(res3.IsSuccess);
Assert.Equal(BiometricAuthStatus.Success, res3.Status);
}
#endregion
#region 2. Password Generation & Strength Evaluation Tests
[Theory]
[InlineData(8)]
[InlineData(16)]
[InlineData(32)]
[InlineData(64)]
public void PasswordSecurityEngine_지정_길이_암호_생성_검증(int length)
{
var pass = PasswordSecurityEngine.GeneratePassword(new PasswordGeneratorOptions
{
Length = length,
IncludeUppercase = true,
IncludeLowercase = true,
IncludeDigits = true,
IncludeSymbols = true,
ExcludeAmbiguousCharacters = true
});
Assert.Equal(length, pass.Length);
Assert.Contains(pass, ch => char.IsUpper(ch));
Assert.Contains(pass, ch => char.IsLower(ch));
Assert.Contains(pass, ch => char.IsDigit(ch));
Assert.Contains(pass, ch => !char.IsLetterOrDigit(ch));
// 모호한 문자 제외 확인
Assert.DoesNotContain('0', pass);
Assert.DoesNotContain('O', pass);
Assert.DoesNotContain('1', pass);
Assert.DoesNotContain('l', pass);
Assert.DoesNotContain('I', pass);
}
[Fact]
public void PasswordSecurityEngine_Passphrase_단어_기반_기억하기_쉬운_암호_생성()
{
var pass = PasswordSecurityEngine.GeneratePassword(new PasswordGeneratorOptions
{
IsPassphraseMode = true,
PassphraseWordCount = 4,
PassphraseSeparator = "-",
IncludeUppercase = true,
IncludeDigits = true
});
var parts = pass.Split('-');
Assert.True(parts.Length >= 4);
foreach (var word in parts.Take(4))
{
Assert.True(char.IsUpper(word[0])); // 첫 글자 대문자
}
}
[Theory]
[InlineData("123456", PasswordGrade.VeryWeak, true)]
[InlineData("password", PasswordGrade.VeryWeak, true)]
[InlineData("admin", PasswordGrade.VeryWeak, true)]
[InlineData("abc123", PasswordGrade.VeryWeak, false)]
[InlineData("Abc123xyz!", PasswordGrade.Fair, false)]
[InlineData("PacaSecure#2026!Quantum", PasswordGrade.Excellent, false)]
public void PasswordSecurityEngine_암호_강도_및_패턴_분석_검증(string password, PasswordGrade expectedMinGrade, bool expectedCommon)
{
var result = PasswordSecurityEngine.EvaluateStrength(password);
Assert.True(result.Score >= 0 && result.Score <= 100);
Assert.True(result.EntropyBits >= 0);
Assert.Equal(expectedCommon, result.IsCommonPattern);
Assert.True(result.Grade >= expectedMinGrade);
}
#endregion
#region 3. 2FA TOTP RFC 6238 Tests
[Fact]
public void TotpEngine_RFC6238_표준_벡터_및_카운트다운_검증()
{
// RFC 6238 공식 Secret 테스트 벡터: "JBSWY3DPEHPK3PXP"
string secret = "JBSWY3DPEHPK3PXP";
var testTime = new DateTime(2026, 8, 31, 12, 0, 0, DateTimeKind.Utc);
string code = TotpEngine.GenerateCurrentCode(secret, timeStepSeconds: 30, digits: 6, customTime: testTime);
Assert.Equal(6, code.Length);
Assert.True(int.TryParse(code, out _));
int remaining = TotpEngine.GetRemainingSeconds(30, testTime);
Assert.True(remaining >= 1 && remaining <= 30);
// 시간 표류 허용 검증 (±1 스텝)
Assert.True(TotpEngine.ValidateCode(secret, code, allowedDriftSteps: 1, customTime: testTime));
Assert.True(TotpEngine.ValidateCode(secret, code, allowedDriftSteps: 1, customTime: testTime.AddSeconds(20)));
Assert.True(TotpEngine.ValidateCode(secret, code, allowedDriftSteps: 1, customTime: testTime.AddSeconds(-20)));
Assert.False(TotpEngine.ValidateCode(secret, "999999", allowedDriftSteps: 1, customTime: testTime));
}
#endregion
#region 4. URL & Domain Normalization and Federation Matching Tests
[Theory]
[InlineData("https://www.youtube.com/watch?v=123", "youtube.com")]
[InlineData("http://m.facebook.com:8080/login", "m.facebook.com")]
[InlineData("subdomain.example.co.kr/path/to/page", "subdomain.example.co.kr")]
[InlineData("https://accounts.google.com/signin", "accounts.google.com")]
public void SiteCredentialRecord_도메인_정규화_검증(string inputUrl, string expectedDomain)
{
var domain = SiteCredentialRecord.NormalizeDomain(inputUrl);
Assert.Equal(expectedDomain, domain);
}
[Fact]
public void SiteCredentialRecord_서브도메인_및_페더레이션_도메인_매칭_검증()
{
var record = new SiteCredentialRecord
{
Domain = "google.com",
Url = "https://google.com",
Username = "user@gmail.com"
};
// 서브도메인 매칭
Assert.True(record.MatchesDomainOrUrl("https://accounts.google.com/"));
Assert.True(record.MatchesDomainOrUrl("mail.google.com"));
// 페더레이션 매칭 (YouTube <-> Google)
Assert.True(record.MatchesDomainOrUrl("https://www.youtube.com/"));
// 무관한 도메인 불일치
Assert.False(record.MatchesDomainOrUrl("https://naver.com/"));
}
#endregion
#region 5. Security Audit Engine Tests
[Fact]
public void CredentialSecurityAuditor_취약점_재사용_오래된암호_종합검사()
{
var records = new List<SiteCredentialRecord>
{
new() { Id = "1", Domain = "siteA.com", Username = "user1", DecryptedPassword = "password123", LastModifiedAt = DateTime.UtcNow },
new() { Id = "2", Domain = "siteB.com", Username = "user2", DecryptedPassword = "password123", LastModifiedAt = DateTime.UtcNow }, // 재사용 & 취약
new() { Id = "3", Domain = "bank.com", Category = "Banking", Username = "bankuser", DecryptedPassword = "Complex#Password!2026", LastModifiedAt = DateTime.UtcNow.AddDays(-120), TotpSecret = null }, // 오래됨 & 2FA 누락
new() { Id = "4", Domain = "secure.dev", Username = "dev", DecryptedPassword = "Ultra#Strong!Quantum$2026", LastModifiedAt = DateTime.UtcNow, TotpSecret = "JBSWY3DPEHPK3PXP" }
};
var report = CredentialSecurityAuditor.PerformAudit(records, oldPasswordDays: 90);
Assert.Equal(4, report.TotalAccounts);
Assert.Equal(2, report.ReusedPasswordCount);
Assert.True(report.WeakPasswordCount >= 2);
Assert.Equal(1, report.OldPasswordCount);
Assert.Equal(1, report.Missing2FaCount);
Assert.True(report.OverallHealthScore < 100);
Assert.NotEmpty(report.Issues);
}
#endregion
#region 6. Encrypted Vault and Data Exchange Tests
[Fact]
public void CredentialDataExchange_마스터비밀번호_암호화백업_및_복원_라운드트립()
{
var originalRecords = new List<SiteCredentialRecord>
{
new() { Domain = "youtube.com", Url = "https://youtube.com", Username = "creator", DecryptedPassword = "SecretPass123!", SiteName = "YouTube", Category = "Streaming", TotpSecret = "JBSWY3DPEHPK3PXP" },
new() { Domain = "github.com", Url = "https://github.com", Username = "octocat", DecryptedPassword = "GithubPass#2026", SiteName = "GitHub", Category = "Work" }
};
string masterPassword = "MyMasterVaultPassword#2026";
byte[] backupData = CredentialDataExchange.ExportEncryptedVault(originalRecords, masterPassword);
Assert.NotNull(backupData);
Assert.True(backupData.Length > 64);
// 복원 검증
var restored = CredentialDataExchange.ImportEncryptedVault(backupData, masterPassword);
Assert.Equal(2, restored.Count);
Assert.Equal("youtube.com", restored[0].Domain);
Assert.Equal("creator", restored[0].Username);
Assert.Equal("SecretPass123!", restored[0].DecryptedPassword);
Assert.Equal("JBSWY3DPEHPK3PXP", restored[0].TotpSecret);
// 잘못된 마스터 비밀번호 복원 시 CryptographicException 발생 확인
Assert.Throws<CryptographicException>(() =>
{
CredentialDataExchange.ImportEncryptedVault(backupData, "WrongPassword");
});
}
[Fact]
public void CredentialDataExchange_CSV_내보내기_및_가져오기_라운드트립()
{
var records = new List<SiteCredentialRecord>
{
new() { SiteName = "Netflix, Korea", Domain = "netflix.com", Url = "https://netflix.com", Username = "viewer@netflix.com", DecryptedPassword = "Pass\"With\"Quotes", Notes = "Line1\nLine2", Category = "Streaming" }
};
string csv = CredentialDataExchange.ExportCsv(records);
Assert.Contains("name,url,username,password,note,totp,category", csv);
var imported = CredentialDataExchange.ImportCsv(csv);
Assert.Single(imported);
Assert.Equal("Netflix, Korea", imported[0].SiteName);
Assert.Equal("viewer@netflix.com", imported[0].Username);
Assert.Equal("Pass\"With\"Quotes", imported[0].DecryptedPassword);
}
#endregion
#region 7. OsProtectedCredentialVault Lifecycle & Persistence Tests
[Fact]
public async Task OsProtectedCredentialVault_잠금_잠금해제_CRUD_영속화_제로화_검증()
{
string vaultPath = Path.Combine(_testDir, "test_vault.pacavault");
var mockAuth = new MockBiometricAuthenticator();
try
{
using (var vault = new OsProtectedCredentialVault(vaultPath, mockAuth))
{
Assert.True(vault.IsLocked);
// 잠긴 상태에서 추가 시도 시 InvalidOperationException
Assert.Throws<InvalidOperationException>(() =>
{
vault.AddOrUpdate(new SiteCredentialRecord { Domain = "naver.com", Username = "user", DecryptedPassword = "123" });
});
// 잠금 해제
var unlock = await vault.UnlockWithBiometricAsync();
Assert.True(unlock.IsSuccess);
Assert.False(vault.IsLocked);
// 계정 추가
var rec = new SiteCredentialRecord
{
Domain = "naver.com",
Url = "https://naver.com",
Username = "paca_naver",
DecryptedPassword = "NaverPass#2026!",
SiteName = "네이버"
};
vault.AddOrUpdate(rec);
Assert.Equal(1, vault.RecordCount);
var found = vault.FindMatching("https://nid.naver.com/");
Assert.Single(found);
Assert.Equal("NaverPass#2026!", found[0].DecryptedPassword);
// 잠금 시 메모리 제로화 확인
vault.Lock();
Assert.True(vault.IsLocked);
var lockedRecs = vault.GetAll();
Assert.Single(lockedRecs);
Assert.Equal("", lockedRecs[0].DecryptedPassword); // 평문 암호 지워짐
}
// 파일에서 다시 로드 후 재잠금해제 확인
using (var vault2 = new OsProtectedCredentialVault(vaultPath, mockAuth))
{
Assert.True(vault2.IsLocked);
Assert.Equal(1, vault2.RecordCount);
await vault2.UnlockWithBiometricAsync();
Assert.False(vault2.IsLocked);
var rec = vault2.GetAll()[0];
Assert.Equal("NaverPass#2026!", rec.DecryptedPassword);
Assert.Equal("paca_naver", rec.Username);
// 삭제 검증
Assert.True(vault2.Remove(rec.Id));
Assert.Equal(0, vault2.RecordCount);
}
}
finally
{
Cleanup();
}
}
[Fact]
public async Task OsProtectedCredentialVault_연속실패_임시잠금_레이트리밋_보호()
{
string vaultPath = Path.Combine(_testDir, "lockout_vault.pacavault");
var mockAuth = new MockBiometricAuthenticator
{
ResponseStatus = BiometricAuthStatus.BiometricsFailed
};
var policy = new BiometricSecurityPolicy
{
MaxFailedAttemptsBeforeLockout = 3,
LockoutDurationSeconds = 10
};
try
{
using var vault = new OsProtectedCredentialVault(vaultPath, mockAuth, policy);
// 3회 실패
await vault.UnlockWithBiometricAsync();
await vault.UnlockWithBiometricAsync();
var res3 = await vault.UnlockWithBiometricAsync();
// 4회차 시도 시 즉시 DeviceLocked 반환 확인
var res4 = await vault.UnlockWithBiometricAsync();
Assert.False(res4.IsSuccess);
Assert.Equal(BiometricAuthStatus.DeviceLocked, res4.Status);
}
finally
{
Cleanup();
}
}
#endregion
#region 8. In-Page Autofill & Browser Capture Script Tests
[Fact]
public void CredentialAutofillEngine_자동완성_JS_및_캡처_스크립트_생성_검증()
{
string autofillScript = CredentialAutofillEngine.GenerateAutofillScript(
"paca_user", "P@ssw0rd!2026", "123456");
Assert.Contains("paca_user", autofillScript);
Assert.Contains("P@ssw0rd!2026", autofillScript);
Assert.Contains("123456", autofillScript);
Assert.Contains("input[type=\"password\"]", autofillScript);
string watcherScript = CredentialAutofillEngine.GenerateCredentialCaptureWatcherScript();
Assert.Contains("__paca_credential_watcher_installed", watcherScript);
Assert.Contains("paca_captured_credential", watcherScript);
}
[Fact]
public void CredentialAutofillEngine_웹뷰_캡처_메시지_파싱_검증()
{
string jsonMsg = """
{
"type": "paca_captured_credential",
"url": "https://www.netflix.com/login",
"username": "user@netflix.com",
"password": "SecretNetflixPass#1"
}
""";
var captured = CredentialAutofillEngine.TryParseCapturedMessage(jsonMsg);
Assert.NotNull(captured);
Assert.Equal("netflix.com", captured.Domain);
Assert.Equal("user@netflix.com", captured.Username);
Assert.Equal("SecretNetflixPass#1", captured.Password);
}
[Fact]
public async Task CredentialAutofillEngine_EvaluateSaveOrUpdate_정확한_상태_전이_검증()
{
string tempDir = Path.Combine(Path.GetTempPath(), "paca_eval_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var auth = new MockBiometricAuthenticator();
var vault = new OsProtectedCredentialVault(Path.Combine(tempDir, "eval.pacavault"), auth);
await vault.UnlockWithBiometricAsync();
var rec = new SiteCredentialRecord
{
Url = "https://github.com/login",
Domain = "github.com",
Username = "developer@github.com",
DecryptedPassword = "OldPassword123!"
};
vault.AddOrUpdate(rec);
// 1. 신규 계정 캡처 -> SaveNew
var newCap = new CapturedFormCredential("https://github.com/login", "github.com", "otheruser@github.com", "NewPass999!", DateTime.UtcNow);
var evalNew = CredentialAutofillEngine.EvaluateSaveOrUpdateAction(vault, newCap);
Assert.Equal(CredentialAutofillEngine.CredentialActionType.SaveNew, evalNew.Action);
Assert.Null(evalNew.ExistingRecord);
// 2. 동일 비밀번호 캡처 -> None
var sameCap = new CapturedFormCredential("https://github.com/login", "github.com", "developer@github.com", "OldPassword123!", DateTime.UtcNow);
var evalSame = CredentialAutofillEngine.EvaluateSaveOrUpdateAction(vault, sameCap);
Assert.Equal(CredentialAutofillEngine.CredentialActionType.None, evalSame.Action);
Assert.NotNull(evalSame.ExistingRecord);
// 3. 변경된 비밀번호 캡처 -> UpdateExisting
var updateCap = new CapturedFormCredential("https://github.com/login", "github.com", "developer@github.com", "BrandNewPassword2026#", DateTime.UtcNow);
var evalUpdate = CredentialAutofillEngine.EvaluateSaveOrUpdateAction(vault, updateCap);
Assert.Equal(CredentialAutofillEngine.CredentialActionType.UpdateExisting, evalUpdate.Action);
Assert.NotNull(evalUpdate.ExistingRecord);
Assert.Equal("developer@github.com", evalUpdate.ExistingRecord.Username);
}
finally
{
try { Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public void CredentialAutofillEngine_GenerateInlineSuggestionScript_DOM과_이벤트_바인딩_생성_검증()
{
var records = new List<SiteCredentialRecord>
{
new()
{
Id = "rec1",
Url = "https://naver.com",
Domain = "naver.com",
SiteName = "네이버",
Username = "naver_user",
DecryptedPassword = "NaverPass#1"
}
};
string script = CredentialAutofillEngine.GenerateInlineSuggestionScript(records);
Assert.Contains("__paca_autofill_popup", script);
Assert.Contains("naver_user", script);
Assert.Contains("Paca 자격증명 금고", script);
Assert.Contains("triggerInputEvents", script);
Assert.Contains("focusin", script);
}
#endregion
}
/// <summary>
/// 52차: 100+ 극한 시나리오 다각도 E2E 스트레스 & 카오스 장애 주입 & 동시성 무결성 전수 검증.
/// </summary>
public class Milestone52CredentialVaultStressAndChaosTests
{
private readonly string _stressDir;
public Milestone52CredentialVaultStressAndChaosTests()
{
_stressDir = Path.Combine(Path.GetTempPath(), "paca_vault_stress_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_stressDir);
}
private void Cleanup()
{
try
{
if (Directory.Exists(_stressDir))
Directory.Delete(_stressDir, true);
}
catch { }
}
#region 1. 100 Concurrent Multi-Threaded Stress Test (Scenarios 1-100)
[Fact]
public async Task Scenario01_100_병렬_스레드_동시_금고_조회_수정_삭제_스트레스_검증()
{
string vaultPath = Path.Combine(_stressDir, "concurrent_vault.pacavault");
var mockAuth = new MockBiometricAuthenticator();
try
{
using var vault = new OsProtectedCredentialVault(vaultPath, mockAuth);
var unlockResult = await vault.UnlockWithBiometricAsync();
Assert.True(unlockResult.IsSuccess);
// 초기 10개 계정 시드
for (int i = 0; i < 10; i++)
{
vault.AddOrUpdate(new SiteCredentialRecord
{
Domain = $"service{i}.com",
Url = $"https://service{i}.com",
Username = $"user{i}@paca.dev",
DecryptedPassword = $"P@ssword_{i}_Secret!",
SiteName = $"Service {i}"
});
}
// 100개 병렬 작업 동시 실행 (Read, Search, Add, Update, Remove)
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
int workerId = i;
tasks.Add(Task.Run(() =>
{
int op = workerId % 5;
switch (op)
{
case 0: // Add
vault.AddOrUpdate(new SiteCredentialRecord
{
Domain = $"worker{workerId}.com",
Url = $"https://worker{workerId}.com/login",
Username = $"worker_user_{workerId}",
DecryptedPassword = $"Pass_{workerId}_{Guid.NewGuid():N}",
SiteName = $"Worker Site {workerId}"
});
break;
case 1: // Search
var found = vault.Search($"service");
Assert.NotNull(found);
break;
case 2: // FindMatching
var matched = vault.FindMatching($"https://service{workerId % 10}.com/account");
Assert.NotNull(matched);
break;
case 3: // GetAll & Password Check
var all = vault.GetAll();
Assert.NotNull(all);
foreach (var item in all)
{
Assert.False(string.IsNullOrEmpty(item.Username));
}
break;
case 4: // Update existing
var itemToUpdate = vault.FindMatching($"service{workerId % 10}.com").FirstOrDefault();
if (itemToUpdate != null)
{
itemToUpdate.Notes = $"Updated by worker {workerId} at {DateTime.UtcNow.Ticks}";
vault.AddOrUpdate(itemToUpdate);
}
break;
}
}));
}
await Task.WhenAll(tasks);
// 동시성 작업 완료 후 금고 무결성 검증
Assert.False(vault.IsLocked);
Assert.True(vault.RecordCount >= 10);
var finalRecords = vault.GetAll();
Assert.NotEmpty(finalRecords);
// 파일 재로드 무결성 검증
vault.Lock();
Assert.True(vault.IsLocked);
}
finally
{
Cleanup();
}
}
#endregion
#region 2. 100 Chaos File Corruption & Bit-Flip Fault Injection Tests (Scenarios 101-200)
[Fact]
public async Task Scenario02_100회_무작위_파일_손상_비트플립_주입_시_자가치유_백업_복구_검증()
{
string vaultPath = Path.Combine(_stressDir, "chaos_vault.pacavault");
string backupPath = vaultPath + ".bak";
var mockAuth = new MockBiometricAuthenticator();
try
{
// 1. 정상 데이터 작성 및 백업 생성
using (var vault = new OsProtectedCredentialVault(vaultPath, mockAuth))
{
await vault.UnlockWithBiometricAsync();
for (int i = 0; i < 20; i++)
{
vault.AddOrUpdate(new SiteCredentialRecord
{
Domain = $"healthy{i}.org",
Url = $"https://healthy{i}.org",
Username = $"healthy_user_{i}",
DecryptedPassword = $"HealthyP@ss_{i}",
SiteName = $"Healthy Site {i}"
});
}
}
Assert.True(File.Exists(vaultPath));
Assert.True(File.Exists(backupPath));
// 2. 100가지 다양한 손상 케이스 주입 및 자가치유 복구 검증
var rng = new Random(42);
for (int testCase = 0; testCase < 100; testCase++)
{
// 백업 파일 기준으로 손상 데이터 생성하여 원본 파일에 덮어쓰기
byte[] corruptedData = File.ReadAllBytes(backupPath);
int mode = testCase % 5;
switch (mode)
{
case 0: // 0바이트 파일
File.WriteAllBytes(vaultPath, Array.Empty<byte>());
break;
case 1: // 매직 헤더 파괴
corruptedData[0] = (byte)'X';
corruptedData[1] = (byte)'X';
File.WriteAllBytes(vaultPath, corruptedData);
break;
case 2: // 페이로드 길이 음수 또는 거대 크기 변조
BitConverter.GetBytes(-9999).CopyTo(corruptedData, 12);
File.WriteAllBytes(vaultPath, corruptedData);
break;
case 3: // 무작위 바이트 비트 플립 (Fuzzing)
int offset = rng.Next(0, corruptedData.Length);
corruptedData[offset] ^= 0xFF;
File.WriteAllBytes(vaultPath, corruptedData);
break;
case 4: // 파일 절반 절단 (Truncation)
int cutLen = Math.Max(1, corruptedData.Length / 2);
byte[] cutData = new byte[cutLen];
Buffer.BlockCopy(corruptedData, 0, cutData, 0, cutLen);
File.WriteAllBytes(vaultPath, cutData);
break;
}
// 손상된 상태에서 금고 로드 시도시 .bak에서 자가치유 복구 확인 (백업 스냅샷 19개 이상 복원)
using (var healedVault = new OsProtectedCredentialVault(vaultPath, mockAuth))
{
Assert.True(healedVault.RecordCount >= 19, $"자가치유 복구 레코드 수({healedVault.RecordCount})가 유효해야 합니다.");
await healedVault.UnlockWithBiometricAsync();
var item = healedVault.FindMatching("healthy0.org").FirstOrDefault();
Assert.NotNull(item);
Assert.Equal("HealthyP@ss_0", item.DecryptedPassword);
}
}
}
finally
{
Cleanup();
}
}
#endregion
#region 3. 100 Biometric Hardware State & Error Variations (Scenarios 201-300)
[Fact]
public async Task Scenario03_100회_생체인증_하드웨어_상태_전이_및_잠금정책_검증()
{
string vaultPath = Path.Combine(_stressDir, "bio_states_vault.pacavault");
var mockAuth = new MockBiometricAuthenticator();
var policy = new BiometricSecurityPolicy
{
MaxFailedAttemptsBeforeLockout = 5,
LockoutDurationSeconds = 0, // 즉시 복구
AutoLockMinutes = 15
};
try
{
using var vault = new OsProtectedCredentialVault(vaultPath, mockAuth, policy);
// 100회 다양한 상태 전이 테스트
for (int i = 0; i < 100; i++)
{
int stateMode = i % 8;
switch (stateMode)
{
case 0: // 성공
await Task.Delay(70); // 잠금 해제 대기 (50ms 초과)
mockAuth.Availability = BiometricAvailability.Available;
mockAuth.ResponseStatus = BiometricAuthStatus.Success;
var sRes = await vault.UnlockWithBiometricAsync();
Assert.True(sRes.IsSuccess);
Assert.False(vault.IsLocked);
vault.Lock();
break;
case 1: // 사용자 대체 인증(PIN) 성공
await Task.Delay(70);
mockAuth.Availability = BiometricAvailability.Available;
mockAuth.ResponseStatus = BiometricAuthStatus.UserFallback;
var fbRes = await vault.UnlockWithBiometricAsync();
Assert.True(fbRes.IsSuccess);
Assert.False(vault.IsLocked);
vault.Lock();
break;
case 2: // 사용자 취소
mockAuth.Availability = BiometricAvailability.Available;
mockAuth.ResponseStatus = BiometricAuthStatus.Canceled;
var cRes = await vault.UnlockWithBiometricAsync();
Assert.False(cRes.IsSuccess);
Assert.True(vault.IsLocked);
break;
case 3: // 타임아웃
mockAuth.Availability = BiometricAvailability.Available;
mockAuth.ResponseStatus = BiometricAuthStatus.Timeout;
var tRes = await vault.UnlockWithBiometricAsync();
Assert.False(tRes.IsSuccess);
Assert.True(vault.IsLocked);
break;
case 4: // 하드웨어 부재
mockAuth.Availability = BiometricAvailability.DeviceNotPresent;
var hwRes = await vault.UnlockWithBiometricAsync();
Assert.False(hwRes.IsSuccess);
break;
case 5: // 지문 미등록
mockAuth.Availability = BiometricAvailability.NotConfigured;
var ncRes = await vault.UnlockWithBiometricAsync();
Assert.False(ncRes.IsSuccess);
break;
case 6: // 지문 불일치
mockAuth.Availability = BiometricAvailability.Available;
mockAuth.ResponseStatus = BiometricAuthStatus.BiometricsFailed;
var failRes = await vault.UnlockWithBiometricAsync();
Assert.False(failRes.IsSuccess);
break;
case 7: // 잠금 시간 후 복구
await Task.Delay(1050); // 1초 잠금 초과 대기
mockAuth.Availability = BiometricAvailability.Available;
mockAuth.ResponseStatus = BiometricAuthStatus.Success;
var recRes = await vault.UnlockWithBiometricAsync();
Assert.True(recRes.IsSuccess);
vault.Lock();
break;
}
}
}
finally
{
Cleanup();
}
}
#endregion
#region 4. 100 Password Security, Entropy & Fuzzing Edge Cases (Scenarios 301-400)
[Fact]
public void Scenario04_100가지_특수문자_유니코드_이모지_SQL인젝션_암호강도_엔트로피_퍼징()
{
string[] fuzzPasswords = new[]
{
"한글비밀번호!2026",
"パスワード!12345",
"密码#Secure999",
"Пароль_123456!",
"😀🚀🛡️🔥🎉Key!99",
"' OR '1'='1' --",
"<script>alert('paca')</script>",
"admin\0nullbyte",
new string('A', 1024),
new string('!', 512),
" leading and trailing spaces ",
"A1!",
"Paca-Quantum-Matrix-Titan-2026#",
"\u200B\u200C\u200DZeroWidthSecret123!",
"1234567890abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+"
};
for (int i = 0; i < 100; i++)
{
string pass = fuzzPasswords[i % fuzzPasswords.Length] + "_" + i;
var eval = PasswordSecurityEngine.EvaluateStrength(pass);
Assert.NotNull(eval);
Assert.True(eval.Score >= 0 && eval.Score <= 100);
Assert.True(eval.EntropyBits >= 0);
Assert.NotNull(eval.Suggestions);
// 임의 생성기 교차 검증
var gen = PasswordSecurityEngine.GeneratePassword(new PasswordGeneratorOptions
{
Length = (i % 60) + 8,
IncludeSymbols = (i % 2 == 0),
IncludeDigits = (i % 3 != 0)
});
Assert.NotNull(gen);
Assert.True(gen.Length >= 8);
}
}
#endregion
#region 5. 100 URL & Domain Federation Matching Edge Cases (Scenarios 401-500)
[Fact]
public void Scenario05_100가지_복합_도메인_서브도메인_포트_페더레이션_매칭_검증()
{
var baseRecord = new SiteCredentialRecord
{
Domain = "youtube.com",
Url = "https://www.youtube.com",
Username = "creator@gmail.com"
};
string[] testUrls = new[]
{
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://m.youtube.com/feed/subscriptions",
"http://youtube.com:443/login",
"https://accounts.google.com/signin/v2/identifier",
"https://google.com/service/login",
"https://YOUTUBE.COM/channel/UC123",
"https://studio.youtube.com/video/edit",
"https://music.youtube.com/playlist"
};
for (int i = 0; i < 100; i++)
{
string url = testUrls[i % testUrls.Length];
Assert.True(baseRecord.MatchesDomainOrUrl(url), $"URL '{url}' 매칭 실패");
Assert.False(baseRecord.MatchesDomainOrUrl("https://apple.com/kr/store"));
Assert.False(baseRecord.MatchesDomainOrUrl("https://naver.com/login"));
}
}
#endregion
#region 6. 100 CSV & Encrypted Vault Import/Export Fuzzing (Scenarios 501-600)
[Fact]
public void Scenario06_100회_암호화금고_및_CSV_대량데이터_라운드트립_무결성_검증()
{
for (int i = 0; i < 100; i++)
{
var records = new List<SiteCredentialRecord>();
int count = (i % 20) + 1;
for (int k = 0; k < count; k++)
{
records.Add(new SiteCredentialRecord
{
Domain = $"test{i}_{k}.com",
Url = $"https://test{i}_{k}.com/login",
Username = $"user_{i}_{k}@test.com",
DecryptedPassword = $"P@ss_{i}_{k}_\"Quotes\"_{Guid.NewGuid():N}",
SiteName = $"Site, Name with comma {k}",
Notes = $"Note line 1\r\nLine 2 {k}",
Category = k % 2 == 0 ? "Streaming" : "Social",
TotpSecret = k % 3 == 0 ? "JBSWY3DPEHPK3PXP" : null
});
}
// 1. Encrypted Vault Roundtrip
string masterKey = $"MasterPassword_{i}_!@#";
byte[] exportedEncrypted = CredentialDataExchange.ExportEncryptedVault(records, masterKey);
var importedEncrypted = CredentialDataExchange.ImportEncryptedVault(exportedEncrypted, masterKey);
Assert.Equal(count, importedEncrypted.Count);
Assert.Equal(records[0].Domain, importedEncrypted[0].Domain);
Assert.Equal(records[0].DecryptedPassword, importedEncrypted[0].DecryptedPassword);
// 2. CSV Roundtrip
string csv = CredentialDataExchange.ExportCsv(records);
var importedCsv = CredentialDataExchange.ImportCsv(csv);
Assert.Equal(count, importedCsv.Count);
Assert.Equal(records[0].Username, importedCsv[0].Username);
Assert.Equal(records[0].DecryptedPassword, importedCsv[0].DecryptedPassword);
}
}
#endregion
#region 7. 100 TOTP Drift & Time-Window Variations (Scenarios 601-700)
[Fact]
public void Scenario07_100가지_TOTP_시간표류_및_경계값_토큰_유효성_검증()
{
string base32Secret = "JBSWY3DPEHPK3PXP";
var baseTime = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
for (int i = 0; i < 100; i++)
{
var currentTime = baseTime.AddSeconds(i * 17); // 17초씩 전진
string token = TotpEngine.GenerateCurrentCode(base32Secret, 30, 6, currentTime);
Assert.Equal(6, token.Length);
// 현재 윈도우 유효성 확인
Assert.True(TotpEngine.ValidateCode(base32Secret, token, allowedDriftSteps: 1, 30, currentTime));
// 경계 오프셋 검증 (±25초 이내는 ±1 스텝 허용 범위 내)
Assert.True(TotpEngine.ValidateCode(base32Secret, token, allowedDriftSteps: 1, 30, currentTime.AddSeconds(15)));
Assert.True(TotpEngine.ValidateCode(base32Secret, token, allowedDriftSteps: 1, 30, currentTime.AddSeconds(-15)));
// 90초 이상 벗어나면 무효화
Assert.False(TotpEngine.ValidateCode(base32Secret, token, allowedDriftSteps: 1, 30, currentTime.AddSeconds(120)));
}
}
#endregion
#region 8. End-to-End Complete User Journey Lifecycle Verification
[Fact]
public async Task Scenario08_사용자_전체_생애주기_E2E_통합_검증()
{
string vaultPath = Path.Combine(_stressDir, "e2e_journey_vault.pacavault");
var mockAuth = new MockBiometricAuthenticator();
try
{
// 1. 금고 생성 및 잠금 상태 확인
using var vault = new OsProtectedCredentialVault(vaultPath, mockAuth);
Assert.True(vault.IsLocked);
// 2. Windows Hello 잠금 해제
var unlock = await vault.UnlockWithBiometricAsync("E2E 테스트 잠금 해제");
Assert.True(unlock.IsSuccess);
Assert.False(vault.IsLocked);
// 3. 브라우저에서 넷플릭스 / 유튜브 / 네이버 로그인 계정 캡처 시뮬레이션
string capturedMsg = """
{
"type": "paca_captured_credential",
"url": "https://www.netflix.com/kr/login",
"username": "paca_user@netflix.com",
"password": "UltraSecure#Netflix2026!"
}
""";
var captured = CredentialAutofillEngine.TryParseCapturedMessage(capturedMsg);
Assert.NotNull(captured);
var netflixRec = new SiteCredentialRecord
{
Domain = captured.Domain,
Url = captured.Url,
Username = captured.Username,
DecryptedPassword = captured.Password,
SiteName = "Netflix Korea",
Category = "Streaming",
TotpSecret = "JBSWY3DPEHPK3PXP"
};
vault.AddOrUpdate(netflixRec);
// 4. 유튜브 계정 추가
var youtubeRec = new SiteCredentialRecord
{
Domain = "youtube.com",
Url = "https://youtube.com",
Username = "creator@youtube.com",
DecryptedPassword = "Creator#Password!2026",
SiteName = "YouTube",
Category = "Streaming"
};
vault.AddOrUpdate(youtubeRec);
// 5. 보안 감사 실행
var audit = CredentialSecurityAuditor.PerformAudit(vault.GetAll());
Assert.Equal(2, audit.TotalAccounts);
Assert.Equal(100, audit.OverallHealthScore); // 강력한 암호로 100점 만점
Assert.Equal(0, audit.WeakPasswordCount);
Assert.Equal(0, audit.ReusedPasswordCount);
// 6. 브라우저 탭 진입 시 자동완성 스크립트 생성 및 적용 확인
var matchedCredentials = CredentialAutofillEngine.FindApplicableCredentials(vault, "https://netflix.com/login");
Assert.Single(matchedCredentials);
Assert.Equal("paca_user@netflix.com", matchedCredentials[0].Username);
string script = CredentialAutofillEngine.GenerateAutofillScript(
matchedCredentials[0].Username,
matchedCredentials[0].DecryptedPassword,
TotpEngine.GenerateCurrentCode(matchedCredentials[0].TotpSecret!));
Assert.Contains("paca_user@netflix.com", script);
Assert.Contains("UltraSecure#Netflix2026!", script);
// 7. ViewModel 바인딩 및 클립보드 복사 시뮬레이션
var vm = new CredentialVaultViewModel(vault);
vm.SelectedCredential = netflixRec;
string copiedText = "";
vm.CopyPasswordToClipboard(
pass => copiedText = pass,
() => { copiedText = ""; });
Assert.Equal("UltraSecure#Netflix2026!", copiedText);
Assert.True(vm.ClipboardSecondsRemaining > 0);
// 8. 금고 잠금 및 메모리 평문 암호 제로화 검증
vault.Lock();
Assert.True(vault.IsLocked);
Assert.All(vault.GetAll(), r => Assert.Equal("", r.DecryptedPassword));
}
finally
{
Cleanup();
}
}
#endregion
}
/// <summary>
/// Phase 64: Zero-Knowledge CRDT Encrypted Vault & Cross-Cloud Sync 검증 테스트 스위트.
/// </summary>
public class ZeroKnowledgeCrdtVaultPhase64Tests
{
[Fact]
public void CrdtVaultSyncEngine_다중_노드_무충돌_LWW_병합_검증()
{
var nodeA = new CrdtVaultSyncEngine<string>("client-node-A");
var nodeB = new CrdtVaultSyncEngine<string>("client-node-B");
// Node A adds item 1
var rec1 = nodeA.Set("item-1", "Value from Node A (v1)");
// Node B adds item 2
var rec2 = nodeB.Set("item-2", "Value from Node B");
// Node A merges state from Node B
int appliedA = nodeA.Merge(nodeB.ExportState());
Assert.Equal(1, appliedA);
Assert.Equal(2, nodeA.GetActiveItems().Count);
// Concurrent update to item 1 on Node B with higher clock
nodeB.Merge(nodeA.ExportState());
nodeB.Set("item-1", "Value from Node B (v2 overwritten)");
// Merge back to Node A
nodeA.Merge(nodeB.ExportState());
var activeA = nodeA.GetActiveItems();
Assert.Contains("Value from Node B (v2 overwritten)", activeA);
// Delete item 2 on Node A
nodeA.Delete("item-2");
nodeB.Merge(nodeA.ExportState());
var activeB = nodeB.GetActiveItems();
Assert.Single(activeB);
Assert.Equal("Value from Node B (v2 overwritten)", activeB[0]);
}
[Fact]
public void E2eeRemoteBackupProvider_AES256GCM_암복호화_라운드트립_검증()
{
string plainSecret = "{\"bookmarks\": [\"https://youtube.com\"], \"masterKey\": \"super-secret-vault-key-2026\"}";
string masterPass = "MasterPacaPassword#2026!Strong";
string backupPayload = E2eeRemoteBackupProvider.EncryptPayload(plainSecret, masterPass);
Assert.Contains("PACAVAULT", backupPayload);
Assert.Contains("AES-256-GCM", backupPayload);
string decrypted = E2eeRemoteBackupProvider.DecryptPayload(backupPayload, masterPass);
Assert.Equal(plainSecret, decrypted);
// Wrong password should throw CryptographicException
Assert.ThrowsAny<CryptographicException>(() => E2eeRemoteBackupProvider.DecryptPayload(backupPayload, "WrongPassword123!"));
}
[Fact]
public void HardwareKeyTokenAuthenticator_FIDO2_도출키_결정론적_생성_검증()
{
var challenge = HardwareKeyTokenAuthenticator.GenerateChallenge("paca.media");
Assert.NotNull(challenge.ChallengeBase64);
Assert.Equal("paca.media", challenge.RpId);
byte[] masterKey = System.Text.Encoding.UTF8.GetBytes("PacaPhysicalYubiKeyMasterSecret32B");
byte[] salt = System.Text.Encoding.UTF8.GetBytes("HardwareSalt16Bytes!");
byte[] derivedKey1 = HardwareKeyTokenAuthenticator.DeriveVaultKeyFromHmacSecret(masterKey, salt);
byte[] derivedKey2 = HardwareKeyTokenAuthenticator.DeriveVaultKeyFromHmacSecret(masterKey, salt);
Assert.Equal(32, derivedKey1.Length);
Assert.Equal(derivedKey1, derivedKey2);
}
}