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