Files
MoShiro/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
T

255 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。</summary>
internal 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)];
}
/// <summary>passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/access.md 第 3 节。</summary>
[Collection("probe-e2e")]
public sealed class PasskeyTests
{
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<HttpClient> 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<string>());
return client;
}
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
private async Task<string> 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<string>());
return body["challenge"]!.GetValue<string>();
}
private async Task<string> 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<string>();
}
private async Task<SoftwarePasskey> 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<HttpStatusCode> 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 PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
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<string>() == passkey.Id && p["name"]!.GetValue<string>() == "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(BackendFixture.AdminUser, body["username"]!.GetValue<string>());
Assert.NotNull(body["session_token"]);
Assert.Equal("no-store", login.Headers.CacheControl?.ToString());
// 拿这个会话令牌就是登录状态,能力和密码登录一样
using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress };
signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue<string>());
JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me"));
Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue<string>());
Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue<string>() == "fleet.read");
}
[Fact]
public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused()
{
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
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 PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
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 PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
using var passkey = new SoftwarePasskey(RpId, Origin);
async Task<HttpStatusCode> 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 PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
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);
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == 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())));
}
}