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()))); } }