using System; using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.WebUtilities; using Paca.Core.Security; using Paca.Server; using Xunit; namespace Paca.Tests.Server; public class ServerPasskeyAuthTests : IDisposable { private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_passkey_{Guid.NewGuid():N}"); public ServerPasskeyAuthTests() => Directory.CreateDirectory(_libDir); private sealed class EmptyQueue : IQueueAdapter { public Task> GetItemsAsync() => Task.FromResult>(Array.Empty()); public Task AddAsync(string url, string? title) => Task.FromResult(Guid.NewGuid().ToString("N")); public Task PauseAsync(string id) => Task.CompletedTask; public Task ResumeAsync(string id) => Task.CompletedTask; public Task CancelAsync(string id) => Task.CompletedTask; public event Action? Changed { add { } remove { } } } [Fact] public async Task GetChallenge_ReturnsNewValidChallenge() { await using var server = await ServerHost.StartAsync(new ServerOptions { Port = 0, Token = "secret-passkey-token", LibraryFolder = _libDir, Queue = new EmptyQueue() }); using var client = new HttpClient(); var resp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge"); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var doc = await resp.Content.ReadFromJsonAsync(); Assert.True(doc.GetProperty("ok").GetBoolean()); var challenge = doc.GetProperty("challenge").GetString(); Assert.False(string.IsNullOrEmpty(challenge)); Assert.True(doc.TryGetProperty("rpId", out var rpId)); Assert.Equal("paca.local", rpId.GetString()); } [Fact] public async Task Verify_WithValidFido2Assertion_ReturnsSessionToken() { await using var server = await ServerHost.StartAsync(new ServerOptions { Port = 0, Token = "secret-passkey-token-2", DeviceName = "MyTestPc", LibraryFolder = _libDir, Queue = new EmptyQueue() }); using var client = new HttpClient(); // 1. 챌린지 요청 var chalResp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge"); var chalDoc = await chalResp.Content.ReadFromJsonAsync(); var challenge = chalDoc.GetProperty("challenge").GetString()!; // 2. 가상 FIDO2 자격증명 생성 및 어설션 서명 var authenticator = VirtualFido2Authenticator.Default; var cred = authenticator.CreateCredential("paca.local", "user-1", "test-user"); var clientDataHash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge)); var assertion = authenticator.SignChallenge(cred.CredentialId, clientDataHash); // 3. Verify 요청 var verifyReq = new { challenge = challenge, credentialId = cred.CredentialId, clientDataHash = WebEncoders.Base64UrlEncode(clientDataHash), authenticatorData = WebEncoders.Base64UrlEncode(assertion.AuthenticatorData), signature = WebEncoders.Base64UrlEncode(assertion.Signature) }; var verifyResp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq); Assert.Equal(HttpStatusCode.OK, verifyResp.StatusCode); var verifyDoc = await verifyResp.Content.ReadFromJsonAsync(); Assert.True(verifyDoc.GetProperty("ok").GetBoolean()); Assert.Equal("secret-passkey-token-2", verifyDoc.GetProperty("token").GetString()); Assert.Equal("MyTestPc", verifyDoc.GetProperty("device").GetString()); } [Fact] public async Task Verify_WithInvalidSignature_Returns401() { await using var server = await ServerHost.StartAsync(new ServerOptions { Port = 0, Token = "secret-passkey-token-3", LibraryFolder = _libDir, Queue = new EmptyQueue() }); using var client = new HttpClient(); var chalResp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge"); var chalDoc = await chalResp.Content.ReadFromJsonAsync(); var challenge = chalDoc.GetProperty("challenge").GetString()!; var authenticator = VirtualFido2Authenticator.Default; var cred = authenticator.CreateCredential("paca.local", "user-2", "test-user-2"); var clientDataHash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge)); var assertion = authenticator.SignChallenge(cred.CredentialId, clientDataHash); // 위조 서명 var tamperedSignature = (byte[])assertion.Signature.Clone(); tamperedSignature[0] ^= 0xFF; var verifyReq = new { challenge = challenge, credentialId = cred.CredentialId, clientDataHash = WebEncoders.Base64UrlEncode(clientDataHash), authenticatorData = WebEncoders.Base64UrlEncode(assertion.AuthenticatorData), signature = WebEncoders.Base64UrlEncode(tamperedSignature) }; var verifyResp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq); Assert.Equal(HttpStatusCode.Unauthorized, verifyResp.StatusCode); } [Fact] public async Task Verify_ReplayAttack_Returns401() { await using var server = await ServerHost.StartAsync(new ServerOptions { Port = 0, Token = "secret-passkey-token-4", LibraryFolder = _libDir, Queue = new EmptyQueue() }); using var client = new HttpClient(); var chalResp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge"); var chalDoc = await chalResp.Content.ReadFromJsonAsync(); var challenge = chalDoc.GetProperty("challenge").GetString()!; var authenticator = VirtualFido2Authenticator.Default; var cred = authenticator.CreateCredential("paca.local", "user-3", "test-user-3"); var clientDataHash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge)); var assertion = authenticator.SignChallenge(cred.CredentialId, clientDataHash); var verifyReq = new { challenge = challenge, credentialId = cred.CredentialId, clientDataHash = WebEncoders.Base64UrlEncode(clientDataHash), authenticatorData = WebEncoders.Base64UrlEncode(assertion.AuthenticatorData), signature = WebEncoders.Base64UrlEncode(assertion.Signature) }; // 1차 성공 var first = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq); Assert.Equal(HttpStatusCode.OK, first.StatusCode); // 동일 챌린지 재사용 (Replay 공격) -> 401 거부 var second = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq); Assert.Equal(HttpStatusCode.Unauthorized, second.StatusCode); } public void Dispose() { try { Directory.Delete(_libDir, recursive: true); } catch (IOException) { } } }