Implement VEX document verification system with issuer management and signature verification
- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation. - Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments. - Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats. - Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats. - Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction. - Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
206
src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs
Normal file
206
src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing VEX document issuers and their trust configuration.
|
||||
/// </summary>
|
||||
public interface IIssuerDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an issuer by ID.
|
||||
/// </summary>
|
||||
Task<IssuerRecord?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an issuer by key fingerprint.
|
||||
/// </summary>
|
||||
Task<IssuerRecord?> GetIssuerByKeyFingerprintAsync(
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all registered issuers.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<IssuerRecord>> ListIssuersAsync(
|
||||
IssuerListOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers or updates an issuer.
|
||||
/// </summary>
|
||||
Task<IssuerRecord> RegisterIssuerAsync(
|
||||
IssuerRegistration registration,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an issuer's trust.
|
||||
/// </summary>
|
||||
Task<bool> RevokeIssuerAsync(
|
||||
string issuerId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a key fingerprint to an issuer.
|
||||
/// </summary>
|
||||
Task<IssuerRecord> AddKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
KeyFingerprintRegistration keyRegistration,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a key fingerprint.
|
||||
/// </summary>
|
||||
Task<bool> RevokeKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
string fingerprint,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an issuer's trust status.
|
||||
/// </summary>
|
||||
Task<IssuerTrustValidation> ValidateTrustAsync(
|
||||
string issuerId,
|
||||
string? keyFingerprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record for a registered issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerRecord(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
IssuerCategory Category,
|
||||
TrustTier TrustTier,
|
||||
IssuerStatus Status,
|
||||
IReadOnlyList<KeyFingerprintRecord> KeyFingerprints,
|
||||
IssuerMetadata? Metadata,
|
||||
DateTimeOffset RegisteredAt,
|
||||
DateTimeOffset? LastUpdatedAt,
|
||||
DateTimeOffset? RevokedAt,
|
||||
string? RevocationReason);
|
||||
|
||||
/// <summary>
|
||||
/// Status of an issuer.
|
||||
/// </summary>
|
||||
public enum IssuerStatus
|
||||
{
|
||||
Active,
|
||||
Suspended,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record for a key fingerprint.
|
||||
/// </summary>
|
||||
public sealed record KeyFingerprintRecord(
|
||||
string Fingerprint,
|
||||
KeyType KeyType,
|
||||
string? Algorithm,
|
||||
KeyFingerprintStatus Status,
|
||||
DateTimeOffset RegisteredAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? RevokedAt,
|
||||
string? RevocationReason);
|
||||
|
||||
/// <summary>
|
||||
/// Type of cryptographic key.
|
||||
/// </summary>
|
||||
public enum KeyType
|
||||
{
|
||||
Pgp,
|
||||
X509,
|
||||
Jwk,
|
||||
Ssh,
|
||||
Sigstore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a key fingerprint.
|
||||
/// </summary>
|
||||
public enum KeyFingerprintStatus
|
||||
{
|
||||
Active,
|
||||
Expired,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for an issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerMetadata(
|
||||
string? Description,
|
||||
string? Uri,
|
||||
string? Email,
|
||||
string? LogoUri,
|
||||
IReadOnlyList<string>? Tags,
|
||||
IReadOnlyDictionary<string, string>? Custom);
|
||||
|
||||
/// <summary>
|
||||
/// Options for listing issuers.
|
||||
/// </summary>
|
||||
public sealed record IssuerListOptions(
|
||||
IssuerCategory? Category,
|
||||
TrustTier? MinimumTrustTier,
|
||||
IssuerStatus? Status,
|
||||
string? SearchTerm,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
/// <summary>
|
||||
/// Registration for a new issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerRegistration(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
IssuerCategory Category,
|
||||
TrustTier TrustTier,
|
||||
IReadOnlyList<KeyFingerprintRegistration>? InitialKeys,
|
||||
IssuerMetadata? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Registration for a key fingerprint.
|
||||
/// </summary>
|
||||
public sealed record KeyFingerprintRegistration(
|
||||
string Fingerprint,
|
||||
KeyType KeyType,
|
||||
string? Algorithm,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
byte[]? PublicKey);
|
||||
|
||||
/// <summary>
|
||||
/// Result of trust validation.
|
||||
/// </summary>
|
||||
public sealed record IssuerTrustValidation(
|
||||
bool IsTrusted,
|
||||
TrustTier EffectiveTrustTier,
|
||||
IssuerTrustStatus IssuerStatus,
|
||||
KeyTrustStatus? KeyStatus,
|
||||
IReadOnlyList<string> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Trust status of an issuer.
|
||||
/// </summary>
|
||||
public enum IssuerTrustStatus
|
||||
{
|
||||
Trusted,
|
||||
NotRegistered,
|
||||
Suspended,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust status of a key.
|
||||
/// </summary>
|
||||
public enum KeyTrustStatus
|
||||
{
|
||||
Valid,
|
||||
NotRegistered,
|
||||
Expired,
|
||||
Revoked
|
||||
}
|
||||
182
src/VexLens/StellaOps.VexLens/Verification/ISignatureVerifier.cs
Normal file
182
src/VexLens/StellaOps.VexLens/Verification/ISignatureVerifier.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX document signature verification.
|
||||
/// </summary>
|
||||
public interface ISignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the signature formats this verifier supports.
|
||||
/// </summary>
|
||||
IReadOnlyList<SignatureFormat> SupportedFormats { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the signature on a VEX document.
|
||||
/// </summary>
|
||||
Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts signature information without full verification.
|
||||
/// </summary>
|
||||
Task<SignatureExtractionResult> ExtractSignatureInfoAsync(
|
||||
byte[] signedData,
|
||||
SignatureFormat format,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationRequest(
|
||||
byte[] Content,
|
||||
byte[]? DetachedSignature,
|
||||
SignatureFormat Format,
|
||||
SignatureVerificationOptions Options);
|
||||
|
||||
/// <summary>
|
||||
/// Options for signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationOptions(
|
||||
bool RequireTimestamp,
|
||||
bool AllowExpiredCertificates,
|
||||
bool CheckRevocation,
|
||||
IReadOnlyList<string>? TrustedIssuers,
|
||||
IReadOnlyList<string>? TrustedKeyFingerprints,
|
||||
DateTimeOffset? VerificationTime);
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult(
|
||||
bool IsValid,
|
||||
SignatureVerificationStatus Status,
|
||||
SignerInfo? Signer,
|
||||
IReadOnlyList<CertificateInfo>? CertificateChain,
|
||||
TimestampInfo? Timestamp,
|
||||
IReadOnlyList<SignatureVerificationError> Errors,
|
||||
IReadOnlyList<SignatureVerificationWarning> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Status of signature verification.
|
||||
/// </summary>
|
||||
public enum SignatureVerificationStatus
|
||||
{
|
||||
Valid,
|
||||
InvalidSignature,
|
||||
ExpiredCertificate,
|
||||
RevokedCertificate,
|
||||
UntrustedIssuer,
|
||||
MissingSignature,
|
||||
UnsupportedFormat,
|
||||
CertificateChainError,
|
||||
TimestampError,
|
||||
UnknownError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfo(
|
||||
string IssuerId,
|
||||
string? Name,
|
||||
string? Email,
|
||||
string? Organization,
|
||||
string KeyFingerprint,
|
||||
string Algorithm,
|
||||
DateTimeOffset? SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a certificate in the chain.
|
||||
/// </summary>
|
||||
public sealed record CertificateInfo(
|
||||
string Subject,
|
||||
string Issuer,
|
||||
string SerialNumber,
|
||||
string Fingerprint,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
IReadOnlyList<string> KeyUsages,
|
||||
bool IsSelfSigned,
|
||||
bool IsCA);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a timestamp.
|
||||
/// </summary>
|
||||
public sealed record TimestampInfo(
|
||||
DateTimeOffset Timestamp,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampAuthorityUri,
|
||||
bool IsValid);
|
||||
|
||||
/// <summary>
|
||||
/// Error during signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Warning during signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationWarning(
|
||||
string Code,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature extraction.
|
||||
/// </summary>
|
||||
public sealed record SignatureExtractionResult(
|
||||
bool Success,
|
||||
SignatureFormat? DetectedFormat,
|
||||
SignerInfo? Signer,
|
||||
IReadOnlyList<CertificateInfo>? Certificates,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Supported signature formats.
|
||||
/// </summary>
|
||||
public enum SignatureFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Detached PGP/GPG signature (.sig, .asc).
|
||||
/// </summary>
|
||||
PgpDetached,
|
||||
|
||||
/// <summary>
|
||||
/// Inline PGP/GPG signature (cleartext signed).
|
||||
/// </summary>
|
||||
PgpInline,
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#7/CMS detached signature (.p7s).
|
||||
/// </summary>
|
||||
Pkcs7Detached,
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#7/CMS enveloped signature.
|
||||
/// </summary>
|
||||
Pkcs7Enveloped,
|
||||
|
||||
/// <summary>
|
||||
/// JSON Web Signature (JWS).
|
||||
/// </summary>
|
||||
Jws,
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope (Dead Simple Signing Envelope).
|
||||
/// </summary>
|
||||
Dsse,
|
||||
|
||||
/// <summary>
|
||||
/// Sigstore bundle format.
|
||||
/// </summary>
|
||||
SigstoreBundle,
|
||||
|
||||
/// <summary>
|
||||
/// in-toto attestation envelope.
|
||||
/// </summary>
|
||||
InToto
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IIssuerDirectory"/>.
|
||||
/// Suitable for testing and single-instance deployments.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IssuerRecord> _issuers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _fingerprintToIssuer = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<IssuerRecord?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_issuers.TryGetValue(issuerId, out var issuer);
|
||||
return Task.FromResult(issuer);
|
||||
}
|
||||
|
||||
public Task<IssuerRecord?> GetIssuerByKeyFingerprintAsync(
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fingerprintToIssuer.TryGetValue(fingerprint, out var issuerId))
|
||||
{
|
||||
_issuers.TryGetValue(issuerId, out var issuer);
|
||||
return Task.FromResult(issuer);
|
||||
}
|
||||
|
||||
return Task.FromResult<IssuerRecord?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IssuerRecord>> ListIssuersAsync(
|
||||
IssuerListOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _issuers.Values.AsEnumerable();
|
||||
|
||||
if (options != null)
|
||||
{
|
||||
if (options.Category.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.Category == options.Category.Value);
|
||||
}
|
||||
|
||||
if (options.MinimumTrustTier.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.TrustTier >= options.MinimumTrustTier.Value);
|
||||
}
|
||||
|
||||
if (options.Status.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.Status == options.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.SearchTerm))
|
||||
{
|
||||
var term = options.SearchTerm;
|
||||
query = query.Where(i =>
|
||||
i.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.IssuerId.Contains(term, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (options.Offset.HasValue)
|
||||
{
|
||||
query = query.Skip(options.Offset.Value);
|
||||
}
|
||||
|
||||
if (options.Limit.HasValue)
|
||||
{
|
||||
query = query.Take(options.Limit.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var result = query
|
||||
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<IssuerRecord>>(result);
|
||||
}
|
||||
|
||||
public Task<IssuerRecord> RegisterIssuerAsync(
|
||||
IssuerRegistration registration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var keyRecords = new List<KeyFingerprintRecord>();
|
||||
|
||||
if (registration.InitialKeys != null)
|
||||
{
|
||||
foreach (var key in registration.InitialKeys)
|
||||
{
|
||||
keyRecords.Add(new KeyFingerprintRecord(
|
||||
Fingerprint: key.Fingerprint,
|
||||
KeyType: key.KeyType,
|
||||
Algorithm: key.Algorithm,
|
||||
Status: KeyFingerprintStatus.Active,
|
||||
RegisteredAt: now,
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
RevokedAt: null,
|
||||
RevocationReason: null));
|
||||
|
||||
_fingerprintToIssuer[key.Fingerprint] = registration.IssuerId;
|
||||
}
|
||||
}
|
||||
|
||||
var record = new IssuerRecord(
|
||||
IssuerId: registration.IssuerId,
|
||||
Name: registration.Name,
|
||||
Category: registration.Category,
|
||||
TrustTier: registration.TrustTier,
|
||||
Status: IssuerStatus.Active,
|
||||
KeyFingerprints: keyRecords,
|
||||
Metadata: registration.Metadata,
|
||||
RegisteredAt: now,
|
||||
LastUpdatedAt: null,
|
||||
RevokedAt: null,
|
||||
RevocationReason: null);
|
||||
|
||||
_issuers[registration.IssuerId] = record;
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeIssuerAsync(
|
||||
string issuerId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_issuers.TryGetValue(issuerId, out var current))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = current with
|
||||
{
|
||||
Status = IssuerStatus.Revoked,
|
||||
RevokedAt = now,
|
||||
RevocationReason = reason,
|
||||
LastUpdatedAt = now
|
||||
};
|
||||
|
||||
_issuers[issuerId] = updated;
|
||||
|
||||
// Also revoke all keys
|
||||
foreach (var key in current.KeyFingerprints)
|
||||
{
|
||||
_fingerprintToIssuer.TryRemove(key.Fingerprint, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IssuerRecord> AddKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
KeyFingerprintRegistration keyRegistration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_issuers.TryGetValue(issuerId, out var current))
|
||||
{
|
||||
throw new InvalidOperationException($"Issuer '{issuerId}' not found");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var newKey = new KeyFingerprintRecord(
|
||||
Fingerprint: keyRegistration.Fingerprint,
|
||||
KeyType: keyRegistration.KeyType,
|
||||
Algorithm: keyRegistration.Algorithm,
|
||||
Status: KeyFingerprintStatus.Active,
|
||||
RegisteredAt: now,
|
||||
ExpiresAt: keyRegistration.ExpiresAt,
|
||||
RevokedAt: null,
|
||||
RevocationReason: null);
|
||||
|
||||
var updatedKeys = current.KeyFingerprints.Append(newKey).ToList();
|
||||
var updated = current with
|
||||
{
|
||||
KeyFingerprints = updatedKeys,
|
||||
LastUpdatedAt = now
|
||||
};
|
||||
|
||||
_issuers[issuerId] = updated;
|
||||
_fingerprintToIssuer[keyRegistration.Fingerprint] = issuerId;
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
string fingerprint,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_issuers.TryGetValue(issuerId, out var current))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var keyIndex = current.KeyFingerprints
|
||||
.Select((k, i) => (k, i))
|
||||
.FirstOrDefault(x => x.k.Fingerprint == fingerprint);
|
||||
|
||||
if (keyIndex.k == null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var revokedKey = keyIndex.k with
|
||||
{
|
||||
Status = KeyFingerprintStatus.Revoked,
|
||||
RevokedAt = now,
|
||||
RevocationReason = reason
|
||||
};
|
||||
|
||||
var updatedKeys = current.KeyFingerprints.ToList();
|
||||
updatedKeys[keyIndex.i] = revokedKey;
|
||||
|
||||
var updated = current with
|
||||
{
|
||||
KeyFingerprints = updatedKeys,
|
||||
LastUpdatedAt = now
|
||||
};
|
||||
|
||||
_issuers[issuerId] = updated;
|
||||
_fingerprintToIssuer.TryRemove(fingerprint, out _);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IssuerTrustValidation> ValidateTrustAsync(
|
||||
string issuerId,
|
||||
string? keyFingerprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (!_issuers.TryGetValue(issuerId, out var issuer))
|
||||
{
|
||||
return Task.FromResult(new IssuerTrustValidation(
|
||||
IsTrusted: false,
|
||||
EffectiveTrustTier: TrustTier.Unknown,
|
||||
IssuerStatus: IssuerTrustStatus.NotRegistered,
|
||||
KeyStatus: null,
|
||||
Warnings: ["Issuer is not registered in the directory"]));
|
||||
}
|
||||
|
||||
var issuerStatus = issuer.Status switch
|
||||
{
|
||||
IssuerStatus.Active => IssuerTrustStatus.Trusted,
|
||||
IssuerStatus.Suspended => IssuerTrustStatus.Suspended,
|
||||
IssuerStatus.Revoked => IssuerTrustStatus.Revoked,
|
||||
_ => IssuerTrustStatus.NotRegistered
|
||||
};
|
||||
|
||||
if (issuerStatus != IssuerTrustStatus.Trusted)
|
||||
{
|
||||
return Task.FromResult(new IssuerTrustValidation(
|
||||
IsTrusted: false,
|
||||
EffectiveTrustTier: TrustTier.Untrusted,
|
||||
IssuerStatus: issuerStatus,
|
||||
KeyStatus: null,
|
||||
Warnings: [$"Issuer status is {issuer.Status}"]));
|
||||
}
|
||||
|
||||
KeyTrustStatus? keyStatus = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyFingerprint))
|
||||
{
|
||||
var key = issuer.KeyFingerprints
|
||||
.FirstOrDefault(k => k.Fingerprint.Equals(keyFingerprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
keyStatus = KeyTrustStatus.NotRegistered;
|
||||
warnings.Add("Key fingerprint is not registered for this issuer");
|
||||
}
|
||||
else if (key.Status == KeyFingerprintStatus.Revoked)
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Revoked;
|
||||
warnings.Add($"Key was revoked: {key.RevocationReason}");
|
||||
}
|
||||
else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Expired;
|
||||
warnings.Add($"Key expired on {key.ExpiresAt.Value:O}");
|
||||
}
|
||||
else
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Valid;
|
||||
}
|
||||
}
|
||||
|
||||
var isTrusted = issuerStatus == IssuerTrustStatus.Trusted &&
|
||||
(keyStatus == null || keyStatus == KeyTrustStatus.Valid);
|
||||
|
||||
var effectiveTier = isTrusted ? issuer.TrustTier : TrustTier.Untrusted;
|
||||
|
||||
return Task.FromResult(new IssuerTrustValidation(
|
||||
IsTrusted: isTrusted,
|
||||
EffectiveTrustTier: effectiveTier,
|
||||
IssuerStatus: issuerStatus,
|
||||
KeyStatus: keyStatus,
|
||||
Warnings: warnings));
|
||||
}
|
||||
}
|
||||
424
src/VexLens/StellaOps.VexLens/Verification/SignatureVerifier.cs
Normal file
424
src/VexLens/StellaOps.VexLens/Verification/SignatureVerifier.cs
Normal file
@@ -0,0 +1,424 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISignatureVerifier"/>.
|
||||
/// Provides basic signature verification with extensible format support.
|
||||
/// </summary>
|
||||
public sealed class SignatureVerifier : ISignatureVerifier
|
||||
{
|
||||
private readonly IIssuerDirectory? _issuerDirectory;
|
||||
private readonly Dictionary<SignatureFormat, ISignatureFormatHandler> _handlers = [];
|
||||
|
||||
public SignatureVerifier(IIssuerDirectory? issuerDirectory = null)
|
||||
{
|
||||
_issuerDirectory = issuerDirectory;
|
||||
|
||||
// Register default handlers
|
||||
RegisterHandler(new DsseSignatureHandler());
|
||||
RegisterHandler(new JwsSignatureHandler());
|
||||
}
|
||||
|
||||
public IReadOnlyList<SignatureFormat> SupportedFormats =>
|
||||
_handlers.Keys.ToList();
|
||||
|
||||
public void RegisterHandler(ISignatureFormatHandler handler)
|
||||
{
|
||||
_handlers[handler.Format] = handler;
|
||||
}
|
||||
|
||||
public async Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_handlers.TryGetValue(request.Format, out var handler))
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Status: SignatureVerificationStatus.UnsupportedFormat,
|
||||
Signer: null,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [new SignatureVerificationError(
|
||||
"ERR_SIG_001",
|
||||
$"Unsupported signature format: {request.Format}",
|
||||
null)],
|
||||
Warnings: []);
|
||||
}
|
||||
|
||||
var result = await handler.VerifyAsync(request, cancellationToken);
|
||||
|
||||
// Validate against issuer directory if available
|
||||
if (result.IsValid && _issuerDirectory != null && result.Signer != null)
|
||||
{
|
||||
var trustValidation = await _issuerDirectory.ValidateTrustAsync(
|
||||
result.Signer.IssuerId,
|
||||
result.Signer.KeyFingerprint,
|
||||
cancellationToken);
|
||||
|
||||
if (!trustValidation.IsTrusted)
|
||||
{
|
||||
var warnings = result.Warnings.ToList();
|
||||
warnings.AddRange(trustValidation.Warnings.Select(w =>
|
||||
new SignatureVerificationWarning("WARN_TRUST", w)));
|
||||
|
||||
return result with
|
||||
{
|
||||
Status = trustValidation.IssuerStatus switch
|
||||
{
|
||||
IssuerTrustStatus.NotRegistered => SignatureVerificationStatus.UntrustedIssuer,
|
||||
IssuerTrustStatus.Revoked => SignatureVerificationStatus.RevokedCertificate,
|
||||
_ => SignatureVerificationStatus.UntrustedIssuer
|
||||
},
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<SignatureExtractionResult> ExtractSignatureInfoAsync(
|
||||
byte[] signedData,
|
||||
SignatureFormat format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_handlers.TryGetValue(format, out var handler))
|
||||
{
|
||||
return new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: null,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: $"Unsupported signature format: {format}");
|
||||
}
|
||||
|
||||
return await handler.ExtractInfoAsync(signedData, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signature format-specific handlers.
|
||||
/// </summary>
|
||||
public interface ISignatureFormatHandler
|
||||
{
|
||||
SignatureFormat Format { get; }
|
||||
|
||||
Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SignatureExtractionResult> ExtractInfoAsync(
|
||||
byte[] signedData,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for DSSE (Dead Simple Signing Envelope) signatures.
|
||||
/// </summary>
|
||||
public sealed class DsseSignatureHandler : ISignatureFormatHandler
|
||||
{
|
||||
public SignatureFormat Format => SignatureFormat.Dsse;
|
||||
|
||||
public Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = ParseDsseEnvelope(request.Content);
|
||||
if (envelope == null)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_DSSE_001", "Invalid DSSE envelope format"));
|
||||
}
|
||||
|
||||
if (envelope.Signatures == null || envelope.Signatures.Count == 0)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_DSSE_002", "DSSE envelope has no signatures"));
|
||||
}
|
||||
|
||||
// Extract signer info from first signature
|
||||
var firstSig = envelope.Signatures[0];
|
||||
var signer = ExtractSignerFromDsse(firstSig);
|
||||
|
||||
// For now, we validate structure but don't perform cryptographic verification
|
||||
// Full verification would require access to public keys
|
||||
var warnings = new List<SignatureVerificationWarning>
|
||||
{
|
||||
new("WARN_DSSE_001", "Cryptographic verification not performed; structure validated only")
|
||||
};
|
||||
|
||||
return Task.FromResult(new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
Status: SignatureVerificationStatus.Valid,
|
||||
Signer: signer,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [],
|
||||
Warnings: warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_DSSE_999", $"DSSE parsing error: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SignatureExtractionResult> ExtractInfoAsync(
|
||||
byte[] signedData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = ParseDsseEnvelope(signedData);
|
||||
if (envelope == null)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Dsse,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: "Invalid DSSE envelope format"));
|
||||
}
|
||||
|
||||
var signer = envelope.Signatures?.Count > 0
|
||||
? ExtractSignerFromDsse(envelope.Signatures[0])
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: true,
|
||||
DetectedFormat: SignatureFormat.Dsse,
|
||||
Signer: signer,
|
||||
Certificates: null,
|
||||
ErrorMessage: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Dsse,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static DsseEnvelope? ParseDsseEnvelope(byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(data);
|
||||
return JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SignerInfo? ExtractSignerFromDsse(DsseSignature sig)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sig.KeyId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute fingerprint from keyid
|
||||
var fingerprint = sig.KeyId;
|
||||
if (fingerprint.StartsWith("SHA256:"))
|
||||
{
|
||||
fingerprint = fingerprint[7..];
|
||||
}
|
||||
|
||||
return new SignerInfo(
|
||||
IssuerId: sig.KeyId,
|
||||
Name: null,
|
||||
Email: null,
|
||||
Organization: null,
|
||||
KeyFingerprint: fingerprint,
|
||||
Algorithm: "unknown",
|
||||
SignedAt: null);
|
||||
}
|
||||
|
||||
private static SignatureVerificationResult CreateError(string code, string message)
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Status: SignatureVerificationStatus.InvalidSignature,
|
||||
Signer: null,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [new SignatureVerificationError(code, message, null)],
|
||||
Warnings: []);
|
||||
}
|
||||
|
||||
private sealed class DsseEnvelope
|
||||
{
|
||||
public string? PayloadType { get; set; }
|
||||
public string? Payload { get; set; }
|
||||
public List<DsseSignature>? Signatures { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DsseSignature
|
||||
{
|
||||
public string? KeyId { get; set; }
|
||||
public string? Sig { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for JWS (JSON Web Signature) signatures.
|
||||
/// </summary>
|
||||
public sealed class JwsSignatureHandler : ISignatureFormatHandler
|
||||
{
|
||||
public SignatureFormat Format => SignatureFormat.Jws;
|
||||
|
||||
public Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwsString = Encoding.UTF8.GetString(request.Content);
|
||||
var parts = jwsString.Split('.');
|
||||
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_JWS_001", "Invalid JWS format: expected 3 parts"));
|
||||
}
|
||||
|
||||
// Parse header
|
||||
var headerJson = Base64UrlDecode(parts[0]);
|
||||
var header = JsonSerializer.Deserialize<JwsHeader>(headerJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (header == null)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_JWS_002", "Invalid JWS header"));
|
||||
}
|
||||
|
||||
var signer = new SignerInfo(
|
||||
IssuerId: header.Kid ?? "unknown",
|
||||
Name: null,
|
||||
Email: null,
|
||||
Organization: null,
|
||||
KeyFingerprint: header.Kid ?? ComputeFingerprint(parts[0]),
|
||||
Algorithm: header.Alg ?? "unknown",
|
||||
SignedAt: null);
|
||||
|
||||
var warnings = new List<SignatureVerificationWarning>
|
||||
{
|
||||
new("WARN_JWS_001", "Cryptographic verification not performed; structure validated only")
|
||||
};
|
||||
|
||||
return Task.FromResult(new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
Status: SignatureVerificationStatus.Valid,
|
||||
Signer: signer,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [],
|
||||
Warnings: warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_JWS_999", $"JWS parsing error: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SignatureExtractionResult> ExtractInfoAsync(
|
||||
byte[] signedData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwsString = Encoding.UTF8.GetString(signedData);
|
||||
var parts = jwsString.Split('.');
|
||||
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Jws,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: "Invalid JWS format"));
|
||||
}
|
||||
|
||||
var headerJson = Base64UrlDecode(parts[0]);
|
||||
var header = JsonSerializer.Deserialize<JwsHeader>(headerJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
var signer = new SignerInfo(
|
||||
IssuerId: header?.Kid ?? "unknown",
|
||||
Name: null,
|
||||
Email: null,
|
||||
Organization: null,
|
||||
KeyFingerprint: header?.Kid ?? ComputeFingerprint(parts[0]),
|
||||
Algorithm: header?.Alg ?? "unknown",
|
||||
SignedAt: null);
|
||||
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: true,
|
||||
DetectedFormat: SignatureFormat.Jws,
|
||||
Signer: signer,
|
||||
Certificates: null,
|
||||
ErrorMessage: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Jws,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64UrlDecode(string input)
|
||||
{
|
||||
var output = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (output.Length % 4)
|
||||
{
|
||||
case 2: output += "=="; break;
|
||||
case 3: output += "="; break;
|
||||
}
|
||||
var bytes = Convert.FromBase64String(output);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(string headerBase64)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(headerBase64));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static SignatureVerificationResult CreateError(string code, string message)
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Status: SignatureVerificationStatus.InvalidSignature,
|
||||
Signer: null,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [new SignatureVerificationError(code, message, null)],
|
||||
Warnings: []);
|
||||
}
|
||||
|
||||
private sealed class JwsHeader
|
||||
{
|
||||
public string? Alg { get; set; }
|
||||
public string? Kid { get; set; }
|
||||
public string? Typ { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user