Files
MoShiro/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
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]
2026-10-08 03:21:44 +08:00

83 lines
2.7 KiB
C#

using System.Security.Cryptography;
using Fleet.Identity;
using Fleet.Server;
namespace MoShiro.Backend.Application;
/// <summary>
/// 服务器的身份密钥(P-256):操作员用 passkey 把公钥签进操作员集合,之后节点只认带这把密钥签名的服务器,
/// 并在这条链路上用应用层加密(TLS 在 Cloudflare 或被冒充的证书那里终止时,中间人拿不出这把密钥)。
/// 私钥存在 Backend 的设置里:它保护的是「面板被冒充」,不是「面板被攻破」;面板被攻破时它也会泄露,那时要由 passkey 签一个换掉它的新集合版本。
/// </summary>
public sealed class ServerIdentityService : IServerIdentity, IDisposable
{
private const string Key = "server.identity_key";
private readonly ISettingsStore _settings;
private readonly object _gate = new();
private ECDsa? _key;
private byte[]? _spki;
public ServerIdentityService(ISettingsStore settings)
{
_settings = settings;
}
public byte[] PublicKeySpki
{
get
{
EnsureLoaded();
return _spki!;
}
}
/// <summary>公钥的指纹(SHA-256,小写十六进制):页面上给人核对。</summary>
public string Fingerprint => Convert.ToHexStringLower(SHA256.HashData(PublicKeySpki));
public byte[] Sign(ReadOnlySpan<byte> payload)
{
EnsureLoaded();
return _key!.SignData(payload, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
/// <summary>启动时调用:读出或生成密钥。节点可能在启动完成之前就连上来,所以 <see cref="Sign"/> 也会按需加载。</summary>
public ValueTask EnsureAsync(CancellationToken ct)
{
EnsureLoaded(ct);
return ValueTask.CompletedTask;
}
private void EnsureLoaded(CancellationToken ct = default)
{
if (_key is not null)
{
return;
}
lock (_gate)
{
if (_key is not null)
{
return;
}
string? stored = _settings.GetAsync(Key, ct).AsTask().GetAwaiter().GetResult();
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
if (stored is not null)
{
key.ImportPkcs8PrivateKey(Convert.FromBase64String(stored), out _);
}
else
{
_settings.SetAsync(Key, Convert.ToBase64String(key.ExportPkcs8PrivateKey()), ct).AsTask().GetAwaiter().GetResult();
}
_spki = key.ExportSubjectPublicKeyInfo();
_key = key;
}
}
public void Dispose() => _key?.Dispose();
}