Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/Proof/KeyRotationCommandGroup.cs
master 811f35cba7 feat(telemetry): add telemetry client and services for tracking events
- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint.
- Created TtfsTelemetryService for emitting specific telemetry events related to TTFS.
- Added tests for TelemetryClient to ensure event queuing and flushing functionality.
- Introduced models for reachability drift detection, including DriftResult and DriftedSink.
- Developed DriftApiService for interacting with the drift detection API.
- Updated FirstSignalCardComponent to emit telemetry events on signal appearance.
- Enhanced localization support for first signal component with i18n strings.
2025-12-18 16:19:16 +02:00

629 lines
23 KiB
C#

using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Extensions;
namespace StellaOps.Cli.Commands.Proof;
/// <summary>
/// Command group for key rotation operations.
/// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
/// Task: PROOF-KEY-0011
/// Implements advisory §8.2 key rotation commands.
/// </summary>
public class KeyRotationCommandGroup
{
private readonly ILogger<KeyRotationCommandGroup> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public KeyRotationCommandGroup(ILogger<KeyRotationCommandGroup> logger)
{
_logger = logger;
}
/// <summary>
/// Build the key rotation command tree.
/// </summary>
public Command BuildCommand()
{
var keyCommand = new Command("key", "Key management and rotation commands");
keyCommand.Add(BuildListCommand());
keyCommand.Add(BuildAddCommand());
keyCommand.Add(BuildRevokeCommand());
keyCommand.Add(BuildRotateCommand());
keyCommand.Add(BuildStatusCommand());
keyCommand.Add(BuildHistoryCommand());
keyCommand.Add(BuildVerifyCommand());
return keyCommand;
}
private Command BuildListCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var includeRevokedOption = new Option<bool>("--include-revoked")
{
Description = "Include revoked keys in output"
}.SetDefaultValue(false);
var outputOption = new Option<string>("--output")
{
Description = "Output format: text, json"
}.SetDefaultValue("text").FromAmong("text", "json");
var listCommand = new Command("list", "List keys for a trust anchor")
{
anchorArg,
includeRevokedOption,
outputOption
};
listCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var includeRevoked = parseResult.GetValue(includeRevokedOption);
var output = parseResult.GetValue(outputOption) ?? "text";
Environment.ExitCode = await ListKeysAsync(anchorId, includeRevoked, output, ct).ConfigureAwait(false);
});
return listCommand;
}
private Command BuildAddCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var keyIdArg = new Argument<string>("keyId")
{
Description = "New key ID"
};
var algorithmOption = new Option<string>("--algorithm", new[] { "-a" })
{
Description = "Key algorithm: Ed25519, ES256, ES384, RS256"
}.SetDefaultValue("Ed25519").FromAmong("Ed25519", "ES256", "ES384", "RS256");
var publicKeyOption = new Option<string?>("--public-key")
{
Description = "Path to public key file (PEM format)"
};
var notesOption = new Option<string?>("--notes")
{
Description = "Human-readable notes about the key"
};
var addCommand = new Command("add", "Add a new key to a trust anchor")
{
anchorArg,
keyIdArg,
algorithmOption,
publicKeyOption,
notesOption
};
addCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var keyId = parseResult.GetValue(keyIdArg);
var algorithm = parseResult.GetValue(algorithmOption) ?? "Ed25519";
var publicKeyPath = parseResult.GetValue(publicKeyOption);
var notes = parseResult.GetValue(notesOption);
Environment.ExitCode = await AddKeyAsync(anchorId, keyId, algorithm, publicKeyPath, notes, ct).ConfigureAwait(false);
});
return addCommand;
}
private Command BuildRevokeCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var keyIdArg = new Argument<string>("keyId")
{
Description = "Key ID to revoke"
};
var reasonOption = new Option<string>("--reason", new[] { "-r" })
{
Description = "Reason for revocation"
}.SetDefaultValue("rotation-complete");
var effectiveOption = new Option<DateTimeOffset?>("--effective-at")
{
Description = "Effective revocation time (default: now). ISO-8601 format."
};
var forceOption = new Option<bool>("--force")
{
Description = "Skip confirmation prompt"
}.SetDefaultValue(false);
var revokeCommand = new Command("revoke", "Revoke a key from a trust anchor")
{
anchorArg,
keyIdArg,
reasonOption,
effectiveOption,
forceOption
};
revokeCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var keyId = parseResult.GetValue(keyIdArg);
var reason = parseResult.GetValue(reasonOption) ?? "rotation-complete";
var effectiveAt = parseResult.GetValue(effectiveOption) ?? DateTimeOffset.UtcNow;
var force = parseResult.GetValue(forceOption);
Environment.ExitCode = await RevokeKeyAsync(anchorId, keyId, reason, effectiveAt, force, ct).ConfigureAwait(false);
});
return revokeCommand;
}
private Command BuildRotateCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var oldKeyIdArg = new Argument<string>("oldKeyId")
{
Description = "Old key ID to replace"
};
var newKeyIdArg = new Argument<string>("newKeyId")
{
Description = "New key ID"
};
var algorithmOption = new Option<string>("--algorithm", new[] { "-a" })
{
Description = "Key algorithm: Ed25519, ES256, ES384, RS256"
}.SetDefaultValue("Ed25519").FromAmong("Ed25519", "ES256", "ES384", "RS256");
var publicKeyOption = new Option<string?>("--public-key")
{
Description = "Path to new public key file (PEM format)"
};
var overlapOption = new Option<int>("--overlap-days")
{
Description = "Days to keep both keys active before revoking old"
}.SetDefaultValue(30);
var rotateCommand = new Command("rotate", "Rotate a key (add new, schedule old revocation)")
{
anchorArg,
oldKeyIdArg,
newKeyIdArg,
algorithmOption,
publicKeyOption,
overlapOption
};
rotateCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var oldKeyId = parseResult.GetValue(oldKeyIdArg);
var newKeyId = parseResult.GetValue(newKeyIdArg);
var algorithm = parseResult.GetValue(algorithmOption) ?? "Ed25519";
var publicKeyPath = parseResult.GetValue(publicKeyOption);
var overlapDays = parseResult.GetValue(overlapOption);
Environment.ExitCode = await RotateKeyAsync(anchorId, oldKeyId, newKeyId, algorithm, publicKeyPath, overlapDays, ct).ConfigureAwait(false);
});
return rotateCommand;
}
private Command BuildStatusCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var outputOption = new Option<string>("--output")
{
Description = "Output format: text, json"
}.SetDefaultValue("text").FromAmong("text", "json");
var statusCommand = new Command("status", "Show key rotation status and warnings")
{
anchorArg,
outputOption
};
statusCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var output = parseResult.GetValue(outputOption) ?? "text";
Environment.ExitCode = await ShowStatusAsync(anchorId, output, ct).ConfigureAwait(false);
});
return statusCommand;
}
private Command BuildHistoryCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var keyIdOption = new Option<string?>("--key-id", new[] { "-k" })
{
Description = "Filter by specific key ID"
};
var limitOption = new Option<int>("--limit")
{
Description = "Maximum entries to show"
}.SetDefaultValue(50);
var outputOption = new Option<string>("--output")
{
Description = "Output format: text, json"
}.SetDefaultValue("text").FromAmong("text", "json");
var historyCommand = new Command("history", "Show key audit history")
{
anchorArg,
keyIdOption,
limitOption,
outputOption
};
historyCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var keyId = parseResult.GetValue(keyIdOption);
var limit = parseResult.GetValue(limitOption);
var output = parseResult.GetValue(outputOption) ?? "text";
Environment.ExitCode = await ShowHistoryAsync(anchorId, keyId, limit, output, ct).ConfigureAwait(false);
});
return historyCommand;
}
private Command BuildVerifyCommand()
{
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var keyIdArg = new Argument<string>("keyId")
{
Description = "Key ID to verify"
};
var signedAtOption = new Option<DateTimeOffset?>("--signed-at", new[] { "-t" })
{
Description = "Verify key was valid at this time (ISO-8601)"
};
var verifyCommand = new Command("verify", "Verify a key's validity at a point in time")
{
anchorArg,
keyIdArg,
signedAtOption
};
verifyCommand.SetAction(async (parseResult, ct) =>
{
var anchorId = parseResult.GetValue(anchorArg);
var keyId = parseResult.GetValue(keyIdArg);
var signedAt = parseResult.GetValue(signedAtOption) ?? DateTimeOffset.UtcNow;
Environment.ExitCode = await VerifyKeyAsync(anchorId, keyId, signedAt, ct).ConfigureAwait(false);
});
return verifyCommand;
}
#region Handler Implementations
private async Task<int> ListKeysAsync(Guid anchorId, bool includeRevoked, string output, CancellationToken ct)
{
try
{
_logger.LogInformation("Listing keys for anchor {AnchorId}, includeRevoked={IncludeRevoked}",
anchorId, includeRevoked);
// TODO: Wire up to IKeyRotationService when DI is available
if (output == "json")
{
var result = new
{
anchorId = anchorId.ToString(),
activeKeys = Array.Empty<object>(),
revokedKeys = includeRevoked ? Array.Empty<object>() : null
};
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
Console.WriteLine($"Keys for Trust Anchor: {anchorId}");
Console.WriteLine("═════════════════════════════════════════════");
Console.WriteLine();
Console.WriteLine("Active Keys:");
Console.WriteLine(" (No active keys found - connect to service)");
if (includeRevoked)
{
Console.WriteLine();
Console.WriteLine("Revoked Keys:");
Console.WriteLine(" (No revoked keys found - connect to service)");
}
}
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list keys for anchor {AnchorId}", anchorId);
return ProofExitCodes.SystemError;
}
}
private async Task<int> AddKeyAsync(Guid anchorId, string keyId, string algorithm, string? publicKeyPath, string? notes, CancellationToken ct)
{
try
{
_logger.LogInformation("Adding key {KeyId} to anchor {AnchorId}", keyId, anchorId);
string? publicKey = null;
if (publicKeyPath != null)
{
if (!File.Exists(publicKeyPath))
{
Console.Error.WriteLine($"Error: Public key file not found: {publicKeyPath}");
return ProofExitCodes.SystemError;
}
publicKey = await File.ReadAllTextAsync(publicKeyPath, ct);
}
// TODO: Wire up to IKeyRotationService.AddKeyAsync
Console.WriteLine("Adding key to trust anchor...");
Console.WriteLine($" Anchor: {anchorId}");
Console.WriteLine($" Key ID: {keyId}");
Console.WriteLine($" Algorithm: {algorithm}");
Console.WriteLine($" Public Key: {(publicKey != null ? "Provided" : "Not specified")}");
if (notes != null)
Console.WriteLine($" Notes: {notes}");
Console.WriteLine();
Console.WriteLine("✓ Key added successfully (simulation)");
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", keyId, anchorId);
return ProofExitCodes.SystemError;
}
}
private async Task<int> RevokeKeyAsync(Guid anchorId, string keyId, string reason, DateTimeOffset effectiveAt, bool force, CancellationToken ct)
{
try
{
_logger.LogInformation("Revoking key {KeyId} from anchor {AnchorId}", keyId, anchorId);
if (!force)
{
Console.Write($"Revoke key '{keyId}' from anchor {anchorId}? [y/N] ");
var response = Console.ReadLine();
if (response?.ToLowerInvariant() != "y")
{
Console.WriteLine("Cancelled.");
return ProofExitCodes.Success;
}
}
// TODO: Wire up to IKeyRotationService.RevokeKeyAsync
Console.WriteLine("Revoking key...");
Console.WriteLine($" Anchor: {anchorId}");
Console.WriteLine($" Key ID: {keyId}");
Console.WriteLine($" Reason: {reason}");
Console.WriteLine($" Effective At: {effectiveAt:O}");
Console.WriteLine();
Console.WriteLine("✓ Key revoked successfully (simulation)");
Console.WriteLine();
Console.WriteLine("Note: Proofs signed before revocation remain valid.");
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
return ProofExitCodes.SystemError;
}
}
private async Task<int> RotateKeyAsync(Guid anchorId, string oldKeyId, string newKeyId, string algorithm, string? publicKeyPath, int overlapDays, CancellationToken ct)
{
try
{
_logger.LogInformation("Rotating key {OldKeyId} -> {NewKeyId} for anchor {AnchorId}",
oldKeyId, newKeyId, anchorId);
string? publicKey = null;
if (publicKeyPath != null)
{
if (!File.Exists(publicKeyPath))
{
Console.Error.WriteLine($"Error: Public key file not found: {publicKeyPath}");
return ProofExitCodes.SystemError;
}
publicKey = await File.ReadAllTextAsync(publicKeyPath, ct);
}
var revokeAt = DateTimeOffset.UtcNow.AddDays(overlapDays);
// TODO: Wire up to IKeyRotationService
Console.WriteLine("Key Rotation Plan");
Console.WriteLine("═════════════════");
Console.WriteLine($" Anchor: {anchorId}");
Console.WriteLine($" Old Key: {oldKeyId}");
Console.WriteLine($" New Key: {newKeyId}");
Console.WriteLine($" Algorithm: {algorithm}");
Console.WriteLine($" Overlap Period: {overlapDays} days");
Console.WriteLine($" Old Key Revokes At: {revokeAt:O}");
Console.WriteLine();
Console.WriteLine("Step 1: Add new key to allowedKeyIds...");
Console.WriteLine(" ✓ Key added (simulation)");
Console.WriteLine();
Console.WriteLine("Step 2: Schedule old key revocation...");
Console.WriteLine($" ✓ Old key will be revoked on {revokeAt:yyyy-MM-dd} (simulation)");
Console.WriteLine();
Console.WriteLine("✓ Key rotation initiated successfully");
Console.WriteLine();
Console.WriteLine("Next Steps:");
Console.WriteLine($" 1. Start using '{newKeyId}' for new signatures");
Console.WriteLine($" 2. Old key remains valid until {revokeAt:yyyy-MM-dd}");
Console.WriteLine($" 3. Run 'stellaops key status {anchorId}' to check rotation warnings");
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to rotate key {OldKeyId} -> {NewKeyId} for anchor {AnchorId}",
oldKeyId, newKeyId, anchorId);
return ProofExitCodes.SystemError;
}
}
private async Task<int> ShowStatusAsync(Guid anchorId, string output, CancellationToken ct)
{
try
{
_logger.LogInformation("Showing key status for anchor {AnchorId}", anchorId);
// TODO: Wire up to IKeyRotationService.GetRotationWarningsAsync
if (output == "json")
{
var result = new
{
anchorId = anchorId.ToString(),
status = "healthy",
warnings = Array.Empty<object>()
};
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
Console.WriteLine($"Key Status for Trust Anchor: {anchorId}");
Console.WriteLine("═════════════════════════════════════════════");
Console.WriteLine();
Console.WriteLine("Overall Status: ✓ Healthy (simulation)");
Console.WriteLine();
Console.WriteLine("Active Keys: 0");
Console.WriteLine("Revoked Keys: 0");
Console.WriteLine();
Console.WriteLine("Rotation Warnings: None");
}
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to show status for anchor {AnchorId}", anchorId);
return ProofExitCodes.SystemError;
}
}
private async Task<int> ShowHistoryAsync(Guid anchorId, string? keyId, int limit, string output, CancellationToken ct)
{
try
{
_logger.LogInformation("Showing key history for anchor {AnchorId}, keyId={KeyId}, limit={Limit}",
anchorId, keyId, limit);
// TODO: Wire up to IKeyRotationService.GetKeyHistoryAsync
if (output == "json")
{
var result = new
{
anchorId = anchorId.ToString(),
keyId = keyId,
entries = Array.Empty<object>()
};
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
Console.WriteLine($"Key Audit History for Trust Anchor: {anchorId}");
if (keyId != null)
Console.WriteLine($" Filtered by Key: {keyId}");
Console.WriteLine("═════════════════════════════════════════════");
Console.WriteLine();
Console.WriteLine("Timestamp | Operation | Key ID | Operator");
Console.WriteLine("───────────────────────────────────────────────────────────────────");
Console.WriteLine("(No history entries - connect to service)");
}
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to show history for anchor {AnchorId}", anchorId);
return ProofExitCodes.SystemError;
}
}
private async Task<int> VerifyKeyAsync(Guid anchorId, string keyId, DateTimeOffset signedAt, CancellationToken ct)
{
try
{
_logger.LogInformation("Verifying key {KeyId} validity at {SignedAt} for anchor {AnchorId}",
keyId, signedAt, anchorId);
// TODO: Wire up to IKeyRotationService.CheckKeyValidityAsync
Console.WriteLine($"Key Validity Check");
Console.WriteLine("═════════════════════════════════════════════");
Console.WriteLine($" Anchor: {anchorId}");
Console.WriteLine($" Key ID: {keyId}");
Console.WriteLine($" Time: {signedAt:O}");
Console.WriteLine();
Console.WriteLine("Result: ⚠ Unknown (connect to service for verification)");
Console.WriteLine();
Console.WriteLine("Temporal validation checks:");
Console.WriteLine(" [ ] Key existed at specified time");
Console.WriteLine(" [ ] Key was not revoked before specified time");
Console.WriteLine(" [ ] Key algorithm is currently trusted");
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to verify key {KeyId} for anchor {AnchorId}", keyId, anchorId);
return ProofExitCodes.SystemError;
}
}
#endregion
}