feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP]

This commit is contained in:
justme committed 2026-10-07 23:19:59 +08:00
1 parent 321cacac32
commit fc2bbdcc6d
35 files changed
+1374 -40

No files matched your search

@@ -206,6 +206,21 @@ internal static class Schema
)
""");
connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)");
connection.Exec(
"""
CREATE TABLE operator_sets (
version INTEGER PRIMARY KEY,
statement TEXT NOT NULL,
credential_id TEXT NOT NULL,
authenticator_data TEXT NOT NULL,
client_data_json TEXT NOT NULL,
signature TEXT NOT NULL,
created_at INTEGER NOT NULL
)
""");
connection.Exec("ALTER TABLE join_tokens ADD COLUMN op_grant TEXT");
connection.Exec("ALTER TABLE agents ADD COLUMN trust TEXT");
connection.Exec("ALTER TABLE probes ADD COLUMN trust TEXT");
connection.Exec("PRAGMA user_version = 13");
transaction.Commit();
}
@@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
{
private const string AgentColumns =
"id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " +
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by";
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, trust";
private readonly SqliteDatabase _db;
@@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
byte[] hash = tokenSha256.ToArray();
return _db.ReadAsync(
c => c.QueryOne(
"SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h",
r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)),
"SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h",
r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11), StorageJson.Grant(r.NullableString(12))),
("@h", hash)),
ct);
}
@@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
}
c.Exec(
"INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)",
"INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by, trust) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by, @trust)",
("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki),
("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy));
("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof)));
return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
}, ct);
@@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
}
c.Exec(
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)",
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy));
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by, trust) VALUES (@id, @name, @pubkey, @peer, @now, @by, @trust)",
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof)));
return Result<NodeRecord>.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
}
@@ -268,7 +268,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
r.GetInt64(23) != 0,
StorageJson.Strings(r.GetString(24)),
r.NullableBytes(25),
r.NullableString(26));
r.NullableString(26),
StorageJson.Proof(r.NullableString(27)));
private static NodeRecord ToNode(AgentRecord a, string? metaJson) =>
new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
@@ -83,6 +83,9 @@ public sealed class SqliteAuthStore : IAuthStore
public ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<PasskeyRecord>>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct);
public ValueTask<IReadOnlyList<PasskeyRecord>> ListAllPasskeysAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<PasskeyRecord>>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys ORDER BY created_at, id", ReadPasskey), ct);
public ValueTask<bool> DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) =>
_db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct);
@@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
_db = db;
}
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) =>
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct) =>
_db.WriteAsync(c =>
{
c.Exec(
"INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)",
"INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by, op_grant) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by, @grant)",
("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind),
("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy));
return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy);
("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy), ("@grant", StorageJson.ToJson(grant)));
return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy, grant);
}, ct);
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
c => c.Query(
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id",
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))),
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens ORDER BY created_at DESC, id",
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11)))),
ct);
public ValueTask<JoinTokenInfo?> FindAsync(byte[] tokenSha256, CancellationToken ct) =>
_db.ReadAsync(
c => c.QueryOne(
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h",
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)),
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h",
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11))),
("@h", tokenSha256)),
ct);
@@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))),
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8)))),
ct);
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
_db.ReadAsync(
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)),
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8))), ("@id", id)),
ct);
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
@@ -0,0 +1,28 @@
using Fleet.Core;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Storage.Sqlite;
public sealed class SqliteOperatorSetStore : IOperatorSetStore
{
private readonly SqliteDatabase _db;
public SqliteOperatorSetStore(SqliteDatabase db)
{
_db = db;
}
public ValueTask<IReadOnlyList<SignedStatementDto>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<SignedStatementDto>>(
c => c.Query(
"SELECT statement, credential_id, authenticator_data, client_data_json, signature FROM operator_sets ORDER BY version",
r => new SignedStatementDto { Statement = r.GetString(0), CredentialId = r.GetString(1), AuthenticatorData = r.GetString(2), ClientDataJson = r.GetString(3), Signature = r.GetString(4) }),
ct);
public ValueTask<bool> AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct) =>
_db.WriteAsync(
c => c.Exec(
"INSERT OR IGNORE INTO operator_sets (version, statement, credential_id, authenticator_data, client_data_json, signature, created_at) VALUES (@v, @s, @c, @a, @d, @g, @at)",
("@v", version), ("@s", signed.Statement), ("@c", signed.CredentialId), ("@a", signed.AuthenticatorData), ("@d", signed.ClientDataJson), ("@g", signed.Signature), ("@at", nowMs)) == 1,
ct);
}
@@ -1,9 +1,12 @@
using System.Text.Json;
using Fleet.Core;
using System.Text.Json.Serialization;
namespace MoShiro.Backend.Storage.Sqlite;
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(SignedStatementDto))]
[JsonSerializable(typeof(MemberProofDto))]
[JsonSerializable(typeof(List<int>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "ParamMap")]
internal sealed partial class StorageJson : JsonSerializerContext
@@ -15,6 +18,14 @@ internal sealed partial class StorageJson : JsonSerializerContext
public static List<string> Strings(string? json) =>
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? [];
public static string? ToJson(SignedStatementDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.SignedStatementDto);
public static string? ToJson(MemberProofDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.MemberProofDto);
public static SignedStatementDto? Grant(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.SignedStatementDto);
public static MemberProofDto? Proof(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.MemberProofDto);
public static List<int> Ints(string? json) =>
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? [];
}
@@ -23,6 +23,7 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton<INodeMetaStore, SqliteNodeMetaStore>();
services.AddSingleton<IAuthStore, SqliteAuthStore>();
services.AddSingleton<IAuditStore, SqliteAuditStore>();
services.AddSingleton<IOperatorSetStore, SqliteOperatorSetStore>();
services.AddSingleton<IRawStore, SqliteRawStore>();
services.AddSingleton<IMetricStore, SqliteMetricStore>();
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();