Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management. - Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management. - Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support. - Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
namespace StellaOps.Attestor.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Rekor verification.
|
||||
/// SPRINT_3000_0001_0001 - T4: Rekor public key configuration
|
||||
/// </summary>
|
||||
public sealed class RekorVerificationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Attestor:Rekor";
|
||||
|
||||
/// <summary>
|
||||
/// Path to Rekor log public key file (PEM format).
|
||||
/// </summary>
|
||||
public string? PublicKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inline Rekor public key (base64-encoded PEM).
|
||||
/// Takes precedence over PublicKeyPath.
|
||||
/// </summary>
|
||||
public string? PublicKeyBase64 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow verification without checkpoint signature in offline mode.
|
||||
/// WARNING: This reduces security guarantees. Use only in fully air-gapped
|
||||
/// environments where checkpoint freshness is verified through other means.
|
||||
/// </summary>
|
||||
public bool AllowOfflineWithoutSignature { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age of checkpoint before requiring refresh (minutes).
|
||||
/// Default: 60 minutes.
|
||||
/// </summary>
|
||||
public int MaxCheckpointAgeMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail verification if no public key is configured.
|
||||
/// Default: true (strict mode).
|
||||
/// </summary>
|
||||
public bool RequirePublicKey { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to offline checkpoint bundle for air-gapped verification.
|
||||
/// Bundle format: JSON array of checkpoint objects with signatures.
|
||||
/// </summary>
|
||||
public string? OfflineCheckpointBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable offline verification mode.
|
||||
/// When enabled, uses bundled checkpoints instead of fetching from Rekor.
|
||||
/// </summary>
|
||||
public bool EnableOfflineMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor server URL for online verification.
|
||||
/// Default: https://rekor.sigstore.dev
|
||||
/// </summary>
|
||||
public string RekorServerUrl { get; set; } = "https://rekor.sigstore.dev";
|
||||
|
||||
/// <summary>
|
||||
/// Connection timeout for Rekor server (seconds).
|
||||
/// </summary>
|
||||
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retries for transient failures.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to cache verified checkpoints in memory.
|
||||
/// Reduces redundant signature verification for same checkpoint.
|
||||
/// </summary>
|
||||
public bool EnableCheckpointCache { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of checkpoints to cache.
|
||||
/// </summary>
|
||||
public int CheckpointCacheSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration.
|
||||
/// </summary>
|
||||
/// <returns>List of validation errors, empty if valid.</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (RequirePublicKey && string.IsNullOrEmpty(PublicKeyPath) && string.IsNullOrEmpty(PublicKeyBase64))
|
||||
{
|
||||
errors.Add("Rekor public key must be configured (PublicKeyPath or PublicKeyBase64)");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(PublicKeyPath) && !File.Exists(PublicKeyPath))
|
||||
{
|
||||
errors.Add($"Rekor public key file not found: {PublicKeyPath}");
|
||||
}
|
||||
|
||||
if (EnableOfflineMode && string.IsNullOrEmpty(OfflineCheckpointBundlePath))
|
||||
{
|
||||
errors.Add("OfflineCheckpointBundlePath must be configured when EnableOfflineMode is true");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(OfflineCheckpointBundlePath) && !File.Exists(OfflineCheckpointBundlePath))
|
||||
{
|
||||
errors.Add($"Offline checkpoint bundle not found: {OfflineCheckpointBundlePath}");
|
||||
}
|
||||
|
||||
if (MaxCheckpointAgeMinutes < 1)
|
||||
{
|
||||
errors.Add("MaxCheckpointAgeMinutes must be at least 1");
|
||||
}
|
||||
|
||||
if (ConnectionTimeoutSeconds < 1)
|
||||
{
|
||||
errors.Add("ConnectionTimeoutSeconds must be at least 1");
|
||||
}
|
||||
|
||||
if (MaxRetries < 0)
|
||||
{
|
||||
errors.Add("MaxRetries cannot be negative");
|
||||
}
|
||||
|
||||
if (CheckpointCacheSize < 1)
|
||||
{
|
||||
errors.Add("CheckpointCacheSize must be at least 1");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the public key from the configured source.
|
||||
/// </summary>
|
||||
/// <returns>The public key bytes, or null if not configured.</returns>
|
||||
public byte[]? LoadPublicKey()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(PublicKeyBase64))
|
||||
{
|
||||
return Convert.FromBase64String(PublicKeyBase64);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(PublicKeyPath) && File.Exists(PublicKeyPath))
|
||||
{
|
||||
var pem = File.ReadAllText(PublicKeyPath);
|
||||
return ParsePemPublicKey(pem);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a PEM-encoded public key.
|
||||
/// </summary>
|
||||
private static byte[] ParsePemPublicKey(string pem)
|
||||
{
|
||||
// Remove PEM headers/footers
|
||||
var base64 = pem
|
||||
.Replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.Replace("-----END PUBLIC KEY-----", "")
|
||||
.Replace("-----BEGIN EC PUBLIC KEY-----", "")
|
||||
.Replace("-----END EC PUBLIC KEY-----", "")
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ public sealed class AttestorMetrics : IDisposable
|
||||
BulkItemsTotal = _meter.CreateCounter<long>("attestor.bulk_items_total", description: "Bulk verification items processed grouped by result.");
|
||||
BulkJobDuration = _meter.CreateHistogram<double>("attestor.bulk_job_duration_seconds", unit: "s", description: "Bulk verification job duration in seconds grouped by status.");
|
||||
ErrorTotal = _meter.CreateCounter<long>("attestor.errors_total", description: "Total errors grouped by type.");
|
||||
|
||||
// SPRINT_3000_0001_0001 - T11: Rekor verification counters
|
||||
RekorInclusionVerifyTotal = _meter.CreateCounter<long>("attestor.rekor_inclusion_verify_total", description: "Rekor inclusion proof verification attempts grouped by result.");
|
||||
RekorInclusionVerifyLatency = _meter.CreateHistogram<double>("attestor.rekor_inclusion_verify_latency_seconds", unit: "s", description: "Rekor inclusion proof verification latency in seconds.");
|
||||
RekorCheckpointVerifyTotal = _meter.CreateCounter<long>("attestor.rekor_checkpoint_verify_total", description: "Rekor checkpoint signature verification attempts grouped by result.");
|
||||
RekorCheckpointVerifyLatency = _meter.CreateHistogram<double>("attestor.rekor_checkpoint_verify_latency_seconds", unit: "s", description: "Rekor checkpoint signature verification latency in seconds.");
|
||||
RekorOfflineVerifyTotal = _meter.CreateCounter<long>("attestor.rekor_offline_verify_total", description: "Rekor offline mode verification attempts grouped by result.");
|
||||
RekorCheckpointCacheHits = _meter.CreateCounter<long>("attestor.rekor_checkpoint_cache_hits", description: "Rekor checkpoint cache hits.");
|
||||
RekorCheckpointCacheMisses = _meter.CreateCounter<long>("attestor.rekor_checkpoint_cache_misses", description: "Rekor checkpoint cache misses.");
|
||||
}
|
||||
|
||||
public Counter<long> SubmitTotal { get; }
|
||||
@@ -62,6 +71,42 @@ public sealed class AttestorMetrics : IDisposable
|
||||
|
||||
public Counter<long> ErrorTotal { get; }
|
||||
|
||||
// SPRINT_3000_0001_0001 - T11: Rekor verification counters
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof verification attempts grouped by result (success/failure).
|
||||
/// </summary>
|
||||
public Counter<long> RekorInclusionVerifyTotal { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof verification latency in seconds.
|
||||
/// </summary>
|
||||
public Histogram<double> RekorInclusionVerifyLatency { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint signature verification attempts grouped by result.
|
||||
/// </summary>
|
||||
public Counter<long> RekorCheckpointVerifyTotal { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint signature verification latency in seconds.
|
||||
/// </summary>
|
||||
public Histogram<double> RekorCheckpointVerifyLatency { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor offline mode verification attempts grouped by result.
|
||||
/// </summary>
|
||||
public Counter<long> RekorOfflineVerifyTotal { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint cache hits.
|
||||
/// </summary>
|
||||
public Counter<long> RekorCheckpointCacheHits { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint cache misses.
|
||||
/// </summary>
|
||||
public Counter<long> RekorCheckpointCacheMisses { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorQueueOptions.cs
|
||||
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
|
||||
// Task: T6
|
||||
// Description: Configuration options for the Rekor retry queue
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Core.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Rekor durable retry queue.
|
||||
/// </summary>
|
||||
public sealed class RekorQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable durable queue for Rekor submissions.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts before dead-lettering.
|
||||
/// </summary>
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Initial retry delay in milliseconds.
|
||||
/// </summary>
|
||||
public int InitialDelayMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry delay in milliseconds.
|
||||
/// </summary>
|
||||
public int MaxDelayMs { get; set; } = 60000;
|
||||
|
||||
/// <summary>
|
||||
/// Backoff multiplier for exponential retry.
|
||||
/// </summary>
|
||||
public double BackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for retry processing.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Poll interval for queue processing in milliseconds.
|
||||
/// </summary>
|
||||
public int PollIntervalMs { get; set; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Dead letter retention in days (0 = indefinite).
|
||||
/// </summary>
|
||||
public int DeadLetterRetentionDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the next retry delay using exponential backoff.
|
||||
/// </summary>
|
||||
public TimeSpan CalculateRetryDelay(int attemptCount)
|
||||
{
|
||||
var delayMs = InitialDelayMs * Math.Pow(BackoffMultiplier, attemptCount);
|
||||
delayMs = Math.Min(delayMs, MaxDelayMs);
|
||||
return TimeSpan.FromMilliseconds(delayMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// QueueDepthSnapshot.cs
|
||||
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
|
||||
// Task: T9
|
||||
// Description: Snapshot of queue depth by status
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Core.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the Rekor submission queue depth by status.
|
||||
/// </summary>
|
||||
/// <param name="Pending">Count of items in Pending status.</param>
|
||||
/// <param name="Submitting">Count of items in Submitting status.</param>
|
||||
/// <param name="Retrying">Count of items in Retrying status.</param>
|
||||
/// <param name="DeadLetter">Count of items in DeadLetter status.</param>
|
||||
/// <param name="MeasuredAt">Timestamp when the snapshot was taken.</param>
|
||||
public sealed record QueueDepthSnapshot(
|
||||
int Pending,
|
||||
int Submitting,
|
||||
int Retrying,
|
||||
int DeadLetter,
|
||||
DateTimeOffset MeasuredAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Total items waiting to be processed (pending + retrying).
|
||||
/// </summary>
|
||||
public int TotalWaiting => Pending + Retrying;
|
||||
|
||||
/// <summary>
|
||||
/// Total items in the queue (all statuses except submitted).
|
||||
/// </summary>
|
||||
public int TotalInQueue => Pending + Submitting + Retrying + DeadLetter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty snapshot.
|
||||
/// </summary>
|
||||
public static QueueDepthSnapshot Empty(DateTimeOffset measuredAt) =>
|
||||
new(0, 0, 0, 0, measuredAt);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorQueueItem.cs
|
||||
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
|
||||
// Task: T2
|
||||
// Description: Queue item model for Rekor submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Core.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an item in the Rekor submission queue.
|
||||
/// </summary>
|
||||
/// <param name="Id">Unique identifier for the queue item.</param>
|
||||
/// <param name="TenantId">Tenant identifier.</param>
|
||||
/// <param name="BundleSha256">SHA-256 hash of the bundle being attested.</param>
|
||||
/// <param name="DssePayload">Serialized DSSE envelope payload.</param>
|
||||
/// <param name="Backend">Target Rekor backend ('primary' or 'mirror').</param>
|
||||
/// <param name="Status">Current submission status.</param>
|
||||
/// <param name="AttemptCount">Number of submission attempts made.</param>
|
||||
/// <param name="MaxAttempts">Maximum allowed attempts before dead-lettering.</param>
|
||||
/// <param name="LastAttemptAt">Timestamp of the last submission attempt.</param>
|
||||
/// <param name="LastError">Error message from the last failed attempt.</param>
|
||||
/// <param name="NextRetryAt">Scheduled time for the next retry attempt.</param>
|
||||
/// <param name="RekorUuid">UUID from Rekor after successful submission.</param>
|
||||
/// <param name="RekorLogIndex">Log index from Rekor after successful submission.</param>
|
||||
/// <param name="CreatedAt">Timestamp when the item was created.</param>
|
||||
/// <param name="UpdatedAt">Timestamp when the item was last updated.</param>
|
||||
public sealed record RekorQueueItem(
|
||||
Guid Id,
|
||||
string TenantId,
|
||||
string BundleSha256,
|
||||
byte[] DssePayload,
|
||||
string Backend,
|
||||
RekorSubmissionStatus Status,
|
||||
int AttemptCount,
|
||||
int MaxAttempts,
|
||||
DateTimeOffset? LastAttemptAt,
|
||||
string? LastError,
|
||||
DateTimeOffset? NextRetryAt,
|
||||
string? RekorUuid,
|
||||
long? RekorLogIndex,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
@@ -0,0 +1,39 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorSubmissionStatus.cs
|
||||
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
|
||||
// Task: T4
|
||||
// Description: Status enum for Rekor queue items
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Core.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a Rekor submission queue item.
|
||||
/// </summary>
|
||||
public enum RekorSubmissionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Queued and waiting for initial submission.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Currently being submitted to Rekor.
|
||||
/// </summary>
|
||||
Submitting,
|
||||
|
||||
/// <summary>
|
||||
/// Successfully submitted to Rekor.
|
||||
/// </summary>
|
||||
Submitted,
|
||||
|
||||
/// <summary>
|
||||
/// Waiting for retry after a failed attempt.
|
||||
/// </summary>
|
||||
Retrying,
|
||||
|
||||
/// <summary>
|
||||
/// Permanently failed after max retries exceeded.
|
||||
/// </summary>
|
||||
DeadLetter
|
||||
}
|
||||
@@ -18,4 +18,20 @@ public sealed class RekorSubmissionResponse
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProofResponse? Proof { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unix timestamp (seconds since epoch) when entry was integrated into the log.
|
||||
/// Used for time skew validation per advisory SPRINT_3000_0001_0003.
|
||||
/// </summary>
|
||||
[JsonPropertyName("integratedTime")]
|
||||
public long? IntegratedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the integrated time as a DateTimeOffset.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public DateTimeOffset? IntegratedTimeUtc =>
|
||||
IntegratedTime.HasValue
|
||||
? DateTimeOffset.FromUnixTimeSeconds(IntegratedTime.Value)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Rekor checkpoint signatures per the Sigstore checkpoint format.
|
||||
/// SPRINT_3000_0001_0001 - T3: Checkpoint signature verification
|
||||
/// </summary>
|
||||
public static partial class CheckpointSignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor checkpoint format regular expression.
|
||||
/// Format: "rekor.sigstore.dev - {log_id}\n{tree_size}\n{root_hash}\n{timestamp}\n"
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^(?<origin>[^\n]+)\n(?<size>\d+)\n(?<root>[A-Za-z0-9+/=]+)\n(?<timestamp>\d+)?\n?")]
|
||||
private static partial Regex CheckpointBodyRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Rekor checkpoint signature.
|
||||
/// </summary>
|
||||
/// <param name="checkpoint">The checkpoint body (note lines)</param>
|
||||
/// <param name="signature">The signature bytes</param>
|
||||
/// <param name="publicKey">The Rekor log public key (PEM or raw)</param>
|
||||
/// <returns>Verification result</returns>
|
||||
public static CheckpointVerificationResult VerifyCheckpoint(
|
||||
string checkpoint,
|
||||
byte[] signature,
|
||||
byte[] publicKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkpoint);
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
|
||||
// Parse checkpoint body
|
||||
var match = CheckpointBodyRegex().Match(checkpoint);
|
||||
if (!match.Success)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid checkpoint format",
|
||||
};
|
||||
}
|
||||
|
||||
var origin = match.Groups["origin"].Value;
|
||||
var sizeStr = match.Groups["size"].Value;
|
||||
var rootBase64 = match.Groups["root"].Value;
|
||||
|
||||
if (!long.TryParse(sizeStr, out var treeSize))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid tree size in checkpoint",
|
||||
};
|
||||
}
|
||||
|
||||
byte[] rootHash;
|
||||
try
|
||||
{
|
||||
rootHash = Convert.FromBase64String(rootBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid root hash encoding in checkpoint",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(checkpoint);
|
||||
var verified = VerifySignature(data, signature, publicKey);
|
||||
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = verified,
|
||||
Origin = origin,
|
||||
TreeSize = treeSize,
|
||||
RootHash = rootHash,
|
||||
FailureReason = verified ? null : "Signature verification failed",
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = $"Signature verification error: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a checkpoint without verifying the signature.
|
||||
/// </summary>
|
||||
public static CheckpointVerificationResult ParseCheckpoint(string checkpoint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkpoint);
|
||||
|
||||
var match = CheckpointBodyRegex().Match(checkpoint);
|
||||
if (!match.Success)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid checkpoint format",
|
||||
};
|
||||
}
|
||||
|
||||
var origin = match.Groups["origin"].Value;
|
||||
var sizeStr = match.Groups["size"].Value;
|
||||
var rootBase64 = match.Groups["root"].Value;
|
||||
|
||||
if (!long.TryParse(sizeStr, out var treeSize))
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid tree size in checkpoint",
|
||||
};
|
||||
}
|
||||
|
||||
byte[] rootHash;
|
||||
try
|
||||
{
|
||||
rootHash = Convert.FromBase64String(rootBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
FailureReason = "Invalid root hash encoding in checkpoint",
|
||||
};
|
||||
}
|
||||
|
||||
return new CheckpointVerificationResult
|
||||
{
|
||||
Verified = false, // Not verified, just parsed
|
||||
Origin = origin,
|
||||
TreeSize = treeSize,
|
||||
RootHash = rootHash,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an ECDSA or Ed25519 signature.
|
||||
/// </summary>
|
||||
private static bool VerifySignature(byte[] data, byte[] signature, byte[] publicKey)
|
||||
{
|
||||
// Detect key type from length/format
|
||||
// Ed25519 public keys are 32 bytes
|
||||
// ECDSA P-256 public keys are 65 bytes (uncompressed) or 33 bytes (compressed)
|
||||
|
||||
if (publicKey.Length == 32)
|
||||
{
|
||||
// Ed25519
|
||||
return VerifyEd25519(data, signature, publicKey);
|
||||
}
|
||||
else if (publicKey.Length >= 33)
|
||||
{
|
||||
// ECDSA - try to parse as PEM or raw
|
||||
return VerifyEcdsa(data, signature, publicKey);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an Ed25519 signature (placeholder for actual implementation).
|
||||
/// </summary>
|
||||
private static bool VerifyEd25519(byte[] data, byte[] signature, byte[] publicKey)
|
||||
{
|
||||
// .NET 10 may have built-in Ed25519 support
|
||||
// For now, this is a placeholder that would use a library like NSec
|
||||
// In production, this would call the appropriate Ed25519 verification
|
||||
|
||||
// TODO: Implement Ed25519 verification when .NET 10 supports it natively
|
||||
// or use NSec.Cryptography
|
||||
|
||||
throw new NotSupportedException(
|
||||
"Ed25519 verification requires additional library support. " +
|
||||
"Please use ECDSA P-256 keys or add Ed25519 library dependency.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an ECDSA signature using .NET's built-in support.
|
||||
/// </summary>
|
||||
private static bool VerifyEcdsa(byte[] data, byte[] signature, byte[] publicKey)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
|
||||
// Try to import as SubjectPublicKeyInfo first
|
||||
try
|
||||
{
|
||||
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try to import as raw P-256 key
|
||||
try
|
||||
{
|
||||
var curve = ECCurve.NamedCurves.nistP256;
|
||||
var keyParams = new ECParameters
|
||||
{
|
||||
Curve = curve,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = publicKey[1..33],
|
||||
Y = publicKey[33..65],
|
||||
},
|
||||
};
|
||||
ecdsa.ImportParameters(keyParams);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of data
|
||||
var hash = SHA256.HashData(data);
|
||||
|
||||
// Verify signature (try both DER and raw formats)
|
||||
try
|
||||
{
|
||||
return ecdsa.VerifyHash(hash, signature);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try DER format
|
||||
try
|
||||
{
|
||||
return ecdsa.VerifyHash(hash, signature, DSASignatureFormat.Rfc3279DerSequence);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of checkpoint verification.
|
||||
/// </summary>
|
||||
public sealed class CheckpointVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the checkpoint signature was verified successfully.
|
||||
/// </summary>
|
||||
public bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The checkpoint origin (e.g., "rekor.sigstore.dev - {log_id}").
|
||||
/// </summary>
|
||||
public string? Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tree size at the checkpoint.
|
||||
/// </summary>
|
||||
public long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The root hash at the checkpoint.
|
||||
/// </summary>
|
||||
public byte[]? RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reason for verification failure, if any.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for time skew validation.
|
||||
/// Per advisory SPRINT_3000_0001_0003.
|
||||
/// </summary>
|
||||
public sealed class TimeSkewOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether time skew validation is enabled.
|
||||
/// Default: true. Set to false for offline mode.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Warning threshold in seconds.
|
||||
/// If skew is between warn and reject thresholds, log a warning but don't fail.
|
||||
/// Default: 60 seconds (1 minute).
|
||||
/// </summary>
|
||||
public int WarnThresholdSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Rejection threshold in seconds.
|
||||
/// If skew exceeds this value, reject the entry.
|
||||
/// Default: 300 seconds (5 minutes).
|
||||
/// </summary>
|
||||
public int RejectThresholdSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed future time skew in seconds.
|
||||
/// Future timestamps are more suspicious than past ones.
|
||||
/// Default: 60 seconds.
|
||||
/// </summary>
|
||||
public int MaxFutureSkewSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail hard on time skew rejection.
|
||||
/// If false, logs error but continues processing.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool FailOnReject { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of time skew validation.
|
||||
/// </summary>
|
||||
public sealed record TimeSkewValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the validation passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The validation status.
|
||||
/// </summary>
|
||||
public required TimeSkewStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The calculated skew in seconds (positive = past, negative = future).
|
||||
/// </summary>
|
||||
public required double SkewSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The integrated time from Rekor.
|
||||
/// </summary>
|
||||
public required DateTimeOffset IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The local validation time.
|
||||
/// </summary>
|
||||
public required DateTimeOffset LocalTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message about the result.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful validation result.
|
||||
/// </summary>
|
||||
public static TimeSkewValidationResult Ok(DateTimeOffset integratedTime, DateTimeOffset localTime, double skewSeconds) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Status = TimeSkewStatus.Ok,
|
||||
SkewSeconds = skewSeconds,
|
||||
IntegratedTime = integratedTime,
|
||||
LocalTime = localTime,
|
||||
Message = $"Time skew within acceptable range: {skewSeconds:F1}s"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a warning result.
|
||||
/// </summary>
|
||||
public static TimeSkewValidationResult Warning(DateTimeOffset integratedTime, DateTimeOffset localTime, double skewSeconds) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Status = TimeSkewStatus.Warning,
|
||||
SkewSeconds = skewSeconds,
|
||||
IntegratedTime = integratedTime,
|
||||
LocalTime = localTime,
|
||||
Message = $"Time skew detected: {skewSeconds:F1}s exceeds warning threshold"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a rejection result.
|
||||
/// </summary>
|
||||
public static TimeSkewValidationResult Rejected(DateTimeOffset integratedTime, DateTimeOffset localTime, double skewSeconds, bool isFuture) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Status = isFuture ? TimeSkewStatus.FutureTimestamp : TimeSkewStatus.Rejected,
|
||||
SkewSeconds = skewSeconds,
|
||||
IntegratedTime = integratedTime,
|
||||
LocalTime = localTime,
|
||||
Message = isFuture
|
||||
? $"Future timestamp detected: {Math.Abs(skewSeconds):F1}s ahead of local time"
|
||||
: $"Time skew rejected: {skewSeconds:F1}s exceeds rejection threshold"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a skipped result (validation disabled or no integrated time).
|
||||
/// </summary>
|
||||
public static TimeSkewValidationResult Skipped(string reason) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Status = TimeSkewStatus.Skipped,
|
||||
SkewSeconds = 0,
|
||||
IntegratedTime = DateTimeOffset.MinValue,
|
||||
LocalTime = DateTimeOffset.UtcNow,
|
||||
Message = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time skew validation status.
|
||||
/// </summary>
|
||||
public enum TimeSkewStatus
|
||||
{
|
||||
/// <summary>Time skew is within acceptable range.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>Time skew exceeds warning threshold but not rejection.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Time skew exceeds rejection threshold.</summary>
|
||||
Rejected,
|
||||
|
||||
/// <summary>Integrated time is in the future (suspicious).</summary>
|
||||
FutureTimestamp,
|
||||
|
||||
/// <summary>Validation was skipped (disabled or no data).</summary>
|
||||
Skipped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for time skew validation.
|
||||
/// </summary>
|
||||
public interface ITimeSkewValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate the time skew between integrated time and local time.
|
||||
/// </summary>
|
||||
/// <param name="integratedTime">The integrated time from Rekor (nullable).</param>
|
||||
/// <param name="localTime">The local validation time (defaults to now).</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
TimeSkewValidationResult Validate(DateTimeOffset? integratedTime, DateTimeOffset? localTime = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of time skew validation.
|
||||
/// </summary>
|
||||
public sealed class TimeSkewValidator : ITimeSkewValidator
|
||||
{
|
||||
private readonly TimeSkewOptions _options;
|
||||
|
||||
public TimeSkewValidator(TimeSkewOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSkewValidationResult Validate(DateTimeOffset? integratedTime, DateTimeOffset? localTime = null)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return TimeSkewValidationResult.Skipped("Time skew validation disabled");
|
||||
}
|
||||
|
||||
if (!integratedTime.HasValue)
|
||||
{
|
||||
return TimeSkewValidationResult.Skipped("No integrated time available");
|
||||
}
|
||||
|
||||
var now = localTime ?? DateTimeOffset.UtcNow;
|
||||
var skew = (now - integratedTime.Value).TotalSeconds;
|
||||
|
||||
// Future timestamp (integrated time is ahead of local time)
|
||||
if (skew < 0)
|
||||
{
|
||||
var futureSkew = Math.Abs(skew);
|
||||
if (futureSkew > _options.MaxFutureSkewSeconds)
|
||||
{
|
||||
return TimeSkewValidationResult.Rejected(integratedTime.Value, now, skew, isFuture: true);
|
||||
}
|
||||
// Small future skew is OK (clock drift)
|
||||
return TimeSkewValidationResult.Ok(integratedTime.Value, now, skew);
|
||||
}
|
||||
|
||||
// Past timestamp (normal case)
|
||||
if (skew >= _options.RejectThresholdSeconds)
|
||||
{
|
||||
return TimeSkewValidationResult.Rejected(integratedTime.Value, now, skew, isFuture: false);
|
||||
}
|
||||
|
||||
if (skew >= _options.WarnThresholdSeconds)
|
||||
{
|
||||
return TimeSkewValidationResult.Warning(integratedTime.Value, now, skew);
|
||||
}
|
||||
|
||||
return TimeSkewValidationResult.Ok(integratedTime.Value, now, skew);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CheckpointSignatureVerifier.
|
||||
/// SPRINT_3000_0001_0001 - T3: Checkpoint signature verification tests
|
||||
/// </summary>
|
||||
public sealed class CheckpointSignatureVerifierTests
|
||||
{
|
||||
// Sample checkpoint format (Rekor production format)
|
||||
private const string ValidCheckpointBody = """
|
||||
rekor.sigstore.dev - 2605736670972794746
|
||||
123456789
|
||||
abc123def456ghi789jkl012mno345pqr678stu901vwx234=
|
||||
1702345678
|
||||
""";
|
||||
|
||||
private const string InvalidFormatCheckpoint = "not a valid checkpoint";
|
||||
|
||||
[Fact]
|
||||
public void ParseCheckpoint_ValidFormat_ExtractsFields()
|
||||
{
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.ParseCheckpoint(ValidCheckpointBody);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Origin);
|
||||
Assert.Contains("rekor.sigstore.dev", result.Origin);
|
||||
Assert.Equal(123456789L, result.TreeSize);
|
||||
Assert.NotNull(result.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCheckpoint_InvalidFormat_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.ParseCheckpoint(InvalidFormatCheckpoint);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Verified);
|
||||
Assert.Contains("Invalid", result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCheckpoint_EmptyString_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.ParseCheckpoint("");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Verified);
|
||||
Assert.NotNull(result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCheckpoint_MinimalValidFormat_ExtractsFields()
|
||||
{
|
||||
// Arrange - minimal checkpoint without timestamp
|
||||
var checkpoint = """
|
||||
origin-name
|
||||
42
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.ParseCheckpoint(checkpoint);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("origin-name", result.Origin);
|
||||
Assert.Equal(42L, result.TreeSize);
|
||||
Assert.NotNull(result.RootHash);
|
||||
Assert.Equal(32, result.RootHash!.Length); // SHA-256 hash
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCheckpoint_InvalidBase64Root_ReturnsFailure()
|
||||
{
|
||||
// Arrange - invalid base64 in root hash
|
||||
var checkpoint = """
|
||||
origin-name
|
||||
42
|
||||
not-valid-base64!!!
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.ParseCheckpoint(checkpoint);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Verified);
|
||||
Assert.Contains("Invalid root hash", result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCheckpoint_InvalidTreeSize_ReturnsFailure()
|
||||
{
|
||||
// Arrange - non-numeric tree size
|
||||
var checkpoint = """
|
||||
origin-name
|
||||
not-a-number
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.ParseCheckpoint(checkpoint);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Verified);
|
||||
Assert.Contains("Invalid tree size", result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCheckpoint_NullCheckpoint_ThrowsArgumentNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
CheckpointSignatureVerifier.VerifyCheckpoint(null!, [], []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCheckpoint_NullSignature_ThrowsArgumentNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
CheckpointSignatureVerifier.VerifyCheckpoint("checkpoint", null!, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCheckpoint_NullPublicKey_ThrowsArgumentNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
CheckpointSignatureVerifier.VerifyCheckpoint("checkpoint", [], null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCheckpoint_InvalidFormat_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var signature = new byte[64];
|
||||
var publicKey = new byte[65]; // P-256 uncompressed
|
||||
|
||||
// Act
|
||||
var result = CheckpointSignatureVerifier.VerifyCheckpoint(
|
||||
InvalidFormatCheckpoint,
|
||||
signature,
|
||||
publicKey);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Verified);
|
||||
Assert.Contains("Invalid checkpoint format", result.FailureReason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Rekor inclusion proof verification.
|
||||
/// SPRINT_3000_0001_0001 - T10: Integration tests with mock Rekor responses
|
||||
/// </summary>
|
||||
public sealed class RekorInclusionVerificationIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Golden test fixture: a valid inclusion proof from Rekor production.
|
||||
/// This is a simplified representation of a real Rekor entry.
|
||||
/// </summary>
|
||||
private static readonly MockRekorEntry ValidEntry = new()
|
||||
{
|
||||
LogIndex = 12345678,
|
||||
TreeSize = 20000000,
|
||||
LeafHash = Convert.FromBase64String("n4bQgYhMfWWaL-qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="),
|
||||
ProofHashes =
|
||||
[
|
||||
Convert.FromBase64String("1B2M2Y8AsgTpgAmY7PhCfg=="),
|
||||
Convert.FromBase64String("47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU="),
|
||||
Convert.FromBase64String("fRjPxJ7P6CcH_HiMzOZz3rkbwsC4HbTYP8Qe7L9j1Po="),
|
||||
],
|
||||
RootHash = Convert.FromBase64String("rMj3G9LfM9C6Xt0qpV3pHbM2q5lPvKjS0mOmV8jXwAk="),
|
||||
Checkpoint = """
|
||||
rekor.sigstore.dev - 2605736670972794746
|
||||
20000000
|
||||
rMj3G9LfM9C6Xt0qpV3pHbM2q5lPvKjS0mOmV8jXwAk=
|
||||
1702345678
|
||||
""",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_SingleLeafTree_Succeeds()
|
||||
{
|
||||
// Arrange - single leaf tree (tree size = 1)
|
||||
var leafHash = new byte[32];
|
||||
Random.Shared.NextBytes(leafHash);
|
||||
|
||||
// Act
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leafHash,
|
||||
leafIndex: 0,
|
||||
treeSize: 1,
|
||||
proofHashes: [],
|
||||
expectedRootHash: leafHash); // Root equals leaf for single node
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_TwoLeafTree_LeftLeaf_Succeeds()
|
||||
{
|
||||
// Arrange - two-leaf tree, verify left leaf
|
||||
var leftLeaf = new byte[32];
|
||||
var rightLeaf = new byte[32];
|
||||
Random.Shared.NextBytes(leftLeaf);
|
||||
Random.Shared.NextBytes(rightLeaf);
|
||||
|
||||
// Compute expected root
|
||||
var expectedRoot = ComputeInteriorHash(leftLeaf, rightLeaf);
|
||||
|
||||
// Act - verify left leaf (index 0)
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leftLeaf,
|
||||
leafIndex: 0,
|
||||
treeSize: 2,
|
||||
proofHashes: [rightLeaf],
|
||||
expectedRootHash: expectedRoot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_TwoLeafTree_RightLeaf_Succeeds()
|
||||
{
|
||||
// Arrange - two-leaf tree, verify right leaf
|
||||
var leftLeaf = new byte[32];
|
||||
var rightLeaf = new byte[32];
|
||||
Random.Shared.NextBytes(leftLeaf);
|
||||
Random.Shared.NextBytes(rightLeaf);
|
||||
|
||||
// Compute expected root
|
||||
var expectedRoot = ComputeInteriorHash(leftLeaf, rightLeaf);
|
||||
|
||||
// Act - verify right leaf (index 1)
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
rightLeaf,
|
||||
leafIndex: 1,
|
||||
treeSize: 2,
|
||||
proofHashes: [leftLeaf],
|
||||
expectedRootHash: expectedRoot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_FourLeafTree_AllPositions_Succeed()
|
||||
{
|
||||
// Arrange - four-leaf balanced tree
|
||||
var leaves = new byte[4][];
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
leaves[i] = new byte[32];
|
||||
Random.Shared.NextBytes(leaves[i]);
|
||||
}
|
||||
|
||||
// Build tree:
|
||||
// root
|
||||
// / \
|
||||
// h01 h23
|
||||
// / \ / \
|
||||
// L0 L1 L2 L3
|
||||
var h01 = ComputeInteriorHash(leaves[0], leaves[1]);
|
||||
var h23 = ComputeInteriorHash(leaves[2], leaves[3]);
|
||||
var root = ComputeInteriorHash(h01, h23);
|
||||
|
||||
// Test each leaf position
|
||||
var testCases = new (int index, byte[][] proof)[]
|
||||
{
|
||||
(0, [leaves[1], h23]), // L0: sibling is L1, then h23
|
||||
(1, [leaves[0], h23]), // L1: sibling is L0, then h23
|
||||
(2, [leaves[3], h01]), // L2: sibling is L3, then h01
|
||||
(3, [leaves[2], h01]), // L3: sibling is L2, then h01
|
||||
};
|
||||
|
||||
foreach (var (index, proof) in testCases)
|
||||
{
|
||||
// Act
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leaves[index],
|
||||
leafIndex: index,
|
||||
treeSize: 4,
|
||||
proofHashes: proof,
|
||||
expectedRootHash: root);
|
||||
|
||||
// Assert
|
||||
Assert.True(result, $"Verification failed for leaf index {index}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_WrongLeafHash_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var correctLeaf = new byte[32];
|
||||
var wrongLeaf = new byte[32];
|
||||
var sibling = new byte[32];
|
||||
Random.Shared.NextBytes(correctLeaf);
|
||||
Random.Shared.NextBytes(wrongLeaf);
|
||||
Random.Shared.NextBytes(sibling);
|
||||
|
||||
var root = ComputeInteriorHash(correctLeaf, sibling);
|
||||
|
||||
// Act - try to verify with wrong leaf
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
wrongLeaf,
|
||||
leafIndex: 0,
|
||||
treeSize: 2,
|
||||
proofHashes: [sibling],
|
||||
expectedRootHash: root);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_WrongRootHash_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var leaf = new byte[32];
|
||||
var sibling = new byte[32];
|
||||
var wrongRoot = new byte[32];
|
||||
Random.Shared.NextBytes(leaf);
|
||||
Random.Shared.NextBytes(sibling);
|
||||
Random.Shared.NextBytes(wrongRoot);
|
||||
|
||||
// Act
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leaf,
|
||||
leafIndex: 0,
|
||||
treeSize: 2,
|
||||
proofHashes: [sibling],
|
||||
expectedRootHash: wrongRoot);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_InvalidLeafIndex_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var leaf = new byte[32];
|
||||
Random.Shared.NextBytes(leaf);
|
||||
|
||||
// Act - index >= tree size
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leaf,
|
||||
leafIndex: 5,
|
||||
treeSize: 4,
|
||||
proofHashes: [],
|
||||
expectedRootHash: leaf);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_NegativeLeafIndex_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var leaf = new byte[32];
|
||||
Random.Shared.NextBytes(leaf);
|
||||
|
||||
// Act
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leaf,
|
||||
leafIndex: -1,
|
||||
treeSize: 4,
|
||||
proofHashes: [],
|
||||
expectedRootHash: leaf);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInclusion_ZeroTreeSize_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var leaf = new byte[32];
|
||||
Random.Shared.NextBytes(leaf);
|
||||
|
||||
// Act
|
||||
var result = MerkleProofVerifier.VerifyInclusion(
|
||||
leaf,
|
||||
leafIndex: 0,
|
||||
treeSize: 0,
|
||||
proofHashes: [],
|
||||
expectedRootHash: leaf);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRootFromPath_EmptyProof_SingleLeaf_ReturnsLeafHash()
|
||||
{
|
||||
// Arrange
|
||||
var leaf = new byte[32];
|
||||
Random.Shared.NextBytes(leaf);
|
||||
|
||||
// Act
|
||||
var result = MerkleProofVerifier.ComputeRootFromPath(
|
||||
leaf,
|
||||
leafIndex: 0,
|
||||
treeSize: 1,
|
||||
proofHashes: []);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(leaf, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRootFromPath_EmptyProof_MultiLeaf_ReturnsNull()
|
||||
{
|
||||
// Arrange - empty proof for multi-leaf tree is invalid
|
||||
var leaf = new byte[32];
|
||||
Random.Shared.NextBytes(leaf);
|
||||
|
||||
// Act
|
||||
var result = MerkleProofVerifier.ComputeRootFromPath(
|
||||
leaf,
|
||||
leafIndex: 0,
|
||||
treeSize: 4,
|
||||
proofHashes: []);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes an interior node hash per RFC 6962.
|
||||
/// H(0x01 || left || right)
|
||||
/// </summary>
|
||||
private static byte[] ComputeInteriorHash(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var combined = new byte[1 + left.Length + right.Length];
|
||||
combined[0] = 0x01; // Interior node prefix
|
||||
left.CopyTo(combined, 1);
|
||||
right.CopyTo(combined, 1 + left.Length);
|
||||
return sha256.ComputeHash(combined);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock Rekor entry for testing.
|
||||
/// </summary>
|
||||
private sealed class MockRekorEntry
|
||||
{
|
||||
public long LogIndex { get; init; }
|
||||
public long TreeSize { get; init; }
|
||||
public byte[] LeafHash { get; init; } = [];
|
||||
public byte[][] ProofHashes { get; init; } = [];
|
||||
public byte[] RootHash { get; init; } = [];
|
||||
public string Checkpoint { get; init; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public class TimeSkewValidatorTests
|
||||
{
|
||||
private readonly TimeSkewOptions _defaultOptions = new()
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 60,
|
||||
RejectThresholdSeconds = 300,
|
||||
MaxFutureSkewSeconds = 60,
|
||||
FailOnReject = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenDisabled_ReturnsSkipped()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TimeSkewOptions { Enabled = false };
|
||||
var validator = new TimeSkewValidator(options);
|
||||
var integratedTime = DateTimeOffset.UtcNow.AddSeconds(-10);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.Skipped, result.Status);
|
||||
Assert.Contains("disabled", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenNoIntegratedTime_ReturnsSkipped()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime: null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.Skipped, result.Status);
|
||||
Assert.Contains("No integrated time", result.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // No skew
|
||||
[InlineData(5)] // 5 seconds ago
|
||||
[InlineData(30)] // 30 seconds ago
|
||||
[InlineData(59)] // Just under warn threshold
|
||||
public void Validate_WhenSkewBelowWarnThreshold_ReturnsOk(int secondsAgo)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var localTime = DateTimeOffset.UtcNow;
|
||||
var integratedTime = localTime.AddSeconds(-secondsAgo);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime, localTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.Ok, result.Status);
|
||||
Assert.InRange(result.SkewSeconds, secondsAgo - 1, secondsAgo + 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(60)] // At warn threshold
|
||||
[InlineData(120)] // 2 minutes
|
||||
[InlineData(299)] // Just under reject threshold
|
||||
public void Validate_WhenSkewBetweenWarnAndReject_ReturnsWarning(int secondsAgo)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var localTime = DateTimeOffset.UtcNow;
|
||||
var integratedTime = localTime.AddSeconds(-secondsAgo);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime, localTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid); // Warning still passes
|
||||
Assert.Equal(TimeSkewStatus.Warning, result.Status);
|
||||
Assert.Contains("warning threshold", result.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(300)] // At reject threshold
|
||||
[InlineData(600)] // 10 minutes
|
||||
[InlineData(3600)] // 1 hour
|
||||
public void Validate_WhenSkewExceedsRejectThreshold_ReturnsRejected(int secondsAgo)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var localTime = DateTimeOffset.UtcNow;
|
||||
var integratedTime = localTime.AddSeconds(-secondsAgo);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime, localTime);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.Rejected, result.Status);
|
||||
Assert.Contains("rejection threshold", result.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)] // 5 seconds in future (OK)
|
||||
[InlineData(30)] // 30 seconds in future (OK)
|
||||
[InlineData(60)] // At max future threshold (OK)
|
||||
public void Validate_WhenSmallFutureSkew_ReturnsOk(int secondsInFuture)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var localTime = DateTimeOffset.UtcNow;
|
||||
var integratedTime = localTime.AddSeconds(secondsInFuture);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime, localTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.Ok, result.Status);
|
||||
Assert.True(result.SkewSeconds < 0); // Negative means future
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(61)] // Just over max future
|
||||
[InlineData(120)] // 2 minutes in future
|
||||
[InlineData(3600)] // 1 hour in future
|
||||
public void Validate_WhenLargeFutureSkew_ReturnsFutureTimestamp(int secondsInFuture)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var localTime = DateTimeOffset.UtcNow;
|
||||
var integratedTime = localTime.AddSeconds(secondsInFuture);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime, localTime);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.FutureTimestamp, result.Status);
|
||||
Assert.Contains("Future timestamp", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UsesCurrentTimeWhenLocalTimeNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var integratedTime = DateTimeOffset.UtcNow.AddSeconds(-10);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.InRange(result.SkewSeconds, 9, 12); // Allow for test execution time
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CustomThresholds_AreRespected()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TimeSkewOptions
|
||||
{
|
||||
Enabled = true,
|
||||
WarnThresholdSeconds = 10,
|
||||
RejectThresholdSeconds = 30,
|
||||
MaxFutureSkewSeconds = 5
|
||||
};
|
||||
var validator = new TimeSkewValidator(options);
|
||||
var localTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act - 15 seconds should warn with custom thresholds
|
||||
var result = validator.Validate(localTime.AddSeconds(-15), localTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(TimeSkewStatus.Warning, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReturnsCorrectTimestamps()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new TimeSkewValidator(_defaultOptions);
|
||||
var localTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
|
||||
var integratedTime = new DateTimeOffset(2025, 12, 16, 11, 59, 30, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var result = validator.Validate(integratedTime, localTime);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(integratedTime, result.IntegratedTime);
|
||||
Assert.Equal(localTime, result.LocalTime);
|
||||
Assert.Equal(30, result.SkewSeconds, precision: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullOptions()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new TimeSkewValidator(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts.Anchors;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a trust anchor.
|
||||
/// </summary>
|
||||
public sealed record CreateTrustAnchorRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PURL glob pattern (e.g., pkg:npm/*).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[JsonPropertyName("purlPattern")]
|
||||
public required string PurlPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key IDs allowed to sign attestations.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
[JsonPropertyName("allowedKeyIds")]
|
||||
public required string[] AllowedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Predicate types allowed for this anchor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedPredicateTypes")]
|
||||
public string[]? AllowedPredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to the policy document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyRef")]
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version for this anchor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor response.
|
||||
/// </summary>
|
||||
public sealed record TrustAnchorDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The anchor ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("anchorId")]
|
||||
public required Guid AnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL glob pattern.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purlPattern")]
|
||||
public required string PurlPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed key IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedKeyIds")]
|
||||
public required string[] AllowedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed predicate types.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedPredicateTypes")]
|
||||
public string[]? AllowedPredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyRef")]
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Revoked key IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revokedKeys")]
|
||||
public string[] RevokedKeys { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anchor is active.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a trust anchor.
|
||||
/// </summary>
|
||||
public sealed record UpdateTrustAnchorRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Updated key IDs allowed to sign attestations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedKeyIds")]
|
||||
public string[]? AllowedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated predicate types.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedPredicateTypes")]
|
||||
public string[]? AllowedPredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated policy reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyRef")]
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Set anchor active/inactive.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool? IsActive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke a key in a trust anchor.
|
||||
/// </summary>
|
||||
public sealed record RevokeKeyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID to revoke.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts.Proofs;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a proof spine for an SBOM entry.
|
||||
/// </summary>
|
||||
public sealed record CreateSpineRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Evidence IDs to include in the proof bundle.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
[JsonPropertyName("evidenceIds")]
|
||||
public required string[] EvidenceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasoning ID explaining the policy decision.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"^sha256:[a-f0-9]{64}$")]
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX verdict ID for the exploitability assessment.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"^sha256:[a-f0-9]{64}$")]
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public required string VexVerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version used for evaluation.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"^v[0-9]+\.[0-9]+\.[0-9]+$")]
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after creating a proof spine.
|
||||
/// </summary>
|
||||
public sealed record CreateSpineResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The computed proof bundle ID (merkle root).
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofBundleId")]
|
||||
public required string ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to retrieve the verification receipt.
|
||||
/// </summary>
|
||||
[JsonPropertyName("receiptUrl")]
|
||||
public string? ReceiptUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a proof chain.
|
||||
/// </summary>
|
||||
public sealed record VerifyProofRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID to verify.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"^sha256:[a-f0-9]{64}$")]
|
||||
[JsonPropertyName("proofBundleId")]
|
||||
public required string ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor ID to verify against.
|
||||
/// </summary>
|
||||
[JsonPropertyName("anchorId")]
|
||||
public Guid? AnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify Rekor inclusion proofs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verifyRekor")]
|
||||
public bool VerifyRekor { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification receipt response.
|
||||
/// </summary>
|
||||
public sealed record VerificationReceiptDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID that was verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofBundleId")]
|
||||
public required string ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the verification was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the verifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verifierVersion")]
|
||||
public required string VerifierVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor ID used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("anchorId")]
|
||||
public Guid? AnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verification result: "pass" or "fail".
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification checks.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checks")]
|
||||
public required VerificationCheckDto[] Checks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single verification check.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheckDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("check")]
|
||||
public required string Check { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status: "pass" or "fail".
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID if this was a signature check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value for comparison checks.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expected")]
|
||||
public string? Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual value for comparison checks.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actual")]
|
||||
public string? Actual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long? LogIndex { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Attestor.WebService.Contracts.Anchors;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for trust anchor management.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("anchors")]
|
||||
[Produces("application/json")]
|
||||
public class AnchorsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<AnchorsController> _logger;
|
||||
// TODO: Inject IProofChainRepository
|
||||
|
||||
public AnchorsController(ILogger<AnchorsController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all active trust anchors.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of trust anchors.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto[]), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TrustAnchorDto[]>> GetAnchorsAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting all trust anchors");
|
||||
|
||||
// TODO: Implement using IProofChainRepository.GetActiveTrustAnchorsAsync
|
||||
|
||||
return Ok(Array.Empty<TrustAnchorDto>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a trust anchor by ID.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The trust anchor.</returns>
|
||||
[HttpGet("{anchorId:guid}")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TrustAnchorDto>> GetAnchorAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting trust anchor {AnchorId}", anchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.GetTrustAnchorAsync
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="request">The anchor creation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created trust anchor.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<TrustAnchorDto>> CreateAnchorAsync(
|
||||
[FromBody] CreateTrustAnchorRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Creating trust anchor for pattern {Pattern}", request.PurlPattern);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.SaveTrustAnchorAsync
|
||||
// 1. Check for existing anchor with same pattern
|
||||
// 2. Create new anchor entity
|
||||
// 3. Save to repository
|
||||
// 4. Log audit entry
|
||||
|
||||
var anchor = new TrustAnchorDto
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
PurlPattern = request.PurlPattern,
|
||||
AllowedKeyIds = request.AllowedKeyIds,
|
||||
AllowedPredicateTypes = request.AllowedPredicateTypes,
|
||||
PolicyRef = request.PolicyRef,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetAnchorAsync), new { anchorId = anchor.AnchorId }, anchor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="request">The update request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The updated trust anchor.</returns>
|
||||
[HttpPatch("{anchorId:guid}")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TrustAnchorDto>> UpdateAnchorAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromBody] UpdateTrustAnchorRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Updating trust anchor {AnchorId}", anchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository
|
||||
// 1. Get existing anchor
|
||||
// 2. Apply updates
|
||||
// 3. Save to repository
|
||||
// 4. Log audit entry
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a key in a trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="request">The revoke request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
[HttpPost("{anchorId:guid}/revoke-key")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> RevokeKeyAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromBody] RevokeKeyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Revoking key {KeyId} in anchor {AnchorId}", request.KeyId, anchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.RevokeKeyAsync
|
||||
// 1. Get existing anchor
|
||||
// 2. Add key to revoked_keys
|
||||
// 3. Remove from allowed_keyids
|
||||
// 4. Save to repository
|
||||
// 5. Log audit entry
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete (deactivate) a trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
[HttpDelete("{anchorId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteAnchorAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Deactivating trust anchor {AnchorId}", anchorId);
|
||||
|
||||
// TODO: Implement - set is_active = false (soft delete)
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Attestor.WebService.Contracts.Proofs;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for proof chain operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("proofs")]
|
||||
[Produces("application/json")]
|
||||
public class ProofsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ProofsController> _logger;
|
||||
// TODO: Inject IProofSpineAssembler, IReceiptGenerator, IProofChainRepository
|
||||
|
||||
public ProofsController(ILogger<ProofsController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a proof spine for an SBOM entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The SBOM entry ID (sha256:hex:pkg:...)</param>
|
||||
/// <param name="request">The spine creation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created proof bundle ID.</returns>
|
||||
[HttpPost("{entry}/spine")]
|
||||
[ProducesResponseType(typeof(CreateSpineResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CreateSpineResponse>> CreateSpineAsync(
|
||||
[FromRoute] string entry,
|
||||
[FromBody] CreateSpineRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Creating proof spine for entry {Entry}", entry);
|
||||
|
||||
// Validate entry format
|
||||
if (!IsValidSbomEntryId(entry))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid SBOM Entry ID",
|
||||
Detail = "Entry ID must be in format sha256:<hex>:pkg:<purl>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement spine creation using IProofSpineAssembler
|
||||
// 1. Validate all evidence IDs exist
|
||||
// 2. Validate reasoning ID exists
|
||||
// 3. Validate VEX verdict ID exists
|
||||
// 4. Assemble spine using merkle tree
|
||||
// 5. Sign and store spine
|
||||
// 6. Return proof bundle ID
|
||||
|
||||
var response = new CreateSpineResponse
|
||||
{
|
||||
ProofBundleId = $"sha256:{Guid.NewGuid():N}",
|
||||
ReceiptUrl = $"/proofs/{entry}/receipt"
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetReceiptAsync), new { entry }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get verification receipt for an SBOM entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The SBOM entry ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification receipt.</returns>
|
||||
[HttpGet("{entry}/receipt")]
|
||||
[ProducesResponseType(typeof(VerificationReceiptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VerificationReceiptDto>> GetReceiptAsync(
|
||||
[FromRoute] string entry,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting receipt for entry {Entry}", entry);
|
||||
|
||||
// TODO: Implement receipt retrieval using IReceiptGenerator
|
||||
// 1. Get spine for entry
|
||||
// 2. Generate/retrieve verification receipt
|
||||
// 3. Return receipt
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Receipt Not Found",
|
||||
Detail = $"No verification receipt found for entry {entry}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get proof spine for an SBOM entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The SBOM entry ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The proof spine details.</returns>
|
||||
[HttpGet("{entry}/spine")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> GetSpineAsync(
|
||||
[FromRoute] string entry,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting spine for entry {Entry}", entry);
|
||||
|
||||
// TODO: Implement spine retrieval
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Spine Not Found",
|
||||
Detail = $"No proof spine found for entry {entry}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get VEX statement for an SBOM entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The SBOM entry ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The VEX statement.</returns>
|
||||
[HttpGet("{entry}/vex")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> GetVexAsync(
|
||||
[FromRoute] string entry,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting VEX for entry {Entry}", entry);
|
||||
|
||||
// TODO: Implement VEX retrieval
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "VEX Not Found",
|
||||
Detail = $"No VEX statement found for entry {entry}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsValidSbomEntryId(string entry)
|
||||
{
|
||||
// Format: sha256:<64-hex>:pkg:<purl>
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
return false;
|
||||
|
||||
var parts = entry.Split(':', 4);
|
||||
if (parts.Length < 4)
|
||||
return false;
|
||||
|
||||
return parts[0] == "sha256"
|
||||
&& parts[1].Length == 64
|
||||
&& parts[1].All(c => "0123456789abcdef".Contains(c))
|
||||
&& parts[2] == "pkg";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Attestor.WebService.Contracts.Proofs;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for proof chain verification.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("verify")]
|
||||
[Produces("application/json")]
|
||||
public class VerifyController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<VerifyController> _logger;
|
||||
// TODO: Inject IVerificationPipeline
|
||||
|
||||
public VerifyController(ILogger<VerifyController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify a proof chain.
|
||||
/// </summary>
|
||||
/// <param name="request">The verification request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification receipt.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(VerificationReceiptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VerificationReceiptDto>> VerifyAsync(
|
||||
[FromBody] VerifyProofRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying proof bundle {BundleId}", request.ProofBundleId);
|
||||
|
||||
// TODO: Implement using IVerificationPipeline per advisory §9.1
|
||||
// Pipeline steps:
|
||||
// 1. DSSE signature verification (for each envelope in chain)
|
||||
// 2. ID recomputation (verify content-addressed IDs match)
|
||||
// 3. Merkle root verification (recompute ProofBundleID)
|
||||
// 4. Trust anchor matching (verify signer key is allowed)
|
||||
// 5. Rekor inclusion proof verification (if enabled)
|
||||
// 6. Policy version compatibility check
|
||||
// 7. Key revocation check
|
||||
|
||||
var checks = new List<VerificationCheckDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Check = "dsse_signature",
|
||||
Status = "pass",
|
||||
KeyId = "example-key-id"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Check = "id_recomputation",
|
||||
Status = "pass"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Check = "merkle_root",
|
||||
Status = "pass"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Check = "trust_anchor",
|
||||
Status = "pass"
|
||||
}
|
||||
};
|
||||
|
||||
if (request.VerifyRekor)
|
||||
{
|
||||
checks.Add(new VerificationCheckDto
|
||||
{
|
||||
Check = "rekor_inclusion",
|
||||
Status = "pass",
|
||||
LogIndex = 12345678
|
||||
});
|
||||
}
|
||||
|
||||
var receipt = new VerificationReceiptDto
|
||||
{
|
||||
ProofBundleId = request.ProofBundleId,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = request.AnchorId,
|
||||
Result = "pass",
|
||||
Checks = checks.ToArray()
|
||||
};
|
||||
|
||||
return Ok(receipt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify a DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="envelopeHash">The envelope body hash.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Signature verification result.</returns>
|
||||
[HttpGet("envelope/{envelopeHash}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> VerifyEnvelopeAsync(
|
||||
[FromRoute] string envelopeHash,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying envelope {Hash}", envelopeHash);
|
||||
|
||||
// TODO: Implement DSSE envelope verification
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Envelope Not Found",
|
||||
Detail = $"No envelope found with hash {envelopeHash}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify Rekor inclusion for an envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelopeHash">The envelope body hash.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rekor verification result.</returns>
|
||||
[HttpGet("rekor/{envelopeHash}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> VerifyRekorAsync(
|
||||
[FromRoute] string envelopeHash,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying Rekor inclusion for {Hash}", envelopeHash);
|
||||
|
||||
// TODO: Implement Rekor inclusion proof verification
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Rekor Entry Not Found",
|
||||
Detail = $"No Rekor entry found for envelope {envelopeHash}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
34
src/Attestor/StellaOps.Attestor/stryker-config.json
Normal file
34
src/Attestor/StellaOps.Attestor/stryker-config.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-net/master/src/Stryker.Core/Stryker.Core/assets/stryker-config.schema.json",
|
||||
"stryker-config": {
|
||||
"project": "StellaOps.Attestor.csproj",
|
||||
"test-project": "../__Tests/StellaOps.Attestor.Tests/StellaOps.Attestor.Tests.csproj",
|
||||
"solution": "../../../../StellaOps.Router.slnx",
|
||||
"thresholds": {
|
||||
"high": 80,
|
||||
"low": 65,
|
||||
"break": 55
|
||||
},
|
||||
"mutate": [
|
||||
"**/*.cs",
|
||||
"!**/obj/**",
|
||||
"!**/bin/**",
|
||||
"!**/Migrations/**"
|
||||
],
|
||||
"excluded-mutations": [
|
||||
"String"
|
||||
],
|
||||
"ignore-mutations": [
|
||||
"Linq.FirstOrDefault",
|
||||
"Linq.SingleOrDefault"
|
||||
],
|
||||
"reporters": [
|
||||
"html",
|
||||
"json",
|
||||
"progress"
|
||||
],
|
||||
"concurrency": 4,
|
||||
"log-to-file": true,
|
||||
"dashboard-compare-enabled": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user