using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Fleet.Core;
namespace MoShiro.Backend.Tests;
/// 软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。
public sealed class SoftwarePasskey : IDisposable
{
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private uint _counter;
public SoftwarePasskey(string rpId, string origin)
{
RpId = rpId;
Origin = origin;
CredentialId = RandomNumberGenerator.GetBytes(32);
}
public string RpId { get; }
public string Origin { get; }
public byte[] CredentialId { get; }
public string Id => Base64Url.Encode(CredentialId);
public JsonObject Register(string name, string challenge, byte flags = 0x45, string? origin = null, string? rpId = null)
{
ECParameters p = _key.ExportParameters(false);
byte[] cose = [.. MapHead(5), .. Int(1), .. Int(2), .. Int(3), .. Int(-7), .. Int(-1), .. Int(1), .. Int(-2), .. Bytes(p.Q.X!), .. Int(-3), .. Bytes(p.Q.Y!)];
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter), .. new byte[16], (byte)(CredentialId.Length >> 8), (byte)CredentialId.Length, .. CredentialId, .. cose];
byte[] attestation = [.. MapHead(3), .. Text("fmt"), .. Text("none"), .. Text("attStmt"), .. MapHead(0), .. Text("authData"), .. Bytes(authData)];
return new JsonObject
{
["name"] = name,
["credential_id"] = Id,
["attestation_object"] = Base64Url.Encode(attestation),
["client_data_json"] = Base64Url.Encode(ClientData("webauthn.create", challenge, origin ?? Origin)),
["transports"] = new JsonArray("internal"),
};
}
public JsonObject Assert(string challenge, uint? counter = null, byte flags = 0x05, string? origin = null, string? rpId = null, bool tamper = false)
{
_counter = counter ?? _counter + 1;
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter)];
byte[] clientData = ClientData("webauthn.get", challenge, origin ?? Origin);
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
if (tamper)
{
signature[^1] ^= 0x01;
}
return new JsonObject
{
["credential_id"] = Id,
["authenticator_data"] = Base64Url.Encode(authData),
["client_data_json"] = Base64Url.Encode(clientData),
["signature"] = Base64Url.Encode(signature),
["return_session"] = true,
};
}
public void Dispose() => _key.Dispose();
private static byte[] ClientData(string type, string challenge, string origin) =>
Encoding.UTF8.GetBytes($"{{\"type\":\"{type}\",\"challenge\":\"{challenge}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
private static byte[] Counter(uint value) => [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value];
// 最小的 CBOR 编码器:只写测试需要的几种
private static byte[] Head(int major, int length) =>
length < 24 ? [(byte)((major << 5) | length)] : length < 256 ? [(byte)((major << 5) | 24), (byte)length] : [(byte)((major << 5) | 25), (byte)(length >> 8), (byte)length];
private static byte[] MapHead(int count) => Head(5, count);
private static byte[] Int(int value) => value >= 0 ? Head(0, value) : Head(1, -1 - value);
private static byte[] Bytes(byte[] data) => [.. Head(2, data.Length), .. data];
private static byte[] Text(string text) => [.. Head(3, Encoding.UTF8.GetByteCount(text)), .. Encoding.UTF8.GetBytes(text)];
}
/// passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/security/access.md 第 4 节。
public sealed class PasskeyTests : IClassFixture
{
private readonly BackendFixture _backend;
public PasskeyTests(BackendFixture backend)
{
_backend = backend;
}
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
private string RpId => _backend.BaseAddress.Host;
private async Task PasswordSessionAsync(string user, string password)
{
HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password, ["return_session"] = true }));
string body = await login.Content.ReadAsStringAsync();
Assert.True(login.IsSuccessStatusCode, body);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["session_token"]!.GetValue());
return client;
}
private string _user = "";
/// 每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,而这里测的是第一个 passkey 的流程。
private async Task FreshSessionAsync()
{
_user = _backend.UniqueName("pk");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _user, ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
return await PasswordSessionAsync(_user, "correct horse battery");
}
private static async Task JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
private async Task RegistrationChallengeAsync(HttpClient session)
{
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
JsonNode body = await JsonOf(options);
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
Assert.Equal(RpId, body["rp_id"]!.GetValue());
return body["challenge"]!.GetValue();
}
private async Task LoginChallengeAsync()
{
using HttpResponseMessage options = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/options", null);
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
return (await JsonOf(options))["challenge"]!.GetValue();
}
private async Task RegisterAsync(HttpClient session, string name = "laptop")
{
var passkey = new SoftwarePasskey(RpId, Origin);
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, await RegistrationChallengeAsync(session))));
Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync());
return passkey;
}
private async Task LoginStatusAsync(JsonObject assertion)
{
using HttpResponseMessage response = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(assertion));
return response.StatusCode;
}
[Fact]
public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade()
{
using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys"));
Assert.Contains(list["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id && p["name"]!.GetValue() == "laptop");
using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync())));
JsonNode body = await JsonOf(login);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.Equal(_user, body["username"]!.GetValue());
Assert.NotNull(body["session_token"]);
Assert.Equal("no-store", login.Headers.CacheControl?.ToString());
// 拿这个会话令牌就是登录状态,能力和密码登录一样
using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress, Timeout = TimeSpan.FromSeconds(15) };
signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue());
JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me"));
Assert.Equal(_user, me["username"]!.GetValue());
Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue() == "fleet.read");
}
[Fact]
public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused()
{
using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), origin: "https://evil.example")));
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), rpId: "evil.example")));
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), tamper: true)));
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), flags: 0x01))); // 没有用户验证(UV)
// 挑战只能用一次:同一份断言重放
string challenge = await LoginChallengeAsync();
JsonObject assertion = passkey.Assert(challenge);
Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(assertion));
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(assertion));
// 凭证不存在
using var stranger = new SoftwarePasskey(RpId, Origin);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(stranger.Assert(await LoginChallengeAsync())));
// 缺字段不会崩
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(new JsonObject()));
}
[Fact]
public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne()
{
using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5)));
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); // 克隆或回放
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 3)));
Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 6)));
}
[Fact]
public async Task Registration_NeedsASignedInUser_AValidAttestation_AndAFreshChallenge()
{
// API 令牌不是用户:没有 passkey 可言
using HttpResponseMessage asToken = await _backend.Admin.PostAsync("/api/admin/auth/passkeys/options", null);
Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode);
using HttpClient session = await FreshSessionAsync();
using var passkey = new SoftwarePasskey(RpId, Origin);
async Task TryAsync(JsonObject body)
{
using HttpResponseMessage response = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(body));
return response.StatusCode;
}
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", "bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), origin: "https://evil.example")));
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), rpId: "evil.example")));
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x41))); // 没有 UV
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x05))); // 没有凭证数据
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register(" ", await RegistrationChallengeAsync(session)))); // 名字为空
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new JsonObject { ["name"] = "x", ["credential_id"] = "AA", ["attestation_object"] = "AAAA", ["client_data_json"] = "AAAA" })); // 垃圾
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
string challenge = await RegistrationChallengeAsync(session);
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
}
[Fact]
public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn()
{
using HttpClient admin = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(admin, "phone");
string other = _backend.UniqueName("user");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 别人的 passkey:看不见,也删不了
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
}
}