diff --git a/backend/fleet/src/Fleet.Core/TrustDtos.cs b/backend/fleet/src/Fleet.Core/TrustDtos.cs new file mode 100644 index 0000000..05ce501 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/TrustDtos.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace Fleet.Core; + +/// passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 OperatorTrust。 +public sealed class SignedStatementDto +{ + [JsonPropertyName("statement")] + public string Statement { get; set; } = ""; + + [JsonPropertyName("credential_id")] + public string CredentialId { get; set; } = ""; + + [JsonPropertyName("authenticator_data")] + public string AuthenticatorData { get; set; } = ""; + + [JsonPropertyName("client_data_json")] + public string ClientDataJson { get; set; } = ""; + + [JsonPropertyName("signature")] + public string Signature { get; set; } = ""; +} + +/// 一个成员带的证明:操作员签的委托,加加入码的私钥签的绑定。 +public sealed class MemberProofDto +{ + [JsonPropertyName("grant")] + public SignedStatementDto Grant { get; set; } = new(); + + [JsonPropertyName("binding")] + public string Binding { get; set; } = ""; + + [JsonPropertyName("binding_signature")] + public string BindingSignature { get; set; } = ""; +} + +/// 操作员集合的链(创世在前),可选带一个委托(注册前取的,节点验过之后才继续)。 +public sealed class TrustBundle +{ + [JsonPropertyName("chain")] + public List Chain { get; set; } = []; + + [JsonPropertyName("grant")] + public SignedStatementDto? Grant { get; set; } +} diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index 726d27c..bc81783 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -2,13 +2,17 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Fleet.Core; using Fleet.Hosting; using Fleet.Identity; namespace Fleet.Enrollment.Client; -public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory); +[JsonSerializable(typeof(TrustBundle))] +internal sealed partial class TrustBundleContext : JsonSerializerContext; + +public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false); /// /// 用加入令牌注册(供 fleet-enroll 使用,也可以嵌入产品自己的安装工具): @@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident public static class EnrollmentClient { public static async Task> EnrollAsync( - HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct) + HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct, + string? seed = null, string? genesis = null, string role = "agent") { string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); if (File.Exists(nodeFile)) @@ -36,6 +41,27 @@ public static class EnrollmentClient tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo(); } + // 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的, + // 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。 + Binding? binding = null; + string? bindingStatement = null; + string? bindingSignature = null; + TrustBundle? bundle = null; + if (seed is not null || genesis is not null) + { + Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted = + await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct); + if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError)) + { + return Result.Fail(trustError); + } + + bundle = value.Bundle; + binding = value.Binding; + bindingStatement = value.Statement; + bindingSignature = value.Signature; + } + string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken))); var body = new JsonObject @@ -48,6 +74,8 @@ public static class EnrollmentClient ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), ["node_version"] = nodeVersion, ["meta"] = productMeta, + ["binding"] = bindingStatement, + ["binding_signature"] = bindingSignature, }; using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); @@ -76,10 +104,77 @@ public static class EnrollmentClient var store = new ConfigStore(identityDirectory); store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); store.Write(LoadedNodeIdentity.TlsFileName, pfx); + if (bundle is not null && binding is not null) + { + TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! }); + } + // node.json 最后写:它存在就表示注册完整完成 store.Write( LoadedNodeIdentity.NodeFileName, JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile)); - return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory)); + return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null)); + } + + private static async Task> EstablishTrustAsync( + HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct) + { + static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason); + + if (seedText is null || genesis is null) + { + return Refuse("--seed and --genesis go together (the panel's install command has both)"); + } + + if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32) + { + return Refuse("the code seed is malformed"); + } + + if (OperatorTrust.AuthToken(seed) != joinToken) + { + return Refuse("the join token is not derived from the code seed"); + } + + using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct); + if (!response.IsSuccessStatusCode) + { + return Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain"); + } + + TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle); + if (bundle?.Grant is null || bundle.Chain.Count == 0) + { + return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator"); + } + + OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]); + if (current is null) + { + return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)"); + } + + Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement); + long now = time.GetUtcNow().ToUnixTimeMilliseconds(); + if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current)) + { + return Refuse("the grant is not signed by your operator passkey"); + } + + if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs) + { + return Refuse("the grant is not for this code, this role, or this time"); + } + + using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey); + if (codeKey is null) + { + return Refuse("the code seed does not match the grant's code key"); + } + + var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now); + string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement); + return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature)); } } diff --git a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs new file mode 100644 index 0000000..be5051c --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; +using Fleet.Identity; + +namespace Fleet.Hosting; + +/// trust.json:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。 +public sealed class TrustFile +{ + [JsonPropertyName("genesis")] + public string Genesis { get; set; } = ""; + + [JsonPropertyName("chain")] + public List Chain { get; set; } = []; + + [JsonPropertyName("proof")] + public MemberProofDto? Proof { get; set; } +} + +[JsonSerializable(typeof(TrustFile))] +public sealed partial class TrustFileContext : JsonSerializerContext; + +/// +/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式—— +/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 trust.json(旧节点、没有指纹的安装)就不强制。 +/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 +/// +public sealed class TrustAnchor +{ + public const string FileName = "trust.json"; + + private readonly ConfigStore? _store; + private readonly INodeLog _log; + private readonly object _gate = new(); + private readonly TrustFile? _file; + private OperatorSet? _current; + + private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log) + { + _store = store; + _file = file; + _current = current; + _log = log; + } + + /// 不强制(没有固定的创世)。 + public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance); + + public bool Enforcing => _file is not null; + + public string? Genesis => _file?.Genesis; + + public MemberProofDto? OwnProof => _file?.Proof; + + public static TrustAnchor Load(string identityDirectory, INodeLog log) + { + string path = Path.Combine(identityDirectory, FileName); + if (!File.Exists(path)) + { + return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log); + } + + TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile); + OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]); + if (current is null) + { + // 固定的创世和存下来的链对不上:不能当成「不强制」放过,直接让节点起不来比悄悄失去保护好 + throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again"); + } + + return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log); + } + + /// 注册时写下:固定的创世、验证过的链、本节点的证明。 + public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, MemberProofDto proof) => + new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Proof = proof }, TrustFileContext.Default.TrustFile)); + + /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有强制时为 0)。 + public int Update(IReadOnlyList? chain) + { + lock (_gate) + { + if (_file is null || chain is not { Count: > 0 }) + { + return _current?.Version ?? 0; + } + + OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]); + if (candidate is null) + { + _log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it"); + return _current!.Version; + } + + if (candidate.Version > _current!.Version) + { + _file.Chain = [.. chain]; + _current = candidate; + try + { + _store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.Warn("could not save trust.json", ("err", ex.Message)); + } + + _log.Info("operator set updated", ("version", candidate.Version)); + } + + return _current.Version; + } + } + + /// 不强制就放行;强制时对端必须带证明,且验证通过(密钥是用来认证它的那把)。 + public TrustFailure VerifyPeer(MemberProofDto? proof, ReadOnlySpan peerPublicKey, string role) + { + OperatorSet? current; + lock (_gate) + { + if (_file is null) + { + return TrustFailure.None; + } + + current = _current; + } + + return proof is null || current is null ? TrustFailure.Malformed : OperatorTrust.VerifyPeer(current, proof.ToProof(), peerPublicKey, role); + } +} diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs index 980b5a5..320196a 100644 --- a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs +++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs @@ -187,6 +187,16 @@ public static class OperatorTrust return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _); } + /// + /// 验证一个对端:和 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥), + /// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。 + /// + public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan peerPublicKey, string role) + { + Binding? binding = ParseBinding(proof.BindingStatement); + return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role); + } + /// /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签, /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。 diff --git a/backend/fleet/src/Fleet.Identity/TrustMapping.cs b/backend/fleet/src/Fleet.Identity/TrustMapping.cs new file mode 100644 index 0000000..59a4843 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/TrustMapping.cs @@ -0,0 +1,15 @@ +using Fleet.Core; + +namespace Fleet.Identity; + +public static class TrustMapping +{ + public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature); + + public static SignedStatementDto ToDto(this SignedStatement signed) => + new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature }; + + public static MemberProof ToProof(this MemberProofDto dto) => new(dto.Grant.ToSigned(), dto.Binding, dto.BindingSignature); + + public static MemberProofDto ToDto(this MemberProof proof) => new() { Grant = proof.Grant.ToDto(), Binding = proof.BindingStatement, BindingSignature = proof.BindingSignature }; +} diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index d5db797..7f88641 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -34,7 +34,8 @@ public sealed record NewNode( long NowMs, string Kind = "", byte[]? TlsPublicKey = null, - string? AddedBy = null); + string? AddedBy = null, + MemberProofDto? Proof = null); public sealed record JoinTokenRecord( string Id, @@ -48,6 +49,7 @@ public sealed record JoinTokenRecord( long? RevokedAtMs, long CreatedAtMs, string Kind = "", - string? CreatedBy = null); + string? CreatedBy = null, + SignedStatementDto? Grant = null); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs index 72c567a..66a50b0 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -6,6 +6,12 @@ namespace Fleet.Server; public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta); +/// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。 +public interface IOperatorTrustSource +{ + ValueTask> ChainAsync(CancellationToken ct); +} + /// 产品挂钩:节点注册成功之后调用。 public interface IEnrollmentHook { diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs index de87ec2..bfa2dec 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs @@ -33,10 +33,23 @@ public sealed class EnrollRequest [JsonPropertyName("node_version")] public string NodeVersion { get; set; } = ""; + /// 带操作员委托的加入码要附上:用码的私钥签的绑定语句(节点的两把公钥和名字)和它的签名。 + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + [JsonPropertyName("binding_signature")] + public string? BindingSignature { get; set; } + [JsonPropertyName("meta")] public JsonElement? Meta { get; set; } } +public sealed class TrustRequest +{ + [JsonPropertyName("join_token")] + public string JoinToken { get; set; } = ""; +} + public sealed class EnrollResponse { [JsonPropertyName("node_id")] @@ -69,5 +82,7 @@ public sealed class FleetProblem [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(EnrollRequest))] [JsonSerializable(typeof(EnrollResponse))] +[JsonSerializable(typeof(TrustRequest))] +[JsonSerializable(typeof(Fleet.Core.TrustBundle))] [JsonSerializable(typeof(FleetProblem))] internal sealed partial class EnrollmentJsonContext : JsonSerializerContext; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs index 730cf82..ccd3567 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs @@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints .WithTags("agent") .WithSummary("Enroll a node with a join token") .DisableAntiforgery(); + // 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去 + app.MapPost(path + "/trust", HandleTrustAsync) + .WithName("fleet_enroll_trust") + .WithTags("agent") + .WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls") + .DisableAntiforgery(); return app; } + private static async Task HandleTrustAsync(HttpContext http, FleetEnrollmentService service) + { + TrustRequest? request; + try + { + request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted); + } + catch (JsonException) + { + request = null; + } + + if (request is null || string.IsNullOrEmpty(request.JoinToken)) + { + return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required"); + } + + Result result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted); + if (!result.TryGet(out TrustBundle? bundle, out FleetError? error)) + { + return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message); + } + + http.Response.Headers.CacheControl = "no-store"; + return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle); + } + private static async Task HandleAsync(HttpContext http, FleetEnrollmentService service) { EnrollRequest? request; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index bf50fb0..346cbe1 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService { private readonly IFleetStore _store; private readonly IEnumerable _hooks; + private readonly IEnumerable _trust; private readonly FleetServerOptions _options; private readonly TimeProvider _time; - public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, FleetServerOptions options, TimeProvider time) + public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, IEnumerable trust, FleetServerOptions options, TimeProvider time) { _store = store; _hooks = hooks; + _trust = trust; _options = options; _time = time; } @@ -59,6 +61,17 @@ public sealed partial class FleetEnrollmentService return Result.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify"); } + // 带操作员委托的码:节点必须附上用码的私钥签的绑定,委托、绑定和这次注册的密钥、名字、角色都对得上才收 + MemberProofDto? memberProof = null; + if (token.Grant is not null) + { + Result verified = await VerifyBindingAsync(token, request, publicKey, tlsPublicKey, ct); + if (!verified.TryGet(out memberProof, out FleetError? bindingError)) + { + return Result.Fail(bindingError); + } + } + if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct)) { return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); @@ -81,7 +94,8 @@ public sealed partial class FleetEnrollmentService nowMs, token.Kind, tlsPublicKey, - token.CreatedBy); + token.CreatedBy, + memberProof); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) @@ -102,6 +116,50 @@ public sealed partial class FleetEnrollmentService }); } + /// 节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。 + public async ValueTask> GetTrustAsync(string joinToken, CancellationToken ct) + { + JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct); + long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses) + { + return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); + } + + if (token.ExpiresAtMs <= nowMs) + { + return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); + } + + var bundle = new TrustBundle { Grant = token.Grant }; + foreach (IOperatorTrustSource source in _trust) + { + bundle.Chain.AddRange(await source.ChainAsync(ct)); + } + + return Result.Ok(bundle); + } + + private async ValueTask> VerifyBindingAsync(JoinTokenRecord token, EnrollRequest request, byte[] publicKey, byte[] tlsPublicKey, CancellationToken ct) + { + IReadOnlyList chain = _trust.Any() ? await _trust.First().ChainAsync(ct) : []; + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + if (current is null || string.IsNullOrEmpty(request.Binding) || string.IsNullOrEmpty(request.BindingSignature)) + { + return Result.Fail(FleetErrors.ProofInvalid, "this join code carries an operator grant: the enrollment needs a binding signed with the code key"); + } + + string role = token.Kind.Length == 0 ? "agent" : token.Kind; + var proof = new MemberProofDto { Grant = token.Grant!, Binding = request.Binding, BindingSignature = request.BindingSignature }; + TrustFailure failure = OperatorTrust.VerifyMember(current, proof.ToProof(), publicKey, tlsPublicKey, role); + if (failure != TrustFailure.None || OperatorTrust.ParseBinding(request.Binding)?.Name != request.Name) + { + return Result.Fail(FleetErrors.ProofInvalid, "the binding does not verify against the operator's grant (" + failure + ")"); + } + + return Result.Ok(proof); + } + private static bool TryDecode(string text, out byte[] data) { try diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index 3c31989..c2610d2 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -12,7 +12,7 @@ string? name = Arg("--name"); string? directory = Arg("--identity-dir"); if (server is null || join is null || name is null || directory is null) { - Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ]"); + Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--role agent|probe --seed --genesis ]"); return 2; } @@ -30,14 +30,18 @@ if (Arg("--meta") is { } metaText) } } +string? seed = Arg("--seed"); +string? genesis = Arg("--genesis"); +string role = Arg("--role") ?? "agent"; + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( - http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None); + http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, seed, genesis, role); if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error)) { Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}"); return 1; } -Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}"); +Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)")); return 0; diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 524b140..c766cf4 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -3887,6 +3887,190 @@ "x-moshiro-capability": "audit.read" } }, + "/api/admin/trust": { + "get": { + "tags": [ + "admin-trust" + ], + "summary": "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", + "operationId": "trust_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustStateDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.read" + }, + "post": { + "tags": [ + "admin-trust" + ], + "summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", + "operationId": "trust_commit", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignedStatementDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustStateDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "accounts.manage" + } + }, + "/api/admin/join-tokens/signed": { + "post": { + "tags": [ + "admin-trust" + ], + "summary": "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", + "operationId": "join_tokens_create_signed", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedJoinTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JoinTokenDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.enroll" + } + }, "/api/admin/api-tokens": { "get": { "tags": [ @@ -4959,6 +5143,20 @@ } } } + }, + "/api/agent/v1/enroll/trust": { + "post": { + "tags": [ + "agent" + ], + "summary": "The operator set chain and the grant of a join token, for the node to verify before it enrolls", + "operationId": "fleet_enroll_trust", + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -5820,6 +6018,41 @@ } } }, + "CreateSignedJoinTokenRequest": { + "required": [ + "grant", + "note", + "labels", + "auto_approve" + ], + "type": "object", + "properties": { + "grant": { + "$ref": "#/components/schemas/SignedStatementDto" + }, + "note": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "auto_approve": { + "type": [ + "null", + "boolean" + ] + } + } + }, "CreateTaskRequest": { "required": [ "scope", @@ -6681,6 +6914,10 @@ "null", "string" ] + }, + "signed": { + "type": "boolean", + "default": false } } }, @@ -8733,6 +8970,26 @@ } } }, + "SignedStatementDto": { + "type": "object", + "properties": { + "statement": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, "StartTestRequestDto": { "required": [ "plan", @@ -9084,6 +9341,72 @@ } } }, + "TrustProposalDto": { + "required": [ + "statement", + "version", + "challenge" + ], + "type": "object", + "properties": { + "statement": { + "type": "string" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "challenge": { + "type": "string" + } + } + }, + "TrustStateDto": { + "required": [ + "initialized", + "genesis", + "version", + "in_sync", + "proposal" + ], + "type": "object", + "properties": { + "initialized": { + "type": "boolean" + }, + "genesis": { + "type": [ + "null", + "string" + ] + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "in_sync": { + "type": "boolean" + }, + "proposal": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TrustProposalDto" + } + ] + } + } + }, "UserDto": { "required": [ "id", @@ -9142,6 +9465,9 @@ { "name": "admin-audit" }, + { + "name": "admin-trust" + }, { "name": "admin-api-tokens" }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 622d760..bbe039a 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -116,7 +116,7 @@ public static class AuthEndpoints } /// 这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。 - private static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) + internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) { string own = $"{http.Request.Scheme}://{http.Request.Host}"; Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null; diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs new file mode 100644 index 0000000..7c1d855 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -0,0 +1,74 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Api; + +/// +/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它; +/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。 +/// +public static class TrustEndpoints +{ + private const string Tag = "admin-trust"; + + public static void Map(RouteGroupBuilder guarded) + { + guarded.MapGet("/trust", StateAsync) + .Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); + guarded.MapPost("/trust", CommitAsync) + .Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.AccountsManage) + .ElevationExempt(); + guarded.MapPost("/join-tokens/signed", CreateSignedTokenAsync) + .Api("join_tokens_create_signed", "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.FleetEnroll) + .ElevationExempt(); + } + + private static async Task StateAsync(HttpContext http, TrustService trust, BackendOptions options) + { + TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto); + } + + private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, BackendOptions options) + { + PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options); + // 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根 + if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; + } + + Result result = await trust.CommitAsync(signed, context, http.RequestAborted); + if (!result.TryGet(out TrustState? state, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.SetDetail($"operator set v{state.Version}"); + return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens) + { + Result result = await tokens.CreateSignedAsync(request.Grant, request.Note, request.Labels, request.AutoApprove ?? true, http.GetPrincipal().Label, http.RequestAborted); + if (!result.TryGet(out JoinTokenInfo? info, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.SetDetail($"{JoinTokenService.RoleOf(info.Kind)}: {request.Note}"); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(Mappers.ToDto(info), ApiJsonContext.Default.JoinTokenDto, statusCode: StatusCodes.Status201Created); + } +} diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 8a9da2d..f064f0e 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -112,6 +112,7 @@ public static class ApiModule TaskEndpoints.Map(guarded); DataEndpoints.Map(guarded); AuditEndpoints.Map(guarded); + TrustEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); LinkEndpoints.Map(guarded); diff --git a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs index aec509b..8def48a 100644 --- a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs +++ b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs @@ -18,6 +18,11 @@ public static class EndpointConventions return Task.CompletedTask; }); + /// + /// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。 + /// + public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker()); + /// 匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。 public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) => builder.WithMetadata(new CapabilityRequirement(null)); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 221c1cf..3af9919 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -60,6 +60,9 @@ public static class PrincipalResolver /// public sealed record CapabilityRequirement(string? Capability); +/// 端点自己验 passkey 签名(见 )。 +public sealed record ElevationExemptMarker; + /// 管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 public static class CapabilityGuard { @@ -90,7 +93,7 @@ public static class CapabilityGuard } // 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里 - if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && ElevationGate.Require(http, elevation, consume: true) is { } needed) + if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed) { return needed; } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index d7e8afb..c776cd9 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -154,7 +154,9 @@ public static class Mappers new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); public static JoinTokenDto ToDto(JoinTokenInfo t) => - new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy, t.Grant is not null); + + public static TrustStateDto ToDto(TrustState s) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge)); public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs)); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 80fe143..cf66d62 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -1,3 +1,4 @@ +using Fleet.Core; using System.Text.Json; namespace MoShiro.Backend.Api; @@ -73,13 +74,23 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); /// agent 或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null); +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null, bool Signed = false); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null); /// 令牌明文只在创建的响应里出现一次。 public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); +/// 操作员用 passkey 对这个码签的委托(语句加断言)。 +public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList? Labels, bool? AutoApprove); + +public sealed record TrustProposalDto(string Statement, int Version, string Challenge); + +/// 创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。 +/// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。 +/// 下一个要签的版本(challenge 就是 WebAuthn 的挑战)。 +public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal); + public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); /// Agent 眼中连着的一个 Probe(Agent 上报)。 diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 7238f19..536dd9a 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -79,6 +79,9 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PasskeyRequestOptionsDto))] [JsonSerializable(typeof(RegisterPasskeyRequest))] [JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")] +[JsonSerializable(typeof(CreateSignedJoinTokenRequest))] +[JsonSerializable(typeof(TrustStateDto))] [JsonSerializable(typeof(ElevationRequest))] [JsonSerializable(typeof(ElevationAssertionRequest))] [JsonSerializable(typeof(ElevationDto))] diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index f9c2f34..ee17437 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -1,4 +1,6 @@ using Fleet.Core; +using Fleet.Identity; +using MoShiro.Protocol; namespace MoShiro.Backend.Application; @@ -25,12 +27,14 @@ public sealed class JoinTokenService public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind; private readonly IJoinTokenStore _store; + private readonly TrustService _trust; private readonly BackendOptions _options; private readonly TimeProvider _time; - public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time) + public JoinTokenService(IJoinTokenStore store, TrustService trust, BackendOptions options, TimeProvider time) { _store = store; + _trust = trust; _options = options; _time = time; } @@ -72,7 +76,7 @@ public sealed class JoinTokenService long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); JoinTokenInfo info = await _store.CreateAsync( Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), - request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); + request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, null, ct); string baseUrl = BaseUrl(requestBaseUrl); string query = $"{baseUrl}/install?code={plaintext}"; @@ -82,6 +86,71 @@ public sealed class JoinTokenService $"irm '{baseUrl}/install.ps1?code={plaintext}' | iex"))); } + /// + /// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子, + /// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。 + /// + public async ValueTask> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList? labels, bool autoApprove, string? createdBy, CancellationToken ct) + { + OperatorSet? set = await _trust.CurrentAsync(ct); + if (set is null) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "set up the operator trust root first (System, Trust)"); + } + + Grant? grant = OperatorTrust.ParseGrant(signedGrant.Statement); + if (grant is null || !OperatorTrust.VerifyOperator(signedGrant.ToSigned(), set)) + { + return Result.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey"); + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + var errors = new Dictionary(); + if (grant.Role is not (AgentRole or ProbeRole)) + { + errors["role"] = "must be agent or probe"; + } + + if (grant.Uses is < 1 or > 1000) + { + errors["uses"] = "must be between 1 and 1000"; + } + + if (grant.NotAfterMs <= now || grant.NotAfterMs - now > 24 * 365 * 3_600_000L || grant.NotBeforeMs > now + 300_000) + { + errors["validity"] = "the grant must be valid now and for at most one year"; + } + + if (!System.Text.RegularExpressions.Regex.IsMatch(grant.AuthHash, "^[0-9a-f]{64}$")) + { + errors["auth"] = "must be a lowercase sha-256 hex"; + } + + if (labels is { } l && (l.Count > 16 || l.Any(x => x.Length is 0 or > 32))) + { + errors["labels"] = "at most 16 labels of 1-32 characters"; + } + + if (note is { Length: > 200 }) + { + errors["note"] = "must be at most 200 characters"; + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); + } + + byte[] hash = Convert.FromHexString(grant.AuthHash); + if (await _store.FindAsync(hash, ct) is not null) + { + return Result.Fail(MoShiroErrors.NameConflict, "this code already exists"); + } + + return Result.Ok(await _store.CreateAsync( + Ulid.NewPrefixed("jtk", _time), hash, note, labels ?? [], autoApprove, KindOf(grant.Role), grant.Uses, grant.NotAfterMs, now, createdBy, signedGrant, ct)); + } + private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); /// diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 2ad27e3..ba8fbe9 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs new file mode 100644 index 0000000..0fc0e89 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Server; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// 要操作员用 passkey 签的下一版操作员集合:Challenge 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。 +public sealed record TrustProposal(string Statement, int Version, string Challenge); + +/// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 +public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal); + +/// +/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。 +/// Backend 不持有任何签名的能力:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链, +/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。 +/// +public sealed class TrustService : IOperatorTrustSource, IDisposable +{ + private readonly IOperatorSetStore _store; + private readonly IAuthStore _auth; + private readonly TimeProvider _time; + private readonly SemaphoreSlim _gate = new(1, 1); + + public TrustService(IOperatorSetStore store, IAuthStore auth, TimeProvider time) + { + _store = store; + _auth = auth; + _time = time; + } + + public void Dispose() => _gate.Dispose(); + + public ValueTask> ChainAsync(CancellationToken ct) => _store.ListAsync(ct); + + /// 当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。 + public async ValueTask CurrentAsync(CancellationToken ct) + { + IReadOnlyList chain = await _store.ListAsync(ct); + return chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + } + + public async ValueTask StateAsync(PasskeyContext context, CancellationToken ct) + { + IReadOnlyList chain = await _store.ListAsync(ct); + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + TrustProposal? proposal = await ProposeAsync(chain, current, context, ct); + OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement); + bool inSync = current is not null && proposed is not null && Same(current, proposed); + return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal); + } + + private async ValueTask ProposeAsync(IReadOnlyList chain, OperatorSet? current, PasskeyContext context, CancellationToken ct) + { + IReadOnlyList passkeys = await _auth.ListAllPasskeysAsync(ct); + if (passkeys.Count == 0) + { + return null; + } + + var next = new OperatorSet( + (current?.Version ?? 0) + 1, + chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement), + context.RpId, + [.. context.Origins.Order(StringComparer.Ordinal)], + [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))]); + string statement = OperatorTrust.Encode(next); + return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement)))); + } + + private static bool Same(OperatorSet a, OperatorSet b) => + a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal)) + && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal)); + + /// + /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 + /// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。 + /// + public async ValueTask> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct) + { + await _gate.WaitAsync(ct); + try + { + IReadOnlyList chain = await _store.ListAsync(ct); + OperatorSet? set = OperatorTrust.ParseSet(signed.Statement); + if (set is null) + { + return ValidationError.Fail("statement", "is not an operator set"); + } + + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + if (set.Version != (current?.Version ?? 0) + 1) + { + return Result.Fail(MoShiroErrors.NameConflict, $"the operator set is at version {current?.Version ?? 0}; sign version {(current?.Version ?? 0) + 1}"); + } + + if (set.RpId != context.RpId || set.Origins.Any(o => !context.Origins.Contains(o))) + { + return ValidationError.Fail("statement", "the relying party or origins do not match this panel"); + } + + IReadOnlyList registered = await _auth.ListAllPasskeysAsync(ct); + if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki)))) + { + return ValidationError.Fail("statement", "lists a passkey that is not registered on this panel"); + } + + List extended = [.. chain.Select(c => c.ToSigned()), signed.ToSigned()]; + string genesis = OperatorTrust.Fingerprint(extended[0].Statement); + if (OperatorTrust.VerifyChain(genesis, extended) is not { } verified || verified.Version != set.Version) + { + return Result.Fail(MoShiroErrors.Forbidden, "the operator set is not signed by a passkey of the previous version"); + } + + if (!await _store.AddAsync(set.Version, signed, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct)) + { + return Result.Fail(MoShiroErrors.NameConflict, "this operator set version already exists"); + } + } + finally + { + _gate.Release(); + } + + return Result.Ok(await StateAsync(context, ct)); + } +} diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 33ef79e..62871d1 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -30,7 +30,8 @@ public sealed record AgentRecord( bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, byte[]? TlsPublicKey = null, - string? AddedBy = null); + string? AddedBy = null, + MemberProofDto? Proof = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -78,10 +79,11 @@ public sealed record JoinTokenInfo( long? RevokedAtMs, long CreatedAtMs, string Kind = "", - string? CreatedBy = null); + string? CreatedBy = null, + SignedStatementDto? Grant = null); /// /// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; /// Agent 的 peers 里放的是后者。 /// -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null); +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null, MemberProofDto? Proof = null); diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 4ec3401..5212f36 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -38,6 +38,9 @@ public interface IAuthStore ValueTask> ListPasskeysAsync(string userId, CancellationToken ct); + /// 所有用户的 passkey(操作员集合从这里取)。 + ValueTask> ListAllPasskeysAsync(CancellationToken ct); + /// 只能删自己的。 ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 15d72ee..c9605a2 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -54,7 +54,7 @@ public interface IGeoLookup public interface IJoinTokenStore { - ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct); + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct); ValueTask> ListAsync(CancellationToken ct); @@ -144,6 +144,15 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +/// 操作员集合的链(passkey 签的版本,按版本号递增)。 +public interface IOperatorSetStore +{ + ValueTask> ListAsync(CancellationToken ct); + + /// 追加一个版本;版本号已经有了返回 false。 + ValueTask AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct); +} + public interface IAuditStore { ValueTask AddAsync(AuditRecord record, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 1f03d07..ad26ebd 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -206,6 +206,21 @@ internal static class Schema ) """); connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)"); + connection.Exec( + """ + CREATE TABLE operator_sets ( + version INTEGER PRIMARY KEY, + statement TEXT NOT NULL, + credential_id TEXT NOT NULL, + authenticator_data TEXT NOT NULL, + client_data_json TEXT NOT NULL, + signature TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """); + connection.Exec("ALTER TABLE join_tokens ADD COLUMN op_grant TEXT"); + connection.Exec("ALTER TABLE agents ADD COLUMN trust TEXT"); + connection.Exec("ALTER TABLE probes ADD COLUMN trust TEXT"); connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 6f13a7f..a3b3f31 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, trust"; private readonly SqliteDatabase _db; @@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore byte[] hash = tokenSha256.ToArray(); return _db.ReadAsync( c => c.QueryOne( - "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)), + "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11), StorageJson.Grant(r.NullableString(12))), ("@h", hash)), ct); } @@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)", + "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by, trust) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by, @trust)", ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), - ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy)); + ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof))); return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); @@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy)); + "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by, trust) VALUES (@id, @name, @pubkey, @peer, @now, @by, @trust)", + ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof))); return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); } @@ -268,7 +268,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), r.NullableBytes(25), - r.NullableString(26)); + r.NullableString(26), + StorageJson.Proof(r.NullableString(27))); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index 1fccdb6..7b1a46f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -83,6 +83,9 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask> ListPasskeysAsync(string userId, CancellationToken ct) => _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct); + public ValueTask> ListAllPasskeysAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys ORDER BY created_at, id", ReadPasskey), ct); + public ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) => _db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 1244c2d..a18356a 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db = db; } - public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) => + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct) => _db.WriteAsync(c => { c.Exec( - "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)", + "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by, op_grant) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by, @grant)", ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind), - ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy)); - return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy); + ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy), ("@grant", StorageJson.ToJson(grant))); + return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy, grant); }, ct); public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( c => c.Query( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens ORDER BY created_at DESC, id", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11)))), ct); public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync( c => c.QueryOne( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11))), ("@h", tokenSha256)), ct); @@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))), + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8)))), ct); public ValueTask GetAsync(string id, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)), + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8))), ("@id", id)), ct); public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs new file mode 100644 index 0000000..5aa0dbd --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs @@ -0,0 +1,28 @@ +using Fleet.Core; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteOperatorSetStore : IOperatorSetStore +{ + private readonly SqliteDatabase _db; + + public SqliteOperatorSetStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT statement, credential_id, authenticator_data, client_data_json, signature FROM operator_sets ORDER BY version", + r => new SignedStatementDto { Statement = r.GetString(0), CredentialId = r.GetString(1), AuthenticatorData = r.GetString(2), ClientDataJson = r.GetString(3), Signature = r.GetString(4) }), + ct); + + public ValueTask AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "INSERT OR IGNORE INTO operator_sets (version, statement, credential_id, authenticator_data, client_data_json, signature, created_at) VALUES (@v, @s, @c, @a, @d, @g, @at)", + ("@v", version), ("@s", signed.Statement), ("@c", signed.CredentialId), ("@a", signed.AuthenticatorData), ("@d", signed.ClientDataJson), ("@g", signed.Signature), ("@at", nowMs)) == 1, + ct); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs index 51b9490..996c734 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs @@ -1,9 +1,12 @@ using System.Text.Json; +using Fleet.Core; using System.Text.Json.Serialization; namespace MoShiro.Backend.Storage.Sqlite; [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SignedStatementDto))] +[JsonSerializable(typeof(MemberProofDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ParamMap")] internal sealed partial class StorageJson : JsonSerializerContext @@ -15,6 +18,14 @@ internal sealed partial class StorageJson : JsonSerializerContext public static List Strings(string? json) => string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? []; + public static string? ToJson(SignedStatementDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.SignedStatementDto); + + public static string? ToJson(MemberProofDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.MemberProofDto); + + public static SignedStatementDto? Grant(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.SignedStatementDto); + + public static MemberProofDto? Proof(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.MemberProofDto); + public static List Ints(string? json) => string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? []; } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 63b4f1a..5370a80 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index 61ac795..d8ef4b8 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable var tokens = new SqliteJoinTokenStore(_db); var fleet = new SqliteAgentStore(_db); byte[] hash = TokenHash.Sha256("jt_race"); - JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", grant: null, default); Assert.Equal(0, created.Uses); bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); @@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); - JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, grant: null, default); Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs new file mode 100644 index 0000000..36c9cb1 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Enrollment.Client; +using Fleet.Hosting; +using Fleet.Identity; + +namespace MoShiro.Backend.Tests; + +/// +/// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。 +/// 设计见 docs/access.md 第 7 节。 +/// +[Collection("probe-e2e")] +public sealed class TrustTests +{ + private const string Password = "correct horse battery"; + private static readonly ConditionalWeakTable s_operators = []; + private static readonly SemaphoreSlim s_gate = new(1, 1); + + private readonly BackendFixture _backend; + + public TrustTests(BackendFixture backend) + { + _backend = backend; + } + + /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 + private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private async Task OperatorAsync() + { + await s_gate.WaitAsync(); + try + { + if (s_operators.TryGetValue(_backend, out Operator? existing)) + { + return existing; + } + + string name = _backend.UniqueName("operator"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var session = _backend.NewClient(); + using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); + session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + + var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); + using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null)) + { + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register("operator", challenge))); + Assert.Equal(HttpStatusCode.Created, registered.StatusCode); + } + + // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 + await ElevateAsync(session, passkey); + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + Assert.False(state["initialized"]!.GetValue()); + string statement = state["proposal"]!["statement"]!.GetValue(); + Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue()); + using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement))); + Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync()); + string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue(); + Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint); + + var made = new Operator(session, passkey, fingerprint); + s_operators.Add(_backend, made); + return made; + } + finally + { + s_gate.Release(); + } + } + + private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。 + private static JsonObject Sign(SoftwarePasskey passkey, string statement) + { + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + return new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + } + + private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" })); + JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); + assertion["class"] = "credential"; + using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + } + + /// 浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。 + private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null) + { + using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); + byte[] seed = code.ExportParameters(true).D!; + string auth = OperatorTrust.AuthToken(seed); + long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1)); + using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" })); + return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode); + } + + private async Task> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null) + { + directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + using var http = new HttpClient(); + return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role); + } + + [Fact] + public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof() + { + Operator op = await OperatorAsync(); + (string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op); + Assert.Equal(HttpStatusCode.Created, status); + + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); + Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); + Assert.True(enrolled.Value.Pinned); + + // 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明 + TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); + Assert.True(anchor.Enforcing); + Assert.Equal(op.Genesis, anchor.Genesis); + using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); + Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe")); + Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe")); + Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe")); + + // 面板上这个码标记为已签名 + JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!; + Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue() && t["note"]?.GetValue() == "trust test"); + } + + [Fact] + public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable() + { + Operator op = await OperatorAsync(); + (string seed, string auth, _) = await CreateSignedCodeAsync(op); + + Result wrong = await EnrollAsync(auth, seed, new string('0', 64)); + Assert.False(wrong.IsSuccess); + Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal); + + Result right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗 + Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString()); + } + + [Fact] + public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn() + { + Operator op = await OperatorAsync(); + (string seed, string auth, _) = await CreateSignedCodeAsync(op); + + // 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定 + Result withoutSeed = await EnrollAsync(auth, null, null); + Assert.False(withoutSeed.IsSuccess); + + // 种子不是这个码的:节点自己就不干 + (string otherSeed, _, _) = await CreateSignedCodeAsync(op); + Result mismatched = await EnrollAsync(auth, otherSeed, op.Genesis); + Assert.False(mismatched.IsSuccess); + + Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess); + } + + [Fact] + public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused() + { + Operator op = await OperatorAsync(); + + (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent"); + Result asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的 + Assert.False(asProbe.IsSuccess); + + (_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000); + Assert.Equal(HttpStatusCode.BadRequest, expired); + + using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托 + (_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger); + Assert.Equal(HttpStatusCode.Forbidden, foreign); + } + + [Fact] + public async Task TheGenesis_IsSetOnce_AndNeedsTheOperatorsSignature() + { + Operator op = await OperatorAsync(); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.True(state["initialized"]!.GetValue()); + Assert.Equal(op.Genesis, state["genesis"]!.GetValue()); + + // 没有操作员签名的语句:不收 + JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA"); + unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上 + using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); + Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); + + // 匿名和只有 API key 的调用也没有可能立根 + using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + } +}