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).
172 lines
7.8 KiB
C#
172 lines
7.8 KiB
C#
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>
|
||
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
|
||
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
|
||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||
|
||
/// <summary>
|
||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
|
||
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
|
||
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>();
|
||
var servers = new List<byte[]>();
|
||
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);
|
||
}
|
||
|
||
/// <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 _);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|