diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index 50dc5b6..d2cf6ee 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -631,6 +631,23 @@ gRPC admin API. It should initialize the auth database, create keys, list keys without secrets, revoke keys, rotate keys, and print raw secrets only once at creation. +`MxGateway.Server` exposes local API-key administration as an `apikey` +subcommand before the web host starts: + +```bash +MxGateway.Server apikey init-db --sqlite-path C:\ProgramData\MxGateway\gateway-auth.db +MxGateway.Server apikey create-key --key-id operator01 --display-name Operator --scopes session:open,events:read +MxGateway.Server apikey list-keys --json +MxGateway.Server apikey revoke-key --key-id operator01 +MxGateway.Server apikey rotate-key --key-id operator01 --json +``` + +The subcommands accept `--sqlite-path`, `--pepper`, and `--json`. `--pepper` +sets the local `MxGateway:ApiKeyPepper` configuration value for the command +process; deployments should normally provide the pepper through the configured +secret source. `create-key` and `rotate-key` print the full raw API key exactly +once. `list-keys` never prints raw secrets or `secret_hash` values. + SQLite auth storage should use startup migrations with a `schema_version` table. Migrations should run inside transactions and fail startup if the database schema is newer than the running binary understands. diff --git a/src/MxGateway.Server/Program.cs b/src/MxGateway.Server/Program.cs index 1dd9812..609f6c4 100644 --- a/src/MxGateway.Server/Program.cs +++ b/src/MxGateway.Server/Program.cs @@ -1,7 +1,43 @@ using MxGateway.Server; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; -var app = GatewayApplication.Build(args); +ApiKeyAdminParseResult apiKeyAdminCommand = ApiKeyAdminCommandLineParser.Parse(args); +if (apiKeyAdminCommand.IsApiKeyCommand) +{ + if (apiKeyAdminCommand.Command is null) + { + await Console.Error.WriteLineAsync(apiKeyAdminCommand.Error); + return 2; + } + + WebApplicationBuilder builder = GatewayApplication.CreateBuilder([]); + ApplyApiKeyAdminOverrides(builder.Configuration, apiKeyAdminCommand.Command); + await using WebApplication cliApp = builder.Build(); + await using AsyncServiceScope scope = cliApp.Services.CreateAsyncScope(); + + ApiKeyAdminCliRunner runner = scope.ServiceProvider.GetRequiredService(); + + return await runner.RunAsync(apiKeyAdminCommand.Command, Console.Out, CancellationToken.None); +} + +WebApplication app = GatewayApplication.Build(args); app.Run(); +return 0; + +static void ApplyApiKeyAdminOverrides(IConfiguration configuration, ApiKeyAdminCommand command) +{ + if (!string.IsNullOrWhiteSpace(command.SqlitePath)) + { + configuration[$"{GatewayOptions.SectionName}:Authentication:SqlitePath"] = command.SqlitePath; + } + + if (!string.IsNullOrWhiteSpace(command.Pepper)) + { + configuration["MxGateway:ApiKeyPepper"] = command.Pepper; + } +} + public partial class Program; diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs new file mode 100644 index 0000000..828ec7e --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs @@ -0,0 +1,180 @@ +using System.Text.Json; + +namespace MxGateway.Server.Security.Authentication; + +public sealed class ApiKeyAdminCliRunner( + IAuthStoreMigrator migrator, + IApiKeyAdminStore adminStore, + IApiKeyAuditStore auditStore, + IApiKeySecretHasher hasher) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true + }; + + public async Task RunAsync( + ApiKeyAdminCommand command, + TextWriter output, + CancellationToken cancellationToken) + { + ApiKeyAdminOutput result = command.Kind switch + { + ApiKeyAdminCommandKind.InitDb => await InitDbAsync(cancellationToken).ConfigureAwait(false), + ApiKeyAdminCommandKind.CreateKey => await CreateKeyAsync(command, cancellationToken).ConfigureAwait(false), + ApiKeyAdminCommandKind.ListKeys => await ListKeysAsync(cancellationToken).ConfigureAwait(false), + ApiKeyAdminCommandKind.RevokeKey => await RevokeKeyAsync(command, cancellationToken).ConfigureAwait(false), + ApiKeyAdminCommandKind.RotateKey => await RotateKeyAsync(command, cancellationToken).ConfigureAwait(false), + _ => throw new InvalidOperationException($"Unsupported API key command '{command.Kind}'.") + }; + + await WriteOutputAsync(command, result, output).ConfigureAwait(false); + + return 0; + } + + private async Task InitDbAsync(CancellationToken cancellationToken) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false); + + return new ApiKeyAdminOutput("init-db", "initialized", null, []); + } + + private async Task CreateKeyAsync( + ApiKeyAdminCommand command, + CancellationToken cancellationToken) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + + string keyId = Required(command.KeyId); + string secret = ApiKeySecretGenerator.Generate(); + string apiKey = FormatApiKey(keyId, secret); + + await adminStore.CreateAsync( + new ApiKeyCreateRequest( + KeyId: keyId, + KeyPrefix: $"mxgw_{keyId}", + SecretHash: hasher.HashSecret(secret), + DisplayName: Required(command.DisplayName), + Scopes: command.Scopes, + CreatedUtc: DateTimeOffset.UtcNow), + cancellationToken) + .ConfigureAwait(false); + await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false); + + return new ApiKeyAdminOutput("create-key", "created", apiKey, []); + } + + private async Task ListKeysAsync(CancellationToken cancellationToken) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + IReadOnlyList keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false); + await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false); + + return new ApiKeyAdminOutput( + "list-keys", + "ok", + null, + keys.Select(ToListedKey).ToArray()); + } + + private async Task RevokeKeyAsync( + ApiKeyAdminCommand command, + CancellationToken cancellationToken) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + + string keyId = Required(command.KeyId); + bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken) + .ConfigureAwait(false); + + return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []); + } + + private async Task RotateKeyAsync( + ApiKeyAdminCommand command, + CancellationToken cancellationToken) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + + string keyId = Required(command.KeyId); + string secret = ApiKeySecretGenerator.Generate(); + string apiKey = FormatApiKey(keyId, secret); + + bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken) + .ConfigureAwait(false); + + return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []); + } + + private static async Task WriteOutputAsync( + ApiKeyAdminCommand command, + ApiKeyAdminOutput result, + TextWriter output) + { + if (command.Json) + { + await output.WriteLineAsync(JsonSerializer.Serialize(result, JsonOptions)).ConfigureAwait(false); + return; + } + + await output.WriteLineAsync($"{result.Command}: {result.Status}").ConfigureAwait(false); + + if (result.ApiKey is not null) + { + await output.WriteLineAsync($"API key: {result.ApiKey}").ConfigureAwait(false); + } + + foreach (ApiKeyAdminListedKey key in result.Keys) + { + string revoked = key.RevokedUtc is null ? "active" : "revoked"; + await output.WriteLineAsync($"{key.KeyId}\t{key.DisplayName}\t{revoked}\t{string.Join(',', key.Scopes)}") + .ConfigureAwait(false); + } + } + + private async Task AppendAuditAsync( + string? keyId, + string eventType, + string? details, + CancellationToken cancellationToken) + { + await auditStore.AppendAsync( + new ApiKeyAuditEntry( + KeyId: keyId, + EventType: eventType, + RemoteAddress: null, + Details: details), + cancellationToken) + .ConfigureAwait(false); + } + + private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key) + { + return new ApiKeyAdminListedKey( + KeyId: key.KeyId, + KeyPrefix: key.KeyPrefix, + DisplayName: key.DisplayName, + Scopes: key.Scopes, + CreatedUtc: key.CreatedUtc, + LastUsedUtc: key.LastUsedUtc, + RevokedUtc: key.RevokedUtc); + } + + private static string FormatApiKey(string keyId, string secret) + { + return $"mxgw_{keyId}_{secret}"; + } + + private static string Required(string? value) + { + return value ?? throw new InvalidOperationException("Required command value was not provided."); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs new file mode 100644 index 0000000..1337743 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyAdminCommand( + ApiKeyAdminCommandKind Kind, + bool Json, + string? SqlitePath, + string? Pepper, + string? KeyId, + string? DisplayName, + IReadOnlySet Scopes); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs new file mode 100644 index 0000000..d18392f --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandKind.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Server.Security.Authentication; + +public enum ApiKeyAdminCommandKind +{ + InitDb, + CreateKey, + ListKeys, + RevokeKey, + RotateKey +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs new file mode 100644 index 0000000..0384a8f --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs @@ -0,0 +1,159 @@ +namespace MxGateway.Server.Security.Authentication; + +public static class ApiKeyAdminCommandLineParser +{ + public static ApiKeyAdminParseResult Parse(IReadOnlyList args) + { + if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase)) + { + return ApiKeyAdminParseResult.NotApiKeyCommand(); + } + + if (args.Count < 2) + { + return ApiKeyAdminParseResult.Fail("Missing apikey subcommand."); + } + + if (!TryParseKind(args[1], out ApiKeyAdminCommandKind kind)) + { + return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'."); + } + + Dictionary options = new(StringComparer.OrdinalIgnoreCase); + bool json = false; + + for (int index = 2; index < args.Count; index++) + { + string arg = args[index]; + if (string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase)) + { + json = true; + continue; + } + + if (!arg.StartsWith("--", StringComparison.Ordinal)) + { + return ApiKeyAdminParseResult.Fail($"Unexpected argument '{arg}'."); + } + + string name = arg[2..]; + string? value; + + int equalsIndex = name.IndexOf('=', StringComparison.Ordinal); + if (equalsIndex >= 0) + { + value = name[(equalsIndex + 1)..]; + name = name[..equalsIndex]; + } + else + { + if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value."); + } + + value = args[++index]; + } + + options[name] = value; + } + + string? keyId = GetOption(options, "key-id"); + string? displayName = GetOption(options, "display-name"); + IReadOnlySet scopes = ParseScopes(GetOption(options, "scopes")); + + string? validationError = Validate(kind, keyId, displayName); + if (validationError is not null) + { + return ApiKeyAdminParseResult.Fail(validationError); + } + + return ApiKeyAdminParseResult.Success(new ApiKeyAdminCommand( + Kind: kind, + Json: json, + SqlitePath: GetOption(options, "sqlite-path"), + Pepper: GetOption(options, "pepper"), + KeyId: keyId, + DisplayName: displayName, + Scopes: scopes)); + } + + private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind) + { + switch (value.ToLowerInvariant()) + { + case "init-db": + kind = ApiKeyAdminCommandKind.InitDb; + return true; + case "create-key": + kind = ApiKeyAdminCommandKind.CreateKey; + return true; + case "list-keys": + kind = ApiKeyAdminCommandKind.ListKeys; + return true; + case "revoke-key": + kind = ApiKeyAdminCommandKind.RevokeKey; + return true; + case "rotate-key": + kind = ApiKeyAdminCommandKind.RotateKey; + return true; + default: + kind = default; + return false; + } + } + + private static string? Validate(ApiKeyAdminCommandKind kind, string? keyId, string? displayName) + { + if (kind is ApiKeyAdminCommandKind.CreateKey or ApiKeyAdminCommandKind.RevokeKey or ApiKeyAdminCommandKind.RotateKey + && string.IsNullOrWhiteSpace(keyId)) + { + return $"Subcommand '{KindName(kind)}' requires --key-id."; + } + + if (!string.IsNullOrWhiteSpace(keyId) && !IsValidKeyId(keyId)) + { + return "API key id may contain only letters, numbers, periods, and hyphens."; + } + + if (kind == ApiKeyAdminCommandKind.CreateKey && string.IsNullOrWhiteSpace(displayName)) + { + return "Subcommand 'create-key' requires --display-name."; + } + + return null; + } + + private static string KindName(ApiKeyAdminCommandKind kind) + { + return kind switch + { + ApiKeyAdminCommandKind.InitDb => "init-db", + ApiKeyAdminCommandKind.CreateKey => "create-key", + ApiKeyAdminCommandKind.ListKeys => "list-keys", + ApiKeyAdminCommandKind.RevokeKey => "revoke-key", + ApiKeyAdminCommandKind.RotateKey => "rotate-key", + _ => kind.ToString() + }; + } + + private static bool IsValidKeyId(string keyId) + { + return keyId.All(character => + char.IsAsciiLetterOrDigit(character) + || character is '.' or '-'); + } + + private static string? GetOption(Dictionary options, string name) + { + return options.TryGetValue(name, out string? value) ? value : null; + } + + private static IReadOnlySet ParseScopes(string? scopes) + { + return new HashSet( + (scopes ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + StringComparer.Ordinal); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs new file mode 100644 index 0000000..8b94d3e --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyAdminListedKey( + string KeyId, + string KeyPrefix, + string DisplayName, + IReadOnlySet Scopes, + DateTimeOffset CreatedUtc, + DateTimeOffset? LastUsedUtc, + DateTimeOffset? RevokedUtc); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs new file mode 100644 index 0000000..3567160 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminOutput.cs @@ -0,0 +1,7 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyAdminOutput( + string Command, + string Status, + string? ApiKey, + IReadOnlyList Keys); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs new file mode 100644 index 0000000..a9a25f0 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs @@ -0,0 +1,22 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyAdminParseResult( + bool IsApiKeyCommand, + ApiKeyAdminCommand? Command, + string? Error) +{ + public static ApiKeyAdminParseResult NotApiKeyCommand() + { + return new ApiKeyAdminParseResult(false, null, null); + } + + public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command) + { + return new ApiKeyAdminParseResult(true, command, null); + } + + public static ApiKeyAdminParseResult Fail(string error) + { + return new ApiKeyAdminParseResult(true, null, error); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs new file mode 100644 index 0000000..785f7c1 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs @@ -0,0 +1,9 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyCreateRequest( + string KeyId, + string KeyPrefix, + byte[] SecretHash, + string DisplayName, + IReadOnlySet Scopes, + DateTimeOffset CreatedUtc); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs new file mode 100644 index 0000000..9ed9cc7 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs @@ -0,0 +1,26 @@ +using Microsoft.Data.Sqlite; + +namespace MxGateway.Server.Security.Authentication; + +public static class ApiKeyRecordReader +{ + public static ApiKeyRecord Read(SqliteDataReader reader) + { + return new ApiKeyRecord( + KeyId: reader.GetString(0), + KeyPrefix: reader.GetString(1), + SecretHash: (byte[])reader["secret_hash"], + DisplayName: reader.GetString(3), + Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)), + CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture), + LastUsedUtc: ReadNullableDateTimeOffset(reader, 6), + RevokedUtc: ReadNullableDateTimeOffset(reader, 7)); + } + + private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal) + { + return reader.IsDBNull(ordinal) + ? null + : DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs b/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs new file mode 100644 index 0000000..7aafcf9 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; + +namespace MxGateway.Server.Security.Authentication; + +public static class ApiKeySecretGenerator +{ + public static string Generate() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs index 7057b57..0e142c3 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs @@ -7,9 +7,11 @@ public static class AuthStoreServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs new file mode 100644 index 0000000..ba5ac04 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs @@ -0,0 +1,16 @@ +namespace MxGateway.Server.Security.Authentication; + +public interface IApiKeyAdminStore +{ + Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken); + + Task RotateAsync( + string keyId, + byte[] secretHash, + DateTimeOffset rotatedUtc, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs new file mode 100644 index 0000000..a5893f4 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs @@ -0,0 +1,116 @@ +using Microsoft.Data.Sqlite; + +namespace MxGateway.Server.Security.Authentication; + +public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore +{ + public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) + { + await using SqliteConnection connection = connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO api_keys ( + key_id, + key_prefix, + secret_hash, + display_name, + scopes, + created_utc, + last_used_utc, + revoked_utc) + VALUES ( + $key_id, + $key_prefix, + $secret_hash, + $display_name, + $scopes, + $created_utc, + NULL, + NULL); + """; + AddCreateParameters(command, request); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using SqliteConnection connection = connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc + FROM api_keys + ORDER BY key_id; + """; + + List records = []; + + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken) + .ConfigureAwait(false); + + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + records.Add(ApiKeyRecordReader.Read(reader)); + } + + return records; + } + + public async Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken) + { + await using SqliteConnection connection = connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + UPDATE api_keys + SET revoked_utc = $revoked_utc + WHERE key_id = $key_id AND revoked_utc IS NULL; + """; + command.Parameters.AddWithValue("$key_id", keyId); + command.Parameters.AddWithValue("$revoked_utc", revokedUtc.ToString("O")); + + int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + return rows > 0; + } + + public async Task RotateAsync( + string keyId, + byte[] secretHash, + DateTimeOffset rotatedUtc, + CancellationToken cancellationToken) + { + await using SqliteConnection connection = connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + UPDATE api_keys + SET secret_hash = $secret_hash, + last_used_utc = NULL, + revoked_utc = NULL + WHERE key_id = $key_id; + """; + command.Parameters.AddWithValue("$key_id", keyId); + command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = secretHash; + + int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + return rows > 0; + } + + private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request) + { + command.Parameters.AddWithValue("$key_id", request.KeyId); + command.Parameters.AddWithValue("$key_prefix", request.KeyPrefix); + command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash; + command.Parameters.AddWithValue("$display_name", request.DisplayName); + command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes)); + command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O")); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs index 8c178bb..414c2b4 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs @@ -61,26 +61,6 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact return null; } - return ReadApiKeyRecord(reader); - } - - private static ApiKeyRecord ReadApiKeyRecord(SqliteDataReader reader) - { - return new ApiKeyRecord( - KeyId: reader.GetString(0), - KeyPrefix: reader.GetString(1), - SecretHash: (byte[])reader["secret_hash"], - DisplayName: reader.GetString(3), - Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)), - CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture), - LastUsedUtc: ReadNullableDateTimeOffset(reader, 6), - RevokedUtc: ReadNullableDateTimeOffset(reader, 7)); - } - - private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal) - { - return reader.IsDBNull(ordinal) - ? null - : DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture); + return ApiKeyRecordReader.Read(reader); } } diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs new file mode 100644 index 0000000..c924a29 --- /dev/null +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -0,0 +1,242 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Tests.Security.Authentication; + +public sealed class ApiKeyAdminCliRunnerTests +{ + [Fact] + public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits() + { + await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); + ApiKeyAdminCliRunner runner = services.GetRequiredService(); + StringWriter output = new(); + + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.CreateKey, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: "operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }), + output, + CancellationToken.None); + + string apiKey = ReadApiKey(output.ToString()); + + IApiKeyVerifier verifier = services.GetRequiredService(); + ApiKeyVerificationResult verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None); + + Assert.True(verification.Succeeded); + Assert.NotNull(verification.Identity); + Assert.Equal("operator01", verification.Identity.KeyId); + Assert.Contains("session:open", verification.Identity.Scopes); + + IReadOnlyList auditRecords = await services + .GetRequiredService() + .ListRecentAsync(10, CancellationToken.None); + + Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01"); + } + + [Fact] + public async Task ListKeysAsync_DoesNotPrintRawSecret() + { + await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); + ApiKeyAdminCliRunner runner = services.GetRequiredService(); + string apiKey = await CreateKeyAsync(runner, "operator01"); + StringWriter listOutput = new(); + + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.ListKeys, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: null, + DisplayName: null, + Scopes: new HashSet(StringComparer.Ordinal)), + listOutput, + CancellationToken.None); + + string listJson = listOutput.ToString(); + + Assert.Contains("operator01", listJson, StringComparison.Ordinal); + Assert.DoesNotContain(apiKey, listJson, StringComparison.Ordinal); + Assert.DoesNotContain(ApiKeySecret(apiKey), listJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits() + { + await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); + ApiKeyAdminCliRunner runner = services.GetRequiredService(); + string apiKey = await CreateKeyAsync(runner, "operator01"); + + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.RevokeKey, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: "operator01", + DisplayName: null, + Scopes: new HashSet(StringComparer.Ordinal)), + TextWriter.Null, + CancellationToken.None); + + ApiKeyVerificationResult verification = await services + .GetRequiredService() + .VerifyAsync($"Bearer {apiKey}", CancellationToken.None); + + Assert.False(verification.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure); + + IReadOnlyList auditRecords = await services + .GetRequiredService() + .ListRecentAsync(10, CancellationToken.None); + + Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01"); + } + + [Fact] + public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret() + { + await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); + ApiKeyAdminCliRunner runner = services.GetRequiredService(); + string oldApiKey = await CreateKeyAsync(runner, "operator01"); + StringWriter rotateOutput = new(); + + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.RotateKey, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: "operator01", + DisplayName: null, + Scopes: new HashSet(StringComparer.Ordinal)), + rotateOutput, + CancellationToken.None); + + string rotateJson = rotateOutput.ToString(); + string newApiKey = ReadApiKey(rotateJson); + + Assert.NotEqual(oldApiKey, newApiKey); + Assert.Equal(1, CountOccurrences(rotateJson, newApiKey)); + + IApiKeyVerifier verifier = services.GetRequiredService(); + ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None); + ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None); + + Assert.False(oldVerification.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, oldVerification.Failure); + Assert.True(newVerification.Succeeded); + } + + [Fact] + public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce() + { + await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); + ApiKeyAdminCliRunner runner = services.GetRequiredService(); + StringWriter output = new(); + + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.CreateKey, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: "operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal)), + output, + CancellationToken.None); + + string json = output.ToString(); + string apiKey = ReadApiKey(json); + + Assert.Equal(1, CountOccurrences(json, apiKey)); + Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey))); + } + + private static async Task CreateKeyAsync(ApiKeyAdminCliRunner runner, string keyId) + { + StringWriter output = new(); + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.CreateKey, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: keyId, + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal) { "session:open" }), + output, + CancellationToken.None); + + return ReadApiKey(output.ToString()); + } + + private static ServiceProvider BuildServices(string databasePath) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["MxGateway:Authentication:SqlitePath"] = databasePath, + ["MxGateway:ApiKeyPepper"] = "test-pepper" + }) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddGatewayConfiguration(); + services.AddSqliteAuthStore(); + + return services.BuildServiceProvider(validateScopes: true); + } + + private static string CreateTempDatabasePath() + { + string directory = Path.Combine(Path.GetTempPath(), "mxgateway-auth-cli-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + return Path.Combine(directory, "gateway-auth.db"); + } + + private static string ReadApiKey(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + + return document.RootElement.GetProperty("ApiKey").GetString() + ?? throw new InvalidOperationException("API key was not present in command output."); + } + + private static string ApiKeySecret(string apiKey) + { + string[] parts = apiKey.Split('_', 3); + + return parts[2]; + } + + private static int CountOccurrences(string value, string pattern) + { + int count = 0; + int index = 0; + + while ((index = value.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += pattern.Length; + } + + return count; + } +} diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs new file mode 100644 index 0000000..a7d67e6 --- /dev/null +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs @@ -0,0 +1,70 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Tests.Security.Authentication; + +public sealed class ApiKeyAdminCommandLineParserTests +{ + [Fact] + public void Parse_NonApiKeyCommand_ReturnsNotApiKeyCommand() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(["--urls=http://localhost:5000"]); + + Assert.False(result.IsApiKeyCommand); + Assert.Null(result.Command); + } + + [Fact] + public void Parse_CreateKeyCommand_ReturnsOptions() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + [ + "apikey", + "create-key", + "--key-id", + "operator01", + "--display-name", + "Operator", + "--scopes", + "session:open,events:read", + "--sqlite-path", + "auth.db", + "--pepper", + "pepper", + "--json" + ]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Error); + Assert.NotNull(result.Command); + Assert.Equal(ApiKeyAdminCommandKind.CreateKey, result.Command.Kind); + Assert.True(result.Command.Json); + Assert.Equal("operator01", result.Command.KeyId); + Assert.Equal("Operator", result.Command.DisplayName); + Assert.Equal("auth.db", result.Command.SqlitePath); + Assert.Equal("pepper", result.Command.Pepper); + Assert.Contains("session:open", result.Command.Scopes); + Assert.Contains("events:read", result.Command.Scopes); + } + + [Fact] + public void Parse_CreateKeyWithoutDisplayName_ReturnsError() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + ["apikey", "create-key", "--key-id", "operator01"]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Command); + Assert.Contains("--display-name", result.Error, StringComparison.Ordinal); + } + + [Fact] + public void Parse_KeyIdWithUnderscore_ReturnsError() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + ["apikey", "revoke-key", "--key-id", "operator_01"]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Command); + Assert.Contains("letters, numbers, periods, and hyphens", result.Error, StringComparison.Ordinal); + } +}