1166 lines
46 KiB
C#
1166 lines
46 KiB
C#
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);
|
||
}
|
||
}
|