feat: implement TASK-WP1-04 admin security
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user