265 lines
15 KiB
C#
265 lines
15 KiB
C#
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>
|
||
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)];
|
||
}
|
||
|
||
/// <summary>passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/security/access.md 第 4 节。</summary>
|
||
public sealed class PasskeyTests : IClassFixture<BackendFixture>
|
||
{
|
||
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 string _user = "";
|
||
|
||
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,而这里测的是第一个 passkey 的流程。</summary>
|
||
private async Task<HttpClient> 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<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 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<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(_user, 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(_user, 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 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<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 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<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())));
|
||
}
|
||
}
|