Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP]
171 lines
6.4 KiB
C#
171 lines
6.4 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Fleet.Core;
|
|
using Fleet.Identity;
|
|
|
|
namespace Fleet.Hosting;
|
|
|
|
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
|
|
public sealed class TrustFile
|
|
{
|
|
[JsonPropertyName("genesis")]
|
|
public string Genesis { get; set; } = "";
|
|
|
|
[JsonPropertyName("chain")]
|
|
public List<SignedStatementDto> Chain { get; set; } = [];
|
|
|
|
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
|
|
[JsonPropertyName("controllers")]
|
|
public List<string> Controllers { get; set; } = [];
|
|
}
|
|
|
|
[JsonSerializable(typeof(TrustFile))]
|
|
public sealed partial class TrustFileContext : JsonSerializerContext;
|
|
|
|
/// <summary>
|
|
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
|
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
|
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
|
|
/// </summary>
|
|
public sealed class TrustAnchor : IServerVerifier
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
|
|
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
|
|
|
public bool Pinned => _file is not null;
|
|
|
|
public string? Genesis => _file?.Genesis;
|
|
|
|
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
|
|
public int Version
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _current?.Version ?? 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
|
|
public bool Required
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _current?.Servers is { Count: > 0 };
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
|
|
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
|
{
|
|
IReadOnlyList<byte[]> servers;
|
|
lock (_gate)
|
|
{
|
|
servers = _current?.Servers ?? [];
|
|
}
|
|
|
|
foreach (byte[] server in servers)
|
|
{
|
|
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
|
|
public IReadOnlyList<string> Controllers
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
|
|
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
|
|
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
|
|
|
|
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
|
|
public int Update(IReadOnlyList<SignedStatementDto>? 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;
|
|
}
|
|
}
|
|
}
|