ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link, code comment and the protocol conformance test that read docs/protocol.md. - access.md and security.md rewritten to what is implemented: removed the unadopted member-proof chain, roles and recovery key that do not exist, the old implementation order and the budgets; gaps now carry a status. - enrollment, deployment, api: the install flow is the static, hash-checked script with environment variables; no default admin. - README and architecture: new topology and update-flow diagrams (operator passkey, CI signing, registry feed, auth and trust modules).
159 lines
9.7 KiB
C#
159 lines
9.7 KiB
C#
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, IReadOnlyList<ProposedCredential> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||
|
||
/// <summary>下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。</summary>
|
||
public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs);
|
||
|
||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
|
||
|
||
/// <summary>
|
||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
|
||
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/security/access.md 第 8 节。
|
||
/// </summary>
|
||
public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||
{
|
||
private readonly IOperatorSetStore _store;
|
||
private readonly IAuthStore _auth;
|
||
private readonly ServerIdentityService _server;
|
||
private readonly TimeProvider _time;
|
||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||
|
||
public TrustService(IOperatorSetStore store, IAuthStore auth, ServerIdentityService server, TimeProvider time)
|
||
{
|
||
_server = server;
|
||
_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);
|
||
// 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示
|
||
var details = new List<NodeOwner>();
|
||
foreach (OperatorKey key in current?.Keys ?? [])
|
||
{
|
||
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct);
|
||
string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||
details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account));
|
||
}
|
||
|
||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details);
|
||
}
|
||
|
||
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))],
|
||
[_server.PublicKeySpki]);
|
||
string statement = OperatorTrust.Encode(next);
|
||
HashSet<string> before = [.. current?.Keys.Select(k => k.CredentialId) ?? []];
|
||
HashSet<string> after = [.. next.Keys.Select(k => k.CredentialId)];
|
||
ProposedCredential[] added = [.. passkeys.Where(p => !before.Contains(p.Id)).Select(p => new ProposedCredential(p.Id, p.Name, p.CreatedAtMs))];
|
||
string[] removed = [.. before.Where(id => !after.Contains(id)).Order(StringComparer.Ordinal)];
|
||
bool serverChanged = current is not null && !(current.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((next.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))), added, removed, serverChanged);
|
||
}
|
||
|
||
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))
|
||
&& (a.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||
|
||
/// <summary>
|
||
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 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");
|
||
}
|
||
|
||
if (set.Servers is not { Count: > 0 } || !set.Servers.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)))
|
||
{
|
||
// 集合里没有这个面板的服务器密钥:签下去之后固定了它的节点就认不出这个面板了
|
||
return ValidationError.Fail<TrustState>("statement", "must list this panel's server key");
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|