Files
MoShiro/backend/fleet/src/Fleet.Identity/OperatorTrust.cs
T

323 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Text;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
/// <summary>
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
/// </summary>
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys);
/// <summary>委托:操作员授权了一个加入码。<c>CodeKey</c> 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。</summary>
public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses);
/// <summary>节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。<c>AtMs</c> 必须落在委托的有效期内。</summary>
public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs);
/// <summary>一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。</summary>
public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature);
public enum TrustFailure
{
None,
Malformed,
BrokenChain,
GrantNotSigned,
GrantExpired,
BindingInvalid,
KeyMismatch,
RoleMismatch,
}
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。
/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。
/// 语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。
/// </summary>
public static class OperatorTrust
{
public const string SetHeader = "moshiro-opset-v1";
public const string GrantHeader = "moshiro-grant-v1";
public const string BindingHeader = "moshiro-binding-v1";
public const string Genesis = "0";
private const string AuthLabel = "moshiro-join-auth-v1";
// ───────────────────────── 编码 ─────────────────────────
public static string Encode(OperatorSet set)
{
var text = new StringBuilder();
text.Append(SetHeader).Append('\n')
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
.Append("prev=").Append(set.Previous).Append('\n')
.Append("rp=").Append(set.RpId).Append('\n')
.Append("origins=").Append(string.Join(',', set.Origins));
foreach (OperatorKey key in set.Keys)
{
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
}
return text.ToString();
}
public static string Encode(Grant grant) =>
string.Join(
'\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey),
"nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture));
public static string Encode(Binding binding) =>
string.Join(
'\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey),
"peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture));
// ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ─────────────────────────
public static OperatorSet? ParseSet(string text)
{
string[] lines = text.Split('\n');
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
{
return null;
}
var keys = new List<OperatorKey>();
foreach (string line in lines.Skip(5))
{
if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki))
{
return null;
}
keys.Add(new OperatorKey(value[..split], spki));
}
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys);
}
public static Grant? ParseGrant(string text)
{
string[] lines = text.Split('\n');
if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey)
|| !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count)
|| role.Length == 0 || auth.Length != 64)
{
return null;
}
return new Grant(role, auth, codeKey, notBefore, notAfter, count);
}
public static Binding? ParseBinding(string text)
{
string[] lines = text.Split('\n');
if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node)
|| !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64)
{
return null;
}
return new Binding(name, role, node, peerKey, auth, at);
}
// ───────────────────────── 指纹与验证 ─────────────────────────
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
/// <summary>
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
/// 返回最后一版;不对返回 null。
/// </summary>
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
{
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
{
return null;
}
OperatorSet? current = null;
string? previousFingerprint = null;
foreach (SignedStatement link in chain)
{
OperatorSet? next = ParseSet(link.Statement);
if (next is null)
{
return null;
}
if (current is null)
{
if (next.Version != 1 || !VerifyOperator(link, next))
{
return null;
}
}
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
{
return null;
}
current = next;
previousFingerprint = Fingerprint(link.Statement);
}
return current;
}
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
{
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
{
return false;
}
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
}
/// <summary>
/// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
/// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
/// </summary>
public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> nodePublicKey, ReadOnlySpan<byte> peerPublicKey, string role)
{
Grant? grant = ParseGrant(proof.Grant.Statement);
Binding? binding = ParseBinding(proof.BindingStatement);
if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature))
{
return TrustFailure.Malformed;
}
if (!VerifyOperator(proof.Grant, current))
{
return TrustFailure.GrantNotSigned;
}
if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs)
{
return TrustFailure.GrantExpired;
}
if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role)
{
return TrustFailure.KeyMismatch;
}
if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey))
{
return TrustFailure.KeyMismatch;
}
if (grant.Role != role)
{
return TrustFailure.RoleMismatch;
}
try
{
using ECDsa codeKey = ECDsa.Create();
codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _);
return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
? TrustFailure.None
: TrustFailure.BindingInvalid;
}
catch (CryptographicException)
{
return TrustFailure.BindingInvalid;
}
}
// ───────────────────────── 加入码的种子 ─────────────────────────
/// <summary>
/// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子,
/// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。
/// </summary>
public static string AuthToken(ReadOnlySpan<byte> seed) =>
"ja_" + Base64Url.EncodeToString(Derive(seed));
private static byte[] Derive(ReadOnlySpan<byte> seed)
{
byte[] output = new byte[32];
HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan<byte>.Empty, Encoding.UTF8.GetBytes(AuthLabel));
return output;
}
public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken)));
/// <summary>用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。</summary>
public static ECDsa? CodeSigningKey(ReadOnlySpan<byte> seed, ReadOnlySpan<byte> codeKeySpki)
{
try
{
using ECDsa publicOnly = ECDsa.Create();
publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _);
ECParameters parameters = publicOnly.ExportParameters(false);
parameters.D = seed.ToArray();
return ECDsa.Create(parameters);
}
catch (CryptographicException)
{
return null;
}
}
public static string SignBinding(ECDsa codeKey, Binding binding, out string statement)
{
statement = Encode(binding);
return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
}
// ───────────────────────── 小工具 ─────────────────────────
private static bool TryValue(string line, string key, out string value)
{
value = "";
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
{
return false;
}
value = line[(key.Length + 1)..];
return true;
}
private static bool TryLong(string line, string key, out long value)
{
value = 0;
return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
}
private static bool TryBase64(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
data = [];
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
}
}