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
+33 -1
View File
@@ -32,6 +32,7 @@ internal static class Program
{
var security = await TestCredentialBoundaryAsync();
var supervisor = await TestSupervisorLifecycleAsync();
TestSecureConfigurationPersistence();
TestStructuredLogging();
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
@@ -74,16 +75,46 @@ internal static class Program
}
}
private static void TestSecureConfigurationPersistence()
{
var directory = Path.Combine(Path.GetTempPath(), $"dada-secure-config-{Guid.NewGuid():N}");
var path = Path.Combine(directory, "instance.json");
var email = "synthetic-admin@example.invalid";
var pepper = $"synthetic-pepper-{Guid.NewGuid():N}";
var digest = Convert.ToHexString(System.Security.Cryptography.HMACSHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(pepper),
System.Text.Encoding.UTF8.GetBytes(email)));
try
{
var store = new InstanceConfigurationStore(path);
store.Save(new InstanceConfiguration(1, 7, null, null, [digest], [digest]));
var raw = File.ReadAllText(path);
True(raw.Contains("\"secure_config_revision\": 7", StringComparison.Ordinal), "secure config revision field");
True(raw.Contains("\"schema_version\": 1", StringComparison.Ordinal), "secure config schema field");
False(raw.Contains(email, StringComparison.OrdinalIgnoreCase), "allowlist email persisted");
False(raw.Contains(pepper, StringComparison.Ordinal), "admin pepper persisted");
False(File.Exists(path + ".tmp"), "secure config temporary file residual");
var loaded = store.Load();
Equal(7, loaded.Revision, "secure config revision round trip");
EqualSequence(new[] { digest }, loaded.AdminRecoveryHashes, "secure config recovery marker");
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
}
}
private static async Task<object> TestCredentialBoundaryAsync()
{
var store = new TestCredentialStore();
var marker = $"wp0-{Guid.NewGuid():N}";
store.Write(CredentialCatalog.ApiResend, marker + "-mail");
store.Write(CredentialCatalog.ApiAmap, marker + "-map");
store.Write(CredentialCatalog.AdminPepper, marker + "-admin");
store.Write(CredentialCatalog.WorkerAiGateway, marker + "-ai");
var apiProbe = await LaunchCredentialProbeAsync(ChildRole.Api, store);
EqualSequence(new[] { CredentialCatalog.ApiAmap, CredentialCatalog.ApiResend }, apiProbe.Names.Order().ToArray(), "API credential scope");
EqualSequence(new[] { CredentialCatalog.AdminPepper, CredentialCatalog.ApiAmap, CredentialCatalog.ApiResend }, apiProbe.Names.Order().ToArray(), "API credential scope");
False(apiProbe.EnvironmentContainsMarker, "credential leaked into child environment");
False(apiProbe.ArgumentsContainMarker, "credential leaked into child arguments");
@@ -192,6 +223,7 @@ internal static class Program
var childStore = new TestCredentialStore();
childStore.Write(CredentialCatalog.ApiResend, $"probe-{Guid.NewGuid():N}-mail");
childStore.Write(CredentialCatalog.ApiAmap, $"probe-{Guid.NewGuid():N}-map");
childStore.Write(CredentialCatalog.AdminPepper, $"probe-{Guid.NewGuid():N}-admin");
var childStart = new ProcessStartInfo(Environment.ProcessPath!);
childStart.ArgumentList.Add("--managed-child");
var managed = await ManagedChildProcess.StartAsync(childStart, ChildRole.Api, childStore);
+1 -1
View File
@@ -20,7 +20,7 @@ internal static class CredentialCatalog
internal static IReadOnlyList<string> RequiredFor(ChildRole role) => role switch
{
ChildRole.Api => [ApiResend, ApiAmap],
ChildRole.Api => [ApiResend, ApiAmap, AdminPepper],
ChildRole.Worker => [WorkerAiGateway],
_ => throw new ArgumentOutOfRangeException(nameof(role)),
};
@@ -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);
@@ -60,6 +60,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
startInfo.WorkingDirectory = AppContext.BaseDirectory;
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
startInfo.ArgumentList.Add(entry);
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
child.StatusReceived += status =>