up
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Plugin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies plugin signatures using Cosign.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires Cosign to be installed and available in PATH.
|
||||
/// Signature files are expected to be adjacent to the assembly with .sig extension.
|
||||
/// </remarks>
|
||||
public sealed class CosignPluginVerifier : IPluginSignatureVerifier
|
||||
{
|
||||
private readonly CosignVerifierOptions _options;
|
||||
private readonly ILogger<CosignPluginVerifier>? _logger;
|
||||
|
||||
public CosignPluginVerifier(CosignVerifierOptions options, ILogger<CosignPluginVerifier>? logger = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assemblyPath))
|
||||
{
|
||||
throw new ArgumentException("Assembly path cannot be null or empty.", nameof(assemblyPath));
|
||||
}
|
||||
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
SignerIdentity: null,
|
||||
SignatureTimestamp: null,
|
||||
FailureReason: $"Assembly file not found: {assemblyPath}",
|
||||
CertificateChain: null);
|
||||
}
|
||||
|
||||
var signaturePath = GetSignaturePath(assemblyPath);
|
||||
if (!File.Exists(signaturePath))
|
||||
{
|
||||
if (_options.AllowUnsigned)
|
||||
{
|
||||
_logger?.LogWarning("Plugin '{Assembly}' is unsigned but unsigned plugins are allowed.", Path.GetFileName(assemblyPath));
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
SignerIdentity: null,
|
||||
SignatureTimestamp: null,
|
||||
FailureReason: null,
|
||||
CertificateChain: null);
|
||||
}
|
||||
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
SignerIdentity: null,
|
||||
SignatureTimestamp: null,
|
||||
FailureReason: $"Signature file not found: {signaturePath}",
|
||||
CertificateChain: null);
|
||||
}
|
||||
|
||||
return await VerifyWithCosignAsync(assemblyPath, signaturePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<SignatureVerificationResult> VerifyWithCosignAsync(
|
||||
string assemblyPath,
|
||||
string signaturePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cosignPath = _options.CosignPath ?? "cosign";
|
||||
var arguments = BuildCosignArguments(assemblyPath, signaturePath);
|
||||
|
||||
_logger?.LogDebug("Verifying plugin signature: {CosignPath} {Arguments}", cosignPath, arguments);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = cosignPath,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var output = await outputTask.ConfigureAwait(false);
|
||||
var error = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
var (signerIdentity, timestamp) = ParseCosignOutput(output);
|
||||
_logger?.LogInformation("Plugin '{Assembly}' signature verified. Signer: {Signer}", Path.GetFileName(assemblyPath), signerIdentity ?? "unknown");
|
||||
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
SignerIdentity: signerIdentity,
|
||||
SignatureTimestamp: timestamp,
|
||||
FailureReason: null,
|
||||
CertificateChain: output);
|
||||
}
|
||||
|
||||
_logger?.LogError("Plugin '{Assembly}' signature verification failed: {Error}", Path.GetFileName(assemblyPath), error);
|
||||
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
SignerIdentity: null,
|
||||
SignatureTimestamp: null,
|
||||
FailureReason: string.IsNullOrWhiteSpace(error) ? "Signature verification failed" : error.Trim(),
|
||||
CertificateChain: null);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to execute Cosign for plugin '{Assembly}'.", Path.GetFileName(assemblyPath));
|
||||
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
SignerIdentity: null,
|
||||
SignatureTimestamp: null,
|
||||
FailureReason: $"Cosign execution failed: {ex.Message}",
|
||||
CertificateChain: null);
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildCosignArguments(string assemblyPath, string signaturePath)
|
||||
{
|
||||
var args = $"verify-blob --signature \"{signaturePath}\"";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.PublicKeyPath))
|
||||
{
|
||||
args += $" --key \"{_options.PublicKeyPath}\"";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.CertificatePath))
|
||||
{
|
||||
args += $" --certificate \"{_options.CertificatePath}\"";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.CertificateIdentity))
|
||||
{
|
||||
args += $" --certificate-identity \"{_options.CertificateIdentity}\"";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.CertificateOidcIssuer))
|
||||
{
|
||||
args += $" --certificate-oidc-issuer \"{_options.CertificateOidcIssuer}\"";
|
||||
}
|
||||
|
||||
if (_options.UseRekorTransparencyLog)
|
||||
{
|
||||
args += " --rekor-url https://rekor.sigstore.dev";
|
||||
}
|
||||
else
|
||||
{
|
||||
args += " --insecure-ignore-tlog";
|
||||
}
|
||||
|
||||
args += $" \"{assemblyPath}\"";
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private static string GetSignaturePath(string assemblyPath)
|
||||
{
|
||||
return assemblyPath + ".sig";
|
||||
}
|
||||
|
||||
private static (string? SignerIdentity, DateTimeOffset? Timestamp) ParseCosignOutput(string output)
|
||||
{
|
||||
string? signerIdentity = null;
|
||||
DateTimeOffset? timestamp = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (output.Contains("\""))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
if (doc.RootElement.TryGetProperty("optional", out var optional))
|
||||
{
|
||||
if (optional.TryGetProperty("Subject", out var subject))
|
||||
{
|
||||
signerIdentity = subject.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Output may not be JSON; extract identity from text
|
||||
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Contains("Subject:"))
|
||||
{
|
||||
signerIdentity = line.Split(':', 2)[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (signerIdentity, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Cosign-based plugin verification.
|
||||
/// </summary>
|
||||
public sealed class CosignVerifierOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the Cosign executable. Defaults to "cosign" (must be in PATH).
|
||||
/// </summary>
|
||||
public string? CosignPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the public key file for verification.
|
||||
/// </summary>
|
||||
public string? PublicKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the certificate file for verification.
|
||||
/// </summary>
|
||||
public string? CertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected certificate identity (email or URI).
|
||||
/// </summary>
|
||||
public string? CertificateIdentity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected OIDC issuer for the certificate.
|
||||
/// </summary>
|
||||
public string? CertificateOidcIssuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify against the Rekor transparency log. Defaults to true.
|
||||
/// </summary>
|
||||
public bool UseRekorTransparencyLog { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow loading unsigned plugins. Defaults to false.
|
||||
/// Should only be true in development/testing scenarios.
|
||||
/// </summary>
|
||||
public bool AllowUnsigned { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Plugin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Provides plugin signature verification to ensure integrity and authenticity.
|
||||
/// </summary>
|
||||
public interface IPluginSignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the signature of a plugin assembly file.
|
||||
/// </summary>
|
||||
/// <param name="assemblyPath">The full path to the plugin assembly.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating verification status.</returns>
|
||||
Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a signature verification operation.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">True if the signature is valid.</param>
|
||||
/// <param name="SignerIdentity">The identity of the signer, if available.</param>
|
||||
/// <param name="SignatureTimestamp">When the artifact was signed, if available.</param>
|
||||
/// <param name="FailureReason">Description of verification failure, if applicable.</param>
|
||||
/// <param name="CertificateChain">Certificate chain information, if available.</param>
|
||||
public sealed record SignatureVerificationResult(
|
||||
bool IsValid,
|
||||
string? SignerIdentity,
|
||||
System.DateTimeOffset? SignatureTimestamp,
|
||||
string? FailureReason,
|
||||
string? CertificateChain);
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Plugin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A no-op plugin verifier that always returns valid.
|
||||
/// Use only in development/testing scenarios where signature verification is not required.
|
||||
/// </summary>
|
||||
public sealed class NullPluginVerifier : IPluginSignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton instance.
|
||||
/// </summary>
|
||||
public static NullPluginVerifier Instance { get; } = new();
|
||||
|
||||
private NullPluginVerifier() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
SignerIdentity: null,
|
||||
SignatureTimestamp: null,
|
||||
FailureReason: null,
|
||||
CertificateChain: null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user