using System.Buffers.Text; using System.Globalization; using System.Security.Cryptography; using System.Text; namespace Fleet.Identity; /// passkey(WebAuthn)对一段文本签出的断言:challenge = SHA-256(Statement 的 UTF-8)。四个字段都是 base64url。 public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); /// 一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。 public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki); /// /// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。 /// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。 /// /// 服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名, /// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。 public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys, IReadOnlyList? Servers = null); /// /// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。 /// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本, /// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。 /// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本。 /// public static class OperatorTrust { public const string SetHeader = "moshiro-opset-v1"; public const string Genesis = "0"; 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)); } foreach (byte[] server in set.Servers ?? []) { text.Append("\nserver=").Append(Convert.ToBase64String(server)); } return text.ToString(); } /// 严格解析:多一行、少一行、顺序不对都算坏。 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(); var servers = new List(); foreach (string line in lines.Skip(5)) { if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki)) { keys.Add(new OperatorKey(value[..split], spki)); } else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey)) { servers.Add(serverKey); } else { return null; } } return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers); } /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。 public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); /// /// 验证操作员集合的链:第一项是创世版本(指纹等于 ,自签),其后每一项由上一版里的凭证签名、版本加一、prev 指向上一版的指纹、域名不变。 /// 返回最后一版;不对返回 null。 /// public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList 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; } /// 断言是不是这个集合里某把凭证对 Statement 签的(要求用户验证;挑战是语句的 SHA-256)。 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 _); } 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 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; } }