feat: implement TASK-WP1-04 admin security

This commit is contained in:
suyx
2026-07-28 18:32:50 +08:00
parent 66fe3b763a
commit 03f1509de7
29 changed files with 2344 additions and 40 deletions
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Dada.Supervisor;
@@ -45,15 +46,15 @@ internal static class OfflineCommandRouter
InstanceConfiguration next;
if (args is ["init", var initialDataRoot, var initialAssetRoot])
{
next = new InstanceConfiguration(current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes);
next = new InstanceConfiguration(1, current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes, []);
}
else if (args is ["data-root", var updatedDataRoot])
{
next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot) };
next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot), AdminRecoveryHashes = [] };
}
else if (args is ["asset-root", var updatedAssetRoot])
{
next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot) };
next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot), AdminRecoveryHashes = [] };
}
else
{
@@ -93,7 +94,7 @@ internal static class OfflineCommandRouter
var current = configStore.Load();
if (args is ["status"])
{
WriteResult("allowlist_status", true, current.AdminAllowlistHashes.Count);
WriteAllowlistResult("allowlist_status", current.AdminAllowlistHashes.Count, current.Revision);
return 0;
}
if (args is not [var action, var email] || action is not ("add" or "remove")) return Usage();
@@ -103,8 +104,20 @@ internal static class OfflineCommandRouter
var digest = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(pepper), Encoding.UTF8.GetBytes(normalized)));
var hashes = current.AdminAllowlistHashes.ToHashSet(StringComparer.Ordinal);
if (action == "add") hashes.Add(digest); else hashes.Remove(digest);
configStore.Save(current with { Revision = current.Revision + 1, AdminAllowlistHashes = hashes.Order().ToArray() });
WriteResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", true, hashes.Count);
var recoveries = action == "add" ? new[] { digest } : Array.Empty<string>();
var orderedHashes = hashes.Order().ToArray();
if (action == "remove" && orderedHashes.SequenceEqual(current.AdminAllowlistHashes))
{
WriteAllowlistResult("allowlist_unchanged", orderedHashes.Length, current.Revision);
return 0;
}
configStore.Save(current with
{
Revision = current.Revision + 1,
AdminAllowlistHashes = orderedHashes,
AdminRecoveryHashes = recoveries,
});
WriteAllowlistResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", hashes.Count, current.Revision + 1);
return 0;
}
@@ -180,6 +193,9 @@ internal static class OfflineCommandRouter
private static void WriteResult(string code, bool success, int? revisionOrCount = null) =>
Console.WriteLine(JsonSerializer.Serialize(new { code, revision_or_count = revisionOrCount, success }, JsonOptions));
private static void WriteAllowlistResult(string code, int count, int revision) =>
Console.WriteLine(JsonSerializer.Serialize(new { code, count, secure_config_revision = revision, success = true }, JsonOptions));
private static int Usage()
{
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor", false);
@@ -187,9 +203,15 @@ internal static class OfflineCommandRouter
}
}
internal sealed record InstanceConfiguration(int Revision, string? LocalDataRoot, string? AssetRoot, IReadOnlyList<string> AdminAllowlistHashes)
internal sealed record InstanceConfiguration(
[property: JsonPropertyName("schema_version")] int SchemaVersion,
[property: JsonPropertyName("secure_config_revision")] int Revision,
string? LocalDataRoot,
string? AssetRoot,
IReadOnlyList<string> AdminAllowlistHashes,
IReadOnlyList<string> AdminRecoveryHashes)
{
internal static InstanceConfiguration Empty { get; } = new(0, null, null, []);
internal static InstanceConfiguration Empty { get; } = new(1, 0, null, null, [], []);
}
internal sealed class InstanceConfigurationStore
@@ -202,12 +224,34 @@ internal sealed class InstanceConfigurationStore
this.path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
}
internal InstanceConfiguration Load() => File.Exists(path)
? JsonSerializer.Deserialize<InstanceConfiguration>(File.ReadAllText(path), JsonOptions) ?? InstanceConfiguration.Empty
: InstanceConfiguration.Empty;
internal InstanceConfiguration Load()
{
if (!File.Exists(path)) return InstanceConfiguration.Empty;
var json = File.ReadAllText(path);
var configuration = JsonSerializer.Deserialize<InstanceConfiguration>(json, JsonOptions) ?? InstanceConfiguration.Empty;
using var document = JsonDocument.Parse(json);
if (configuration.Revision == 0 && document.RootElement.TryGetProperty("revision", out var legacyRevision))
{
configuration = configuration with { Revision = legacyRevision.GetInt32() };
}
return configuration with
{
SchemaVersion = configuration.SchemaVersion == 0 ? 1 : configuration.SchemaVersion,
AdminAllowlistHashes = configuration.AdminAllowlistHashes ?? [],
AdminRecoveryHashes = configuration.AdminRecoveryHashes ?? [],
};
}
internal void Save(InstanceConfiguration configuration)
{
if (configuration.SchemaVersion != 1 || configuration.Revision < 0) throw new InvalidOperationException("secure_config_invalid");
var validHash = new System.Text.RegularExpressions.Regex("^[A-F0-9]{64}$", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
if (configuration.AdminAllowlistHashes.Any(hash => !validHash.IsMatch(hash)) ||
configuration.AdminRecoveryHashes.Any(hash => !validHash.IsMatch(hash)) ||
configuration.AdminRecoveryHashes.Any(hash => !configuration.AdminAllowlistHashes.Contains(hash, StringComparer.Ordinal)))
{
throw new InvalidOperationException("secure_config_invalid");
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporary = path + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(configuration, JsonOptions) + Environment.NewLine);