feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP]
This commit is contained in:
1 parent
321cacac32
commit
fc2bbdcc6d
35 files changed
+1374
-40
No files matched your search
@@ -116,7 +116,7 @@ public static class AuthEndpoints
|
||||
}
|
||||
|
||||
/// <summary>这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。</summary>
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
|
||||
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
|
||||
/// </summary>
|
||||
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<TrustStateDto>().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<TrustStateDto>(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<JoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.FleetEnroll)
|
||||
.ElevationExempt();
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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<IResult> 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<TrustState> 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<IResult> CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens)
|
||||
{
|
||||
Result<JoinTokenInfo> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,11 @@ public static class EndpointConventions
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
|
||||
/// </summary>
|
||||
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
@@ -60,6 +60,9 @@ public static class PrincipalResolver
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>端点自己验 passkey 签名(见 <see cref="EndpointConventions.ElevationExempt"/>)。</summary>
|
||||
public sealed record ElevationExemptMarker;
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
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<ElevationExemptMarker>() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed)
|
||||
{
|
||||
return needed;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||
|
||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> 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<string> 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<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
/// <param name="Grant">操作员用 passkey 对这个码签的委托(语句加断言)。</param>
|
||||
public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove);
|
||||
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
||||
|
||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子,
|
||||
/// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<JoinTokenInfo>> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList<string>? labels, bool autoApprove, string? createdBy, CancellationToken ct)
|
||||
{
|
||||
OperatorSet? set = await _trust.CurrentAsync(ct);
|
||||
if (set is null)
|
||||
{
|
||||
return Result<JoinTokenInfo>.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<JoinTokenInfo>.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey");
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var errors = new Dictionary<string, string>();
|
||||
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<JoinTokenInfo>(errors);
|
||||
}
|
||||
|
||||
byte[] hash = Convert.FromHexString(grant.AuthHash);
|
||||
if (await _store.FindAsync(hash, ct) is not null)
|
||||
{
|
||||
return Result<JoinTokenInfo>.Fail(MoShiroErrors.NameConflict, "this code already exists");
|
||||
}
|
||||
|
||||
return Result<JoinTokenInfo>.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('/');
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<TrustService>();
|
||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||
services.AddSingleton<ElevationService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
|
||||
public sealed record TrustProposal(string Statement, int Version, string Challenge);
|
||||
|
||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
|
||||
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。
|
||||
/// </summary>
|
||||
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<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
/// <summary>当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。</summary>
|
||||
public async ValueTask<OperatorSet?> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SignedStatementDto> 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<TrustState> StateAsync(PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SignedStatementDto> 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<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<PasskeyRecord> 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));
|
||||
|
||||
/// <summary>
|
||||
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||||
/// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
IReadOnlyList<SignedStatementDto> chain = await _store.ListAsync(ct);
|
||||
OperatorSet? set = OperatorTrust.ParseSet(signed.Statement);
|
||||
if (set is null)
|
||||
{
|
||||
return ValidationError.Fail<TrustState>("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<TrustState>.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<TrustState>("statement", "the relying party or origins do not match this panel");
|
||||
}
|
||||
|
||||
IReadOnlyList<PasskeyRecord> 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<TrustState>("statement", "lists a passkey that is not registered on this panel");
|
||||
}
|
||||
|
||||
List<SignedStatement> 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<TrustState>.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<TrustState>.Fail(MoShiroErrors.NameConflict, "this operator set version already exists");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
|
||||
return Result<TrustState>.Ok(await StateAsync(context, ct));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ public sealed record AgentRecord(
|
||||
bool Ipv6HostManual = false,
|
||||
IReadOnlyList<string>? ReportedAddresses = null,
|
||||
byte[]? TlsPublicKey = null,
|
||||
string? AddedBy = null);
|
||||
string? AddedBy = null,
|
||||
MemberProofDto? Proof = null);
|
||||
|
||||
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
|
||||
/// Agent 的 <c>peers</c> 里放的是后者。
|
||||
/// </summary>
|
||||
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);
|
||||
@@ -38,6 +38,9 @@ public interface IAuthStore
|
||||
|
||||
ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct);
|
||||
|
||||
/// <summary>所有用户的 passkey(操作员集合从这里取)。</summary>
|
||||
ValueTask<IReadOnlyList<PasskeyRecord>> ListAllPasskeysAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>只能删自己的。</summary>
|
||||
ValueTask<bool> DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ public interface IGeoLookup
|
||||
|
||||
public interface IJoinTokenStore
|
||||
{
|
||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct);
|
||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||
|
||||
@@ -144,6 +144,15 @@ public interface IEventStore
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的版本,按版本号递增)。</summary>
|
||||
public interface IOperatorSetStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<SignedStatementDto>> ListAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>追加一个版本;版本号已经有了返回 false。</summary>
|
||||
ValueTask<bool> AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IAuditStore
|
||||
{
|
||||
ValueTask AddAsync(AuditRecord record, CancellationToken ct);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<NodeRecord>.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<NodeRecord>.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);
|
||||
|
||||
@@ -83,6 +83,9 @@ public sealed class SqliteAuthStore : IAuthStore
|
||||
public ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<PasskeyRecord>>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<PasskeyRecord>> ListAllPasskeysAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<PasskeyRecord>>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys ORDER BY created_at, id", ReadPasskey), ct);
|
||||
|
||||
public ValueTask<bool> 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);
|
||||
|
||||
|
||||
@@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) =>
|
||||
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> 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<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
|
||||
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<JoinTokenInfo?> 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<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
|
||||
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<ProbeRecord?> 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) =>
|
||||
|
||||
@@ -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<IReadOnlyList<SignedStatementDto>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<SignedStatementDto>>(
|
||||
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<bool> 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);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoShiro.Backend.Storage.Sqlite;
|
||||
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(SignedStatementDto))]
|
||||
[JsonSerializable(typeof(MemberProofDto))]
|
||||
[JsonSerializable(typeof(List<int>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "ParamMap")]
|
||||
internal sealed partial class StorageJson : JsonSerializerContext
|
||||
@@ -15,6 +18,14 @@ internal sealed partial class StorageJson : JsonSerializerContext
|
||||
public static List<string> 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<int> Ints(string? json) =>
|
||||
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? [];
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<INodeMetaStore, SqliteNodeMetaStore>();
|
||||
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
||||
services.AddSingleton<IAuditStore, SqliteAuditStore>();
|
||||
services.AddSingleton<IOperatorSetStore, SqliteOperatorSetStore>();
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
||||
|
||||
Reference in new issue
Block a user