releases/tests/Paca.Tests/Server/ServerPasskeyAuthTests.cs

183 lines
7.4 KiB
C#

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<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
public Task<string> 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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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) { }
}
}