up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,55 +1,55 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core;
public interface IProofOfEntitlementIntrospector
{
ValueTask<ProofOfEntitlementResult> IntrospectAsync(
ProofOfEntitlement proof,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface IReleaseIntegrityVerifier
{
ValueTask<ReleaseVerificationResult> VerifyAsync(
string scannerImageDigest,
CancellationToken cancellationToken);
}
public interface ISignerQuotaService
{
ValueTask EnsureWithinLimitsAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface IDsseSigner
{
ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface ISignerAuditSink
{
ValueTask<string> WriteAsync(
SigningRequest request,
SigningBundle bundle,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface ISignerPipeline
{
ValueTask<SigningOutcome> SignAsync(
SigningRequest request,
CallerContext caller,
CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core;
public interface IProofOfEntitlementIntrospector
{
ValueTask<ProofOfEntitlementResult> IntrospectAsync(
ProofOfEntitlement proof,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface IReleaseIntegrityVerifier
{
ValueTask<ReleaseVerificationResult> VerifyAsync(
string scannerImageDigest,
CancellationToken cancellationToken);
}
public interface ISignerQuotaService
{
ValueTask EnsureWithinLimitsAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface IDsseSigner
{
ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface ISignerAuditSink
{
ValueTask<string> WriteAsync(
SigningRequest request,
SigningBundle bundle,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken);
}
public interface ISignerPipeline
{
ValueTask<SigningOutcome> SignAsync(
SigningRequest request,
CallerContext caller,
CancellationToken cancellationToken);
}

View File

@@ -1,105 +1,105 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Signer.Core;
public enum SignerPoEFormat
{
Jwt,
Mtls,
}
public enum SigningMode
{
Keyless,
Kms,
}
public sealed record SigningSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
public sealed record ProofOfEntitlement(
SignerPoEFormat Format,
string Value);
public sealed record SigningOptions(
SigningMode Mode,
int? ExpirySeconds,
string ReturnBundle);
public sealed record SigningRequest(
IReadOnlyList<SigningSubject> Subjects,
string PredicateType,
JsonDocument Predicate,
string ScannerImageDigest,
ProofOfEntitlement ProofOfEntitlement,
SigningOptions Options);
public sealed record CallerContext(
string Subject,
string Tenant,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Audiences,
string? SenderBinding,
string? ClientCertificateThumbprint);
public sealed record ProofOfEntitlementResult(
string LicenseId,
string CustomerId,
string Plan,
int MaxArtifactBytes,
int QpsLimit,
int QpsRemaining,
DateTimeOffset ExpiresAtUtc);
public sealed record ReleaseVerificationResult(
bool Trusted,
string? ReleaseSigner);
public sealed record SigningIdentity(
string Mode,
string Issuer,
string Subject,
DateTimeOffset? ExpiresAtUtc);
public sealed record SigningMetadata(
SigningIdentity Identity,
IReadOnlyList<string> CertificateChain,
string ProviderName,
string AlgorithmId);
public sealed record SigningBundle(
DsseEnvelope Envelope,
SigningMetadata Metadata);
public sealed record PolicyCounters(
string Plan,
int MaxArtifactBytes,
int QpsRemaining);
public sealed record SigningOutcome(
SigningBundle Bundle,
PolicyCounters Policy,
string AuditId);
public sealed record SignerAuditEntry(
string AuditId,
DateTimeOffset TimestampUtc,
string Subject,
string Tenant,
string Plan,
string ScannerImageDigest,
string SigningMode,
string ProviderName,
IReadOnlyList<SigningSubject> Subjects);
public sealed record DsseEnvelope(
string Payload,
string PayloadType,
IReadOnlyList<DsseSignature> Signatures);
public sealed record DsseSignature(
string Signature,
string? KeyId);
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Signer.Core;
public enum SignerPoEFormat
{
Jwt,
Mtls,
}
public enum SigningMode
{
Keyless,
Kms,
}
public sealed record SigningSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
public sealed record ProofOfEntitlement(
SignerPoEFormat Format,
string Value);
public sealed record SigningOptions(
SigningMode Mode,
int? ExpirySeconds,
string ReturnBundle);
public sealed record SigningRequest(
IReadOnlyList<SigningSubject> Subjects,
string PredicateType,
JsonDocument Predicate,
string ScannerImageDigest,
ProofOfEntitlement ProofOfEntitlement,
SigningOptions Options);
public sealed record CallerContext(
string Subject,
string Tenant,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Audiences,
string? SenderBinding,
string? ClientCertificateThumbprint);
public sealed record ProofOfEntitlementResult(
string LicenseId,
string CustomerId,
string Plan,
int MaxArtifactBytes,
int QpsLimit,
int QpsRemaining,
DateTimeOffset ExpiresAtUtc);
public sealed record ReleaseVerificationResult(
bool Trusted,
string? ReleaseSigner);
public sealed record SigningIdentity(
string Mode,
string Issuer,
string Subject,
DateTimeOffset? ExpiresAtUtc);
public sealed record SigningMetadata(
SigningIdentity Identity,
IReadOnlyList<string> CertificateChain,
string ProviderName,
string AlgorithmId);
public sealed record SigningBundle(
DsseEnvelope Envelope,
SigningMetadata Metadata);
public sealed record PolicyCounters(
string Plan,
int MaxArtifactBytes,
int QpsRemaining);
public sealed record SigningOutcome(
SigningBundle Bundle,
PolicyCounters Policy,
string AuditId);
public sealed record SignerAuditEntry(
string AuditId,
DateTimeOffset TimestampUtc,
string Subject,
string Tenant,
string Plan,
string ScannerImageDigest,
string SigningMode,
string ProviderName,
IReadOnlyList<SigningSubject> Subjects);
public sealed record DsseEnvelope(
string Payload,
string PayloadType,
IReadOnlyList<DsseSignature> Signatures);
public sealed record DsseSignature(
string Signature,
string? KeyId);

View File

@@ -1,46 +1,46 @@
using System;
namespace StellaOps.Signer.Core;
public abstract class SignerException : Exception
{
protected SignerException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}
public sealed class SignerValidationException : SignerException
{
public SignerValidationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerAuthorizationException : SignerException
{
public SignerAuthorizationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerReleaseVerificationException : SignerException
{
public SignerReleaseVerificationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerQuotaException : SignerException
{
public SignerQuotaException(string code, string message)
: base(code, message)
{
}
}
using System;
namespace StellaOps.Signer.Core;
public abstract class SignerException : Exception
{
protected SignerException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}
public sealed class SignerValidationException : SignerException
{
public SignerValidationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerAuthorizationException : SignerException
{
public SignerAuthorizationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerReleaseVerificationException : SignerException
{
public SignerReleaseVerificationException(string code, string message)
: base(code, message)
{
}
}
public sealed class SignerQuotaException : SignerException
{
public SignerQuotaException(string code, string message)
: base(code, message)
{
}
}

View File

@@ -1,147 +1,147 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core;
public sealed class SignerPipeline : ISignerPipeline
{
private const string RequiredScope = "signer.sign";
private const string RequiredAudience = "signer";
private readonly IProofOfEntitlementIntrospector _poe;
private readonly IReleaseIntegrityVerifier _releaseVerifier;
private readonly ISignerQuotaService _quotaService;
private readonly IDsseSigner _signer;
private readonly ISignerAuditSink _auditSink;
private readonly TimeProvider _timeProvider;
public SignerPipeline(
IProofOfEntitlementIntrospector poe,
IReleaseIntegrityVerifier releaseVerifier,
ISignerQuotaService quotaService,
IDsseSigner signer,
ISignerAuditSink auditSink,
TimeProvider timeProvider)
{
_poe = poe ?? throw new ArgumentNullException(nameof(poe));
_releaseVerifier = releaseVerifier ?? throw new ArgumentNullException(nameof(releaseVerifier));
_quotaService = quotaService ?? throw new ArgumentNullException(nameof(quotaService));
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<SigningOutcome> SignAsync(
SigningRequest request,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(caller);
ValidateCaller(caller);
ValidateRequest(request);
var entitlement = await _poe
.IntrospectAsync(request.ProofOfEntitlement, caller, cancellationToken)
.ConfigureAwait(false);
if (entitlement.ExpiresAtUtc <= _timeProvider.GetUtcNow())
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement is expired.");
}
var releaseResult = await _releaseVerifier
.VerifyAsync(request.ScannerImageDigest, cancellationToken)
.ConfigureAwait(false);
if (!releaseResult.Trusted)
{
throw new SignerReleaseVerificationException("release_untrusted", "Scanner image digest failed release verification.");
}
await _quotaService
.EnsureWithinLimitsAsync(request, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var bundle = await _signer
.SignAsync(request, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var auditId = await _auditSink
.WriteAsync(request, bundle, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var outcome = new SigningOutcome(
bundle,
new PolicyCounters(entitlement.Plan, entitlement.MaxArtifactBytes, entitlement.QpsRemaining),
auditId);
return outcome;
}
private static void ValidateCaller(CallerContext caller)
{
if (string.IsNullOrWhiteSpace(caller.Subject))
{
throw new SignerAuthorizationException("invalid_caller", "Caller subject is required.");
}
if (string.IsNullOrWhiteSpace(caller.Tenant))
{
throw new SignerAuthorizationException("invalid_caller", "Caller tenant is required.");
}
if (!caller.Scopes.Contains(RequiredScope, StringComparer.OrdinalIgnoreCase))
{
throw new SignerAuthorizationException("insufficient_scope", $"Scope '{RequiredScope}' is required.");
}
if (!caller.Audiences.Contains(RequiredAudience, StringComparer.OrdinalIgnoreCase))
{
throw new SignerAuthorizationException("invalid_audience", $"Audience '{RequiredAudience}' is required.");
}
}
private static void ValidateRequest(SigningRequest request)
{
if (request.Subjects.Count == 0)
{
throw new SignerValidationException("subject_missing", "At least one subject must be provided.");
}
foreach (var subject in request.Subjects)
{
if (string.IsNullOrWhiteSpace(subject.Name))
{
throw new SignerValidationException("subject_invalid", "Subject name is required.");
}
if (subject.Digest is null || subject.Digest.Count == 0)
{
throw new SignerValidationException("subject_digest_invalid", "Subject digest is required.");
}
}
if (string.IsNullOrWhiteSpace(request.PredicateType))
{
throw new SignerValidationException("predicate_type_missing", "Predicate type is required.");
}
if (request.Predicate is null || request.Predicate.RootElement.ValueKind == JsonValueKind.Undefined)
{
throw new SignerValidationException("predicate_missing", "Predicate payload is required.");
}
if (string.IsNullOrWhiteSpace(request.ScannerImageDigest))
{
throw new SignerValidationException("scanner_digest_missing", "Scanner image digest is required.");
}
if (request.ProofOfEntitlement is null)
{
throw new SignerValidationException("poe_missing", "Proof of entitlement is required.");
}
}
}
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signer.Core;
public sealed class SignerPipeline : ISignerPipeline
{
private const string RequiredScope = "signer.sign";
private const string RequiredAudience = "signer";
private readonly IProofOfEntitlementIntrospector _poe;
private readonly IReleaseIntegrityVerifier _releaseVerifier;
private readonly ISignerQuotaService _quotaService;
private readonly IDsseSigner _signer;
private readonly ISignerAuditSink _auditSink;
private readonly TimeProvider _timeProvider;
public SignerPipeline(
IProofOfEntitlementIntrospector poe,
IReleaseIntegrityVerifier releaseVerifier,
ISignerQuotaService quotaService,
IDsseSigner signer,
ISignerAuditSink auditSink,
TimeProvider timeProvider)
{
_poe = poe ?? throw new ArgumentNullException(nameof(poe));
_releaseVerifier = releaseVerifier ?? throw new ArgumentNullException(nameof(releaseVerifier));
_quotaService = quotaService ?? throw new ArgumentNullException(nameof(quotaService));
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<SigningOutcome> SignAsync(
SigningRequest request,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(caller);
ValidateCaller(caller);
ValidateRequest(request);
var entitlement = await _poe
.IntrospectAsync(request.ProofOfEntitlement, caller, cancellationToken)
.ConfigureAwait(false);
if (entitlement.ExpiresAtUtc <= _timeProvider.GetUtcNow())
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement is expired.");
}
var releaseResult = await _releaseVerifier
.VerifyAsync(request.ScannerImageDigest, cancellationToken)
.ConfigureAwait(false);
if (!releaseResult.Trusted)
{
throw new SignerReleaseVerificationException("release_untrusted", "Scanner image digest failed release verification.");
}
await _quotaService
.EnsureWithinLimitsAsync(request, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var bundle = await _signer
.SignAsync(request, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var auditId = await _auditSink
.WriteAsync(request, bundle, entitlement, caller, cancellationToken)
.ConfigureAwait(false);
var outcome = new SigningOutcome(
bundle,
new PolicyCounters(entitlement.Plan, entitlement.MaxArtifactBytes, entitlement.QpsRemaining),
auditId);
return outcome;
}
private static void ValidateCaller(CallerContext caller)
{
if (string.IsNullOrWhiteSpace(caller.Subject))
{
throw new SignerAuthorizationException("invalid_caller", "Caller subject is required.");
}
if (string.IsNullOrWhiteSpace(caller.Tenant))
{
throw new SignerAuthorizationException("invalid_caller", "Caller tenant is required.");
}
if (!caller.Scopes.Contains(RequiredScope, StringComparer.OrdinalIgnoreCase))
{
throw new SignerAuthorizationException("insufficient_scope", $"Scope '{RequiredScope}' is required.");
}
if (!caller.Audiences.Contains(RequiredAudience, StringComparer.OrdinalIgnoreCase))
{
throw new SignerAuthorizationException("invalid_audience", $"Audience '{RequiredAudience}' is required.");
}
}
private static void ValidateRequest(SigningRequest request)
{
if (request.Subjects.Count == 0)
{
throw new SignerValidationException("subject_missing", "At least one subject must be provided.");
}
foreach (var subject in request.Subjects)
{
if (string.IsNullOrWhiteSpace(subject.Name))
{
throw new SignerValidationException("subject_invalid", "Subject name is required.");
}
if (subject.Digest is null || subject.Digest.Count == 0)
{
throw new SignerValidationException("subject_digest_invalid", "Subject digest is required.");
}
}
if (string.IsNullOrWhiteSpace(request.PredicateType))
{
throw new SignerValidationException("predicate_type_missing", "Predicate type is required.");
}
if (request.Predicate is null || request.Predicate.RootElement.ValueKind == JsonValueKind.Undefined)
{
throw new SignerValidationException("predicate_missing", "Predicate payload is required.");
}
if (string.IsNullOrWhiteSpace(request.ScannerImageDigest))
{
throw new SignerValidationException("scanner_digest_missing", "Scanner image digest is required.");
}
if (request.ProofOfEntitlement is null)
{
throw new SignerValidationException("poe_missing", "Proof of entitlement is required.");
}
}
}

View File

@@ -1,169 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Provenance.Attestation;
namespace StellaOps.Signer.Core;
/// <summary>
/// Builder for in-toto statement payloads with support for StellaOps predicate types.
/// Delegates canonicalization to the Provenance library for deterministic serialization.
/// </summary>
public static class SignerStatementBuilder
{
private const string InTotoStatementTypeV01 = "https://in-toto.io/Statement/v0.1";
private const string InTotoStatementTypeV1 = "https://in-toto.io/Statement/v1";
/// <summary>
/// Builds an in-toto statement payload from a signing request.
/// Uses canonical JSON serialization for deterministic output.
/// </summary>
/// <param name="request">The signing request containing subjects and predicate.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] BuildStatementPayload(SigningRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return BuildStatementPayload(request, InTotoStatementTypeV01);
}
/// <summary>
/// Builds an in-toto statement payload with explicit statement type version.
/// </summary>
/// <param name="request">The signing request.</param>
/// <param name="statementType">The in-toto statement type URI.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] BuildStatementPayload(SigningRequest request, string statementType)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(statementType);
var statement = BuildStatement(request, statementType);
return SerializeCanonical(statement);
}
/// <summary>
/// Builds an in-toto statement object from a signing request.
/// </summary>
public static InTotoStatement BuildStatement(SigningRequest request, string? statementType = null)
{
ArgumentNullException.ThrowIfNull(request);
var subjects = BuildSubjects(request.Subjects);
var predicateType = NormalizePredicateType(request.PredicateType);
return new InTotoStatement(
Type: statementType ?? InTotoStatementTypeV01,
PredicateType: predicateType,
Subject: subjects,
Predicate: request.Predicate.RootElement);
}
/// <summary>
/// Builds statement subjects with canonicalized digest entries.
/// </summary>
private static IReadOnlyList<InTotoSubject> BuildSubjects(IReadOnlyList<SigningSubject> requestSubjects)
{
var subjects = new List<InTotoSubject>(requestSubjects.Count);
foreach (var subject in requestSubjects)
{
// Sort digest keys and normalize to lowercase for determinism
var digest = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var kvp in subject.Digest)
{
digest[kvp.Key.ToLowerInvariant()] = kvp.Value;
}
subjects.Add(new InTotoSubject(subject.Name, digest));
}
return subjects;
}
/// <summary>
/// Normalizes predicate type URIs for consistency.
/// </summary>
private static string NormalizePredicateType(string predicateType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(predicateType);
// Normalize common variations
return predicateType.Trim();
}
/// <summary>
/// Serializes the statement to canonical JSON bytes using Provenance library.
/// </summary>
private static byte[] SerializeCanonical(InTotoStatement statement)
{
// Build the statement object for serialization
var statementObj = new
{
_type = statement.Type,
predicateType = statement.PredicateType,
subject = statement.Subject.Select(s => new
{
name = s.Name,
digest = s.Digest
}).ToArray(),
predicate = statement.Predicate
};
// Use CanonicalJson from Provenance library for deterministic serialization
return CanonicalJson.SerializeToUtf8Bytes(statementObj);
}
/// <summary>
/// Validates that a predicate type is well-known and supported.
/// </summary>
/// <param name="predicateType">The predicate type URI to validate.</param>
/// <returns>True if the predicate type is well-known; false otherwise.</returns>
public static bool IsWellKnownPredicateType(string predicateType)
{
if (string.IsNullOrWhiteSpace(predicateType))
{
return false;
}
return PredicateTypes.IsStellaOpsType(predicateType) ||
PredicateTypes.IsSlsaProvenance(predicateType) ||
predicateType == PredicateTypes.CycloneDxSbom ||
predicateType == PredicateTypes.SpdxSbom ||
predicateType == PredicateTypes.OpenVex;
}
/// <summary>
/// Gets the recommended statement type version for a given predicate type.
/// </summary>
/// <param name="predicateType">The predicate type URI.</param>
/// <returns>The recommended in-toto statement type URI.</returns>
public static string GetRecommendedStatementType(string predicateType)
{
// SLSA v1 and StellaOps types should use Statement v1
if (predicateType == PredicateTypes.SlsaProvenanceV1 ||
PredicateTypes.IsStellaOpsType(predicateType))
{
return InTotoStatementTypeV1;
}
// Default to v0.1 for backwards compatibility
return InTotoStatementTypeV01;
}
}
/// <summary>
/// Represents an in-toto statement.
/// </summary>
public sealed record InTotoStatement(
string Type,
string PredicateType,
IReadOnlyList<InTotoSubject> Subject,
JsonElement Predicate);
/// <summary>
/// Represents a subject in an in-toto statement.
/// </summary>
public sealed record InTotoSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Provenance.Attestation;
namespace StellaOps.Signer.Core;
/// <summary>
/// Builder for in-toto statement payloads with support for StellaOps predicate types.
/// Delegates canonicalization to the Provenance library for deterministic serialization.
/// </summary>
public static class SignerStatementBuilder
{
private const string InTotoStatementTypeV01 = "https://in-toto.io/Statement/v0.1";
private const string InTotoStatementTypeV1 = "https://in-toto.io/Statement/v1";
/// <summary>
/// Builds an in-toto statement payload from a signing request.
/// Uses canonical JSON serialization for deterministic output.
/// </summary>
/// <param name="request">The signing request containing subjects and predicate.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] BuildStatementPayload(SigningRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return BuildStatementPayload(request, InTotoStatementTypeV01);
}
/// <summary>
/// Builds an in-toto statement payload with explicit statement type version.
/// </summary>
/// <param name="request">The signing request.</param>
/// <param name="statementType">The in-toto statement type URI.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] BuildStatementPayload(SigningRequest request, string statementType)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(statementType);
var statement = BuildStatement(request, statementType);
return SerializeCanonical(statement);
}
/// <summary>
/// Builds an in-toto statement object from a signing request.
/// </summary>
public static InTotoStatement BuildStatement(SigningRequest request, string? statementType = null)
{
ArgumentNullException.ThrowIfNull(request);
var subjects = BuildSubjects(request.Subjects);
var predicateType = NormalizePredicateType(request.PredicateType);
return new InTotoStatement(
Type: statementType ?? InTotoStatementTypeV01,
PredicateType: predicateType,
Subject: subjects,
Predicate: request.Predicate.RootElement);
}
/// <summary>
/// Builds statement subjects with canonicalized digest entries.
/// </summary>
private static IReadOnlyList<InTotoSubject> BuildSubjects(IReadOnlyList<SigningSubject> requestSubjects)
{
var subjects = new List<InTotoSubject>(requestSubjects.Count);
foreach (var subject in requestSubjects)
{
// Sort digest keys and normalize to lowercase for determinism
var digest = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var kvp in subject.Digest)
{
digest[kvp.Key.ToLowerInvariant()] = kvp.Value;
}
subjects.Add(new InTotoSubject(subject.Name, digest));
}
return subjects;
}
/// <summary>
/// Normalizes predicate type URIs for consistency.
/// </summary>
private static string NormalizePredicateType(string predicateType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(predicateType);
// Normalize common variations
return predicateType.Trim();
}
/// <summary>
/// Serializes the statement to canonical JSON bytes using Provenance library.
/// </summary>
private static byte[] SerializeCanonical(InTotoStatement statement)
{
// Build the statement object for serialization
var statementObj = new
{
_type = statement.Type,
predicateType = statement.PredicateType,
subject = statement.Subject.Select(s => new
{
name = s.Name,
digest = s.Digest
}).ToArray(),
predicate = statement.Predicate
};
// Use CanonicalJson from Provenance library for deterministic serialization
return CanonicalJson.SerializeToUtf8Bytes(statementObj);
}
/// <summary>
/// Validates that a predicate type is well-known and supported.
/// </summary>
/// <param name="predicateType">The predicate type URI to validate.</param>
/// <returns>True if the predicate type is well-known; false otherwise.</returns>
public static bool IsWellKnownPredicateType(string predicateType)
{
if (string.IsNullOrWhiteSpace(predicateType))
{
return false;
}
return PredicateTypes.IsStellaOpsType(predicateType) ||
PredicateTypes.IsSlsaProvenance(predicateType) ||
predicateType == PredicateTypes.CycloneDxSbom ||
predicateType == PredicateTypes.SpdxSbom ||
predicateType == PredicateTypes.OpenVex;
}
/// <summary>
/// Gets the recommended statement type version for a given predicate type.
/// </summary>
/// <param name="predicateType">The predicate type URI.</param>
/// <returns>The recommended in-toto statement type URI.</returns>
public static string GetRecommendedStatementType(string predicateType)
{
// SLSA v1 and StellaOps types should use Statement v1
if (predicateType == PredicateTypes.SlsaProvenanceV1 ||
PredicateTypes.IsStellaOpsType(predicateType))
{
return InTotoStatementTypeV1;
}
// Default to v0.1 for backwards compatibility
return InTotoStatementTypeV01;
}
}
/// <summary>
/// Represents an in-toto statement.
/// </summary>
public sealed record InTotoStatement(
string Type,
string PredicateType,
IReadOnlyList<InTotoSubject> Subject,
JsonElement Predicate);
/// <summary>
/// Represents a subject in an in-toto statement.
/// </summary>
public sealed record InTotoSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);

View File

@@ -1,49 +1,49 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Auditing;
public sealed class InMemorySignerAuditSink : ISignerAuditSink
{
private readonly ConcurrentDictionary<string, SignerAuditEntry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemorySignerAuditSink> _logger;
public InMemorySignerAuditSink(TimeProvider timeProvider, ILogger<InMemorySignerAuditSink> logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<string> WriteAsync(
SigningRequest request,
SigningBundle bundle,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var auditId = Guid.NewGuid().ToString("d");
var entry = new SignerAuditEntry(
auditId,
_timeProvider.GetUtcNow(),
caller.Subject,
caller.Tenant,
entitlement.Plan,
request.ScannerImageDigest,
bundle.Metadata.Identity.Mode,
bundle.Metadata.ProviderName,
request.Subjects);
_entries[auditId] = entry;
_logger.LogInformation("Signer audit event {AuditId} recorded for tenant {Tenant}", auditId, caller.Tenant);
return ValueTask.FromResult(auditId);
}
}
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Auditing;
public sealed class InMemorySignerAuditSink : ISignerAuditSink
{
private readonly ConcurrentDictionary<string, SignerAuditEntry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemorySignerAuditSink> _logger;
public InMemorySignerAuditSink(TimeProvider timeProvider, ILogger<InMemorySignerAuditSink> logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<string> WriteAsync(
SigningRequest request,
SigningBundle bundle,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var auditId = Guid.NewGuid().ToString("d");
var entry = new SignerAuditEntry(
auditId,
_timeProvider.GetUtcNow(),
caller.Subject,
caller.Tenant,
entitlement.Plan,
request.ScannerImageDigest,
bundle.Metadata.Identity.Mode,
bundle.Metadata.ProviderName,
request.Subjects);
_entries[auditId] = entry;
_logger.LogInformation("Signer audit event {AuditId} recorded for tenant {Tenant}", auditId, caller.Tenant);
return ValueTask.FromResult(auditId);
}
}

View File

@@ -1,16 +1,16 @@
using System;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerCryptoOptions
{
public string KeyId { get; set; } = "signer-kms-default";
public string AlgorithmId { get; set; } = "HS256";
public string Secret { get; set; } = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("stellaops-signer-secret"));
public string ProviderName { get; set; } = "InMemoryHmacProvider";
public string Mode { get; set; } = "kms";
}
using System;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerCryptoOptions
{
public string KeyId { get; set; } = "signer-kms-default";
public string AlgorithmId { get; set; } = "HS256";
public string Secret { get; set; } = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("stellaops-signer-secret"));
public string ProviderName { get; set; } = "InMemoryHmacProvider";
public string Mode { get; set; } = "kms";
}

View File

@@ -1,19 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerEntitlementOptions
{
public IDictionary<string, SignerEntitlementDefinition> Tokens { get; } =
new Dictionary<string, SignerEntitlementDefinition>(StringComparer.Ordinal);
}
public sealed record SignerEntitlementDefinition(
string LicenseId,
string CustomerId,
string Plan,
int MaxArtifactBytes,
int QpsLimit,
int QpsRemaining,
DateTimeOffset ExpiresAtUtc);
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerEntitlementOptions
{
public IDictionary<string, SignerEntitlementDefinition> Tokens { get; } =
new Dictionary<string, SignerEntitlementDefinition>(StringComparer.Ordinal);
}
public sealed record SignerEntitlementDefinition(
string LicenseId,
string CustomerId,
string Plan,
int MaxArtifactBytes,
int QpsLimit,
int QpsRemaining,
DateTimeOffset ExpiresAtUtc);

View File

@@ -1,11 +1,11 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerReleaseVerificationOptions
{
public ISet<string> TrustedScannerDigests { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public string TrustedSigner { get; set; } = "StellaOps Release";
}
using System;
using System.Collections.Generic;
namespace StellaOps.Signer.Infrastructure.Options;
public sealed class SignerReleaseVerificationOptions
{
public ISet<string> TrustedScannerDigests { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public string TrustedSigner { get; set; } = "StellaOps Release";
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
using ProofOfEntitlementRecord = StellaOps.Signer.Core.ProofOfEntitlement;
namespace StellaOps.Signer.Infrastructure.ProofOfEntitlement;
public sealed class InMemoryProofOfEntitlementIntrospector : IProofOfEntitlementIntrospector
{
private readonly IOptionsMonitor<SignerEntitlementOptions> _options;
private readonly TimeProvider _timeProvider;
public InMemoryProofOfEntitlementIntrospector(
IOptionsMonitor<SignerEntitlementOptions> options,
TimeProvider timeProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
}
namespace StellaOps.Signer.Infrastructure.ProofOfEntitlement;
public sealed class InMemoryProofOfEntitlementIntrospector : IProofOfEntitlementIntrospector
{
private readonly IOptionsMonitor<SignerEntitlementOptions> _options;
private readonly TimeProvider _timeProvider;
public InMemoryProofOfEntitlementIntrospector(
IOptionsMonitor<SignerEntitlementOptions> options,
TimeProvider timeProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<ProofOfEntitlementResult> IntrospectAsync(
ProofOfEntitlementRecord proof,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(proof);
ArgumentNullException.ThrowIfNull(caller);
var token = proof.Value ?? string.Empty;
var snapshot = _options.CurrentValue;
if (!snapshot.Tokens.TryGetValue(token, out var definition))
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement is invalid or revoked.");
}
if (definition.ExpiresAtUtc <= _timeProvider.GetUtcNow())
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement has expired.");
}
var result = new ProofOfEntitlementResult(
definition.LicenseId,
definition.CustomerId,
definition.Plan,
definition.MaxArtifactBytes,
definition.QpsLimit,
definition.QpsRemaining,
definition.ExpiresAtUtc);
return ValueTask.FromResult(result);
}
}
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(proof);
ArgumentNullException.ThrowIfNull(caller);
var token = proof.Value ?? string.Empty;
var snapshot = _options.CurrentValue;
if (!snapshot.Tokens.TryGetValue(token, out var definition))
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement is invalid or revoked.");
}
if (definition.ExpiresAtUtc <= _timeProvider.GetUtcNow())
{
throw new SignerAuthorizationException("entitlement_denied", "Proof of entitlement has expired.");
}
var result = new ProofOfEntitlementResult(
definition.LicenseId,
definition.CustomerId,
definition.Plan,
definition.MaxArtifactBytes,
definition.QpsLimit,
definition.QpsRemaining,
definition.ExpiresAtUtc);
return ValueTask.FromResult(result);
}
}

View File

@@ -1,100 +1,100 @@
using System;
using System.Collections.Concurrent;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Quotas;
public sealed class InMemoryQuotaService : ISignerQuotaService
{
private readonly ConcurrentDictionary<string, QuotaWindow> _windows = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryQuotaService> _logger;
public InMemoryQuotaService(TimeProvider timeProvider, ILogger<InMemoryQuotaService> logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask EnsureWithinLimitsAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var payloadSize = EstimatePayloadSize(request);
if (payloadSize > entitlement.MaxArtifactBytes)
{
throw new SignerQuotaException("artifact_too_large", $"Artifact size {payloadSize} exceeds plan cap ({entitlement.MaxArtifactBytes}).");
}
if (entitlement.QpsLimit <= 0)
{
return ValueTask.CompletedTask;
}
var window = _windows.GetOrAdd(caller.Tenant, static _ => new QuotaWindow());
lock (window)
{
var now = _timeProvider.GetUtcNow();
if (window.ResetAt <= now)
{
window.Reset(now, entitlement.QpsLimit);
}
if (window.Remaining <= 0)
{
_logger.LogWarning("Quota exceeded for tenant {Tenant}", caller.Tenant);
throw new SignerQuotaException("plan_throttled", "Plan QPS limit exceeded.");
}
window.Remaining--;
window.LastUpdated = now;
}
return ValueTask.CompletedTask;
}
private static int EstimatePayloadSize(SigningRequest request)
{
var predicateBytes = request.Predicate is null
? Array.Empty<byte>()
: Encoding.UTF8.GetBytes(request.Predicate.RootElement.GetRawText());
var subjectBytes = 0;
foreach (var subject in request.Subjects)
{
subjectBytes += subject.Name.Length;
foreach (var digest in subject.Digest)
{
subjectBytes += digest.Key.Length + digest.Value.Length;
}
}
return predicateBytes.Length + subjectBytes;
}
private sealed class QuotaWindow
{
public DateTimeOffset ResetAt { get; private set; } = DateTimeOffset.MinValue;
public int Remaining { get; set; }
public DateTimeOffset LastUpdated { get; set; }
public void Reset(DateTimeOffset now, int limit)
{
ResetAt = now.AddSeconds(1);
Remaining = limit;
LastUpdated = now;
}
}
}
using System;
using System.Collections.Concurrent;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Signer.Core;
namespace StellaOps.Signer.Infrastructure.Quotas;
public sealed class InMemoryQuotaService : ISignerQuotaService
{
private readonly ConcurrentDictionary<string, QuotaWindow> _windows = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryQuotaService> _logger;
public InMemoryQuotaService(TimeProvider timeProvider, ILogger<InMemoryQuotaService> logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask EnsureWithinLimitsAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var payloadSize = EstimatePayloadSize(request);
if (payloadSize > entitlement.MaxArtifactBytes)
{
throw new SignerQuotaException("artifact_too_large", $"Artifact size {payloadSize} exceeds plan cap ({entitlement.MaxArtifactBytes}).");
}
if (entitlement.QpsLimit <= 0)
{
return ValueTask.CompletedTask;
}
var window = _windows.GetOrAdd(caller.Tenant, static _ => new QuotaWindow());
lock (window)
{
var now = _timeProvider.GetUtcNow();
if (window.ResetAt <= now)
{
window.Reset(now, entitlement.QpsLimit);
}
if (window.Remaining <= 0)
{
_logger.LogWarning("Quota exceeded for tenant {Tenant}", caller.Tenant);
throw new SignerQuotaException("plan_throttled", "Plan QPS limit exceeded.");
}
window.Remaining--;
window.LastUpdated = now;
}
return ValueTask.CompletedTask;
}
private static int EstimatePayloadSize(SigningRequest request)
{
var predicateBytes = request.Predicate is null
? Array.Empty<byte>()
: Encoding.UTF8.GetBytes(request.Predicate.RootElement.GetRawText());
var subjectBytes = 0;
foreach (var subject in request.Subjects)
{
subjectBytes += subject.Name.Length;
foreach (var digest in subject.Digest)
{
subjectBytes += digest.Key.Length + digest.Value.Length;
}
}
return predicateBytes.Length + subjectBytes;
}
private sealed class QuotaWindow
{
public DateTimeOffset ResetAt { get; private set; } = DateTimeOffset.MinValue;
public int Remaining { get; set; }
public DateTimeOffset LastUpdated { get; set; }
public void Reset(DateTimeOffset now, int limit)
{
ResetAt = now.AddSeconds(1);
Remaining = limit;
LastUpdated = now;
}
}
}

View File

@@ -1,38 +1,38 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
namespace StellaOps.Signer.Infrastructure.ReleaseVerification;
public sealed class DefaultReleaseIntegrityVerifier : IReleaseIntegrityVerifier
{
private static readonly Regex DigestPattern = new("^sha256:[a-fA-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IOptionsMonitor<SignerReleaseVerificationOptions> _options;
public DefaultReleaseIntegrityVerifier(IOptionsMonitor<SignerReleaseVerificationOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public ValueTask<ReleaseVerificationResult> VerifyAsync(string scannerImageDigest, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scannerImageDigest) || !DigestPattern.IsMatch(scannerImageDigest))
{
throw new SignerReleaseVerificationException("release_digest_invalid", "Scanner image digest must be a valid sha256 string.");
}
var options = _options.CurrentValue;
if (options.TrustedScannerDigests.Count > 0 &&
!options.TrustedScannerDigests.Contains(scannerImageDigest))
{
return ValueTask.FromResult(new ReleaseVerificationResult(false, null));
}
return ValueTask.FromResult(new ReleaseVerificationResult(true, options.TrustedSigner));
}
}
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
namespace StellaOps.Signer.Infrastructure.ReleaseVerification;
public sealed class DefaultReleaseIntegrityVerifier : IReleaseIntegrityVerifier
{
private static readonly Regex DigestPattern = new("^sha256:[a-fA-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IOptionsMonitor<SignerReleaseVerificationOptions> _options;
public DefaultReleaseIntegrityVerifier(IOptionsMonitor<SignerReleaseVerificationOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public ValueTask<ReleaseVerificationResult> VerifyAsync(string scannerImageDigest, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scannerImageDigest) || !DigestPattern.IsMatch(scannerImageDigest))
{
throw new SignerReleaseVerificationException("release_digest_invalid", "Scanner image digest must be a valid sha256 string.");
}
var options = _options.CurrentValue;
if (options.TrustedScannerDigests.Count > 0 &&
!options.TrustedScannerDigests.Contains(scannerImageDigest))
{
return ValueTask.FromResult(new ReleaseVerificationResult(false, null));
}
return ValueTask.FromResult(new ReleaseVerificationResult(true, options.TrustedSigner));
}
}

View File

@@ -1,67 +1,67 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
namespace StellaOps.Signer.Infrastructure.Signing;
public sealed class HmacDsseSigner : IDsseSigner
{
private readonly IOptionsMonitor<SignerCryptoOptions> _options;
private readonly ICryptoHmac _cryptoHmac;
private readonly TimeProvider _timeProvider;
public HmacDsseSigner(
IOptionsMonitor<SignerCryptoOptions> options,
ICryptoHmac cryptoHmac,
TimeProvider timeProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var options = _options.CurrentValue;
var payloadBytes = SignerStatementBuilder.BuildStatementPayload(request);
var secretBytes = Convert.FromBase64String(options.Secret);
var signature = _cryptoHmac.ComputeHmacBase64ForPurpose(secretBytes, payloadBytes, HmacPurpose.Signing);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var envelope = new DsseEnvelope(
payloadBase64,
"application/vnd.in-toto+json",
new[]
{
new DsseSignature(signature, options.KeyId),
});
var metadata = new SigningMetadata(
new SigningIdentity(
options.Mode,
caller.Subject,
caller.Subject,
_timeProvider.GetUtcNow().AddMinutes(10)),
Array.Empty<string>(),
options.ProviderName,
options.AlgorithmId);
var bundle = new SigningBundle(envelope, metadata);
return ValueTask.FromResult(bundle);
}
}
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Signer.Core;
using StellaOps.Signer.Infrastructure.Options;
namespace StellaOps.Signer.Infrastructure.Signing;
public sealed class HmacDsseSigner : IDsseSigner
{
private readonly IOptionsMonitor<SignerCryptoOptions> _options;
private readonly ICryptoHmac _cryptoHmac;
private readonly TimeProvider _timeProvider;
public HmacDsseSigner(
IOptionsMonitor<SignerCryptoOptions> options,
ICryptoHmac cryptoHmac,
TimeProvider timeProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<SigningBundle> SignAsync(
SigningRequest request,
ProofOfEntitlementResult entitlement,
CallerContext caller,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(entitlement);
ArgumentNullException.ThrowIfNull(caller);
var options = _options.CurrentValue;
var payloadBytes = SignerStatementBuilder.BuildStatementPayload(request);
var secretBytes = Convert.FromBase64String(options.Secret);
var signature = _cryptoHmac.ComputeHmacBase64ForPurpose(secretBytes, payloadBytes, HmacPurpose.Signing);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var envelope = new DsseEnvelope(
payloadBase64,
"application/vnd.in-toto+json",
new[]
{
new DsseSignature(signature, options.KeyId),
});
var metadata = new SigningMetadata(
new SigningIdentity(
options.Mode,
caller.Subject,
caller.Subject,
_timeProvider.GetUtcNow().AddMinutes(10)),
Array.Empty<string>(),
options.ProviderName,
options.AlgorithmId);
var bundle = new SigningBundle(envelope, metadata);
return ValueTask.FromResult(bundle);
}
}

View File

@@ -1,127 +1,127 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Signer.WebService.Contracts;
using Xunit;
namespace StellaOps.Signer.Tests;
public sealed class SignerEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private const string TrustedDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
public SignerEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task SignDsse_ReturnsBundle_WhenRequestValid()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = TrustedDigest,
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var body = await response.Content.ReadFromJsonAsync<SignDsseResponseDto>();
Assert.NotNull(body);
Assert.Equal("stub-subject", body!.Bundle.SigningIdentity.Subject);
Assert.Equal("stub-subject", body.Bundle.SigningIdentity.Issuer);
}
[Fact]
public async Task SignDsse_ReturnsForbidden_WhenDigestUntrusted()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
var response = await client.SendAsync(request);
var problemJson = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var problem = System.Text.Json.JsonSerializer.Deserialize<ProblemDetails>(problemJson, new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
Assert.NotNull(problem);
Assert.Equal("release_untrusted", problem!.Type);
}
[Fact]
public async Task VerifyReferrers_ReturnsTrustedResult_WhenDigestIsKnown()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/signer/verify/referrers?digest={TrustedDigest}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var body = await response.Content.ReadFromJsonAsync<VerifyReferrersResponseDto>();
Assert.NotNull(body);
Assert.True(body!.Trusted);
}
[Fact]
public async Task VerifyReferrers_ReturnsProblem_WhenDigestMissing()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/signer/verify/referrers");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private HttpClient CreateClient() => _factory.CreateClient();
}
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Signer.WebService.Contracts;
using Xunit;
namespace StellaOps.Signer.Tests;
public sealed class SignerEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private const string TrustedDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
public SignerEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task SignDsse_ReturnsBundle_WhenRequestValid()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = TrustedDigest,
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var body = await response.Content.ReadFromJsonAsync<SignDsseResponseDto>();
Assert.NotNull(body);
Assert.Equal("stub-subject", body!.Bundle.SigningIdentity.Subject);
Assert.Equal("stub-subject", body.Bundle.SigningIdentity.Issuer);
}
[Fact]
public async Task SignDsse_ReturnsForbidden_WhenDigestUntrusted()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/signer/sign/dsse")
{
Content = JsonContent.Create(new
{
subject = new[]
{
new
{
name = "pkg:npm/example",
digest = new Dictionary<string, string> { ["sha256"] = "4d5f" },
},
},
predicateType = "https://in-toto.io/Statement/v0.1",
predicate = new { result = "pass" },
scannerImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
poe = new { format = "jwt", value = "valid-poe" },
options = new { signingMode = "kms", expirySeconds = 600, returnBundle = "dsse+cert" },
})
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
request.Headers.Add("DPoP", "stub-proof");
var response = await client.SendAsync(request);
var problemJson = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var problem = System.Text.Json.JsonSerializer.Deserialize<ProblemDetails>(problemJson, new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
Assert.NotNull(problem);
Assert.Equal("release_untrusted", problem!.Type);
}
[Fact]
public async Task VerifyReferrers_ReturnsTrustedResult_WhenDigestIsKnown()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/signer/verify/referrers?digest={TrustedDigest}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var body = await response.Content.ReadFromJsonAsync<VerifyReferrersResponseDto>();
Assert.NotNull(body);
Assert.True(body!.Trusted);
}
[Fact]
public async Task VerifyReferrers_ReturnsProblem_WhenDigestMissing()
{
var client = CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/signer/verify/referrers");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "stub-token");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private HttpClient CreateClient() => _factory.CreateClient();
}

View File

@@ -1,30 +1,30 @@
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Signer.WebService.Contracts;
public sealed record SignDsseSubjectDto(string Name, Dictionary<string, string> Digest);
public sealed record SignDssePoeDto(string Format, string Value);
public sealed record SignDsseOptionsDto(string? SigningMode, int? ExpirySeconds, string? ReturnBundle);
public sealed record SignDsseRequestDto(
List<SignDsseSubjectDto> Subject,
string PredicateType,
JsonElement Predicate,
string ScannerImageDigest,
SignDssePoeDto Poe,
SignDsseOptionsDto? Options);
public sealed record SignDsseResponseDto(SignDsseBundleDto Bundle, SignDssePolicyDto Policy, string AuditId);
public sealed record SignDsseBundleDto(SignDsseEnvelopeDto Dsse, IReadOnlyList<string> CertificateChain, string Mode, SignDsseIdentityDto SigningIdentity);
public sealed record SignDsseEnvelopeDto(string PayloadType, string Payload, IReadOnlyList<SignDsseSignatureDto> Signatures);
public sealed record SignDsseSignatureDto(string Signature, string? KeyId);
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Signer.WebService.Contracts;
public sealed record SignDsseSubjectDto(string Name, Dictionary<string, string> Digest);
public sealed record SignDssePoeDto(string Format, string Value);
public sealed record SignDsseOptionsDto(string? SigningMode, int? ExpirySeconds, string? ReturnBundle);
public sealed record SignDsseRequestDto(
List<SignDsseSubjectDto> Subject,
string PredicateType,
JsonElement Predicate,
string ScannerImageDigest,
SignDssePoeDto Poe,
SignDsseOptionsDto? Options);
public sealed record SignDsseResponseDto(SignDsseBundleDto Bundle, SignDssePolicyDto Policy, string AuditId);
public sealed record SignDsseBundleDto(SignDsseEnvelopeDto Dsse, IReadOnlyList<string> CertificateChain, string Mode, SignDsseIdentityDto SigningIdentity);
public sealed record SignDsseEnvelopeDto(string PayloadType, string Payload, IReadOnlyList<SignDsseSignatureDto> Signatures);
public sealed record SignDsseSignatureDto(string Signature, string? KeyId);
public sealed record SignDsseIdentityDto(string Issuer, string Subject, string? CertExpiry);
public sealed record SignDssePolicyDto(string Plan, int MaxArtifactBytes, int QpsRemaining);

View File

@@ -1,11 +1,11 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -14,15 +14,15 @@ using StellaOps.Signer.Core;
using StellaOps.Signer.WebService.Contracts;
namespace StellaOps.Signer.WebService.Endpoints;
public static class SignerEndpoints
{
public static IEndpointRouteBuilder MapSignerEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/signer")
.WithTags("Signer")
.RequireAuthorization();
public static class SignerEndpoints
{
public static IEndpointRouteBuilder MapSignerEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/signer")
.WithTags("Signer")
.RequireAuthorization();
group.MapPost("/sign/dsse", SignDsseAsync);
group.MapGet("/verify/referrers", VerifyReferrersAsync);
return endpoints;
@@ -30,40 +30,40 @@ public static class SignerEndpoints
private static async Task<IResult> SignDsseAsync(
HttpContext httpContext,
[FromBody] SignDsseRequestDto requestDto,
ISignerPipeline pipeline,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
if (requestDto is null)
{
[FromBody] SignDsseRequestDto requestDto,
ISignerPipeline pipeline,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
if (requestDto is null)
{
return CreateProblem("invalid_request", "Request body is required.", StatusCodes.Status400BadRequest);
}
var logger = loggerFactory.CreateLogger("SignerEndpoints.SignDsse");
try
{
var caller = BuildCallerContext(httpContext);
ValidateSenderBinding(httpContext, requestDto.Poe, caller);
using var predicateDocument = JsonDocument.Parse(requestDto.Predicate.GetRawText());
var signingRequest = new SigningRequest(
ConvertSubjects(requestDto.Subject),
requestDto.PredicateType,
predicateDocument,
requestDto.ScannerImageDigest,
new ProofOfEntitlement(
ParsePoeFormat(requestDto.Poe.Format),
requestDto.Poe.Value),
ConvertOptions(requestDto.Options));
var outcome = await pipeline.SignAsync(signingRequest, caller, cancellationToken).ConfigureAwait(false);
}
var logger = loggerFactory.CreateLogger("SignerEndpoints.SignDsse");
try
{
var caller = BuildCallerContext(httpContext);
ValidateSenderBinding(httpContext, requestDto.Poe, caller);
using var predicateDocument = JsonDocument.Parse(requestDto.Predicate.GetRawText());
var signingRequest = new SigningRequest(
ConvertSubjects(requestDto.Subject),
requestDto.PredicateType,
predicateDocument,
requestDto.ScannerImageDigest,
new ProofOfEntitlement(
ParsePoeFormat(requestDto.Poe.Format),
requestDto.Poe.Value),
ConvertOptions(requestDto.Options));
var outcome = await pipeline.SignAsync(signingRequest, caller, cancellationToken).ConfigureAwait(false);
var response = ConvertOutcome(outcome);
return Json(response);
}
catch (SignerValidationException ex)
{
logger.LogWarning(ex, "Validation failure while signing DSSE.");
}
catch (SignerValidationException ex)
{
logger.LogWarning(ex, "Validation failure while signing DSSE.");
return CreateProblem(ex.Code, ex.Message, StatusCodes.Status400BadRequest);
}
catch (SignerAuthorizationException ex)
@@ -135,155 +135,155 @@ public static class SignerEndpoints
var user = context.User ?? throw new SignerAuthorizationException("invalid_caller", "Caller is not authenticated.");
string subject = user.FindFirstValue(StellaOpsClaimTypes.Subject) ??
throw new SignerAuthorizationException("invalid_caller", "Subject claim is required.");
string tenant = user.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? subject;
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (user.HasClaim(c => c.Type == StellaOpsClaimTypes.Scope))
{
foreach (var value in user.FindAll(StellaOpsClaimTypes.Scope))
{
foreach (var scope in value.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
scopes.Add(scope);
}
}
}
foreach (var scopeClaim in user.FindAll(StellaOpsClaimTypes.ScopeItem))
{
scopes.Add(scopeClaim.Value);
}
var audiences = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var audClaim in user.FindAll(StellaOpsClaimTypes.Audience))
{
if (audClaim.Value.Contains(' '))
{
foreach (var aud in audClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
audiences.Add(aud);
}
}
else
{
audiences.Add(audClaim.Value);
}
}
if (audiences.Count == 0)
{
throw new SignerAuthorizationException("invalid_audience", "Audience claim is required.");
}
var sender = context.Request.Headers.TryGetValue("DPoP", out var dpop)
? dpop.ToString()
: null;
var clientCert = context.Connection.ClientCertificate?.Thumbprint;
return new CallerContext(
subject,
tenant,
scopes.ToArray(),
audiences.ToArray(),
sender,
clientCert);
}
private static void ValidateSenderBinding(HttpContext context, SignDssePoeDto poe, CallerContext caller)
{
if (poe is null)
{
throw new SignerValidationException("poe_missing", "Proof of entitlement is required.");
}
var format = ParsePoeFormat(poe.Format);
if (format == SignerPoEFormat.Jwt)
{
if (string.IsNullOrWhiteSpace(caller.SenderBinding))
{
throw new SignerAuthorizationException("invalid_token", "DPoP proof is required for JWT PoE.");
}
}
else if (format == SignerPoEFormat.Mtls)
{
if (string.IsNullOrWhiteSpace(caller.ClientCertificateThumbprint))
{
throw new SignerAuthorizationException("invalid_token", "Client certificate is required for mTLS PoE.");
}
}
}
private static IReadOnlyList<SigningSubject> ConvertSubjects(List<SignDsseSubjectDto> subjects)
{
if (subjects is null || subjects.Count == 0)
{
throw new SignerValidationException("subject_missing", "At least one subject is required.");
}
return subjects.Select(subject =>
{
if (subject.Digest is null || subject.Digest.Count == 0)
{
throw new SignerValidationException("subject_digest_invalid", $"Digest for subject '{subject.Name}' is required.");
}
return new SigningSubject(subject.Name, subject.Digest);
}).ToArray();
}
private static SigningOptions ConvertOptions(SignDsseOptionsDto? optionsDto)
{
if (optionsDto is null)
{
return new SigningOptions(SigningMode.Kms, null, "dsse+cert");
}
var mode = optionsDto.SigningMode switch
{
null or "" => SigningMode.Kms,
"kms" or "KMS" => SigningMode.Kms,
"keyless" or "KEYLESS" => SigningMode.Keyless,
_ => throw new SignerValidationException("signing_mode_invalid", $"Unsupported signing mode '{optionsDto.SigningMode}'."),
};
return new SigningOptions(mode, optionsDto.ExpirySeconds, optionsDto.ReturnBundle ?? "dsse+cert");
}
private static SignerPoEFormat ParsePoeFormat(string? format)
{
return format?.ToLowerInvariant() switch
{
"jwt" => SignerPoEFormat.Jwt,
"mtls" => SignerPoEFormat.Mtls,
_ => throw new SignerValidationException("poe_invalid", $"Unsupported PoE format '{format}'."),
};
}
private static SignDsseResponseDto ConvertOutcome(SigningOutcome outcome)
{
var signatures = outcome.Bundle.Envelope.Signatures
.Select(signature => new SignDsseSignatureDto(signature.Signature, signature.KeyId))
.ToArray();
var bundle = new SignDsseBundleDto(
new SignDsseEnvelopeDto(
outcome.Bundle.Envelope.PayloadType,
outcome.Bundle.Envelope.Payload,
signatures),
outcome.Bundle.Metadata.CertificateChain,
outcome.Bundle.Metadata.Identity.Mode,
new SignDsseIdentityDto(
outcome.Bundle.Metadata.Identity.Issuer,
outcome.Bundle.Metadata.Identity.Subject,
outcome.Bundle.Metadata.Identity.ExpiresAtUtc?.ToString("O")));
var policy = new SignDssePolicyDto(
outcome.Policy.Plan,
outcome.Policy.MaxArtifactBytes,
outcome.Policy.QpsRemaining);
return new SignDsseResponseDto(bundle, policy, outcome.AuditId);
}
}
throw new SignerAuthorizationException("invalid_caller", "Subject claim is required.");
string tenant = user.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? subject;
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (user.HasClaim(c => c.Type == StellaOpsClaimTypes.Scope))
{
foreach (var value in user.FindAll(StellaOpsClaimTypes.Scope))
{
foreach (var scope in value.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
scopes.Add(scope);
}
}
}
foreach (var scopeClaim in user.FindAll(StellaOpsClaimTypes.ScopeItem))
{
scopes.Add(scopeClaim.Value);
}
var audiences = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var audClaim in user.FindAll(StellaOpsClaimTypes.Audience))
{
if (audClaim.Value.Contains(' '))
{
foreach (var aud in audClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
audiences.Add(aud);
}
}
else
{
audiences.Add(audClaim.Value);
}
}
if (audiences.Count == 0)
{
throw new SignerAuthorizationException("invalid_audience", "Audience claim is required.");
}
var sender = context.Request.Headers.TryGetValue("DPoP", out var dpop)
? dpop.ToString()
: null;
var clientCert = context.Connection.ClientCertificate?.Thumbprint;
return new CallerContext(
subject,
tenant,
scopes.ToArray(),
audiences.ToArray(),
sender,
clientCert);
}
private static void ValidateSenderBinding(HttpContext context, SignDssePoeDto poe, CallerContext caller)
{
if (poe is null)
{
throw new SignerValidationException("poe_missing", "Proof of entitlement is required.");
}
var format = ParsePoeFormat(poe.Format);
if (format == SignerPoEFormat.Jwt)
{
if (string.IsNullOrWhiteSpace(caller.SenderBinding))
{
throw new SignerAuthorizationException("invalid_token", "DPoP proof is required for JWT PoE.");
}
}
else if (format == SignerPoEFormat.Mtls)
{
if (string.IsNullOrWhiteSpace(caller.ClientCertificateThumbprint))
{
throw new SignerAuthorizationException("invalid_token", "Client certificate is required for mTLS PoE.");
}
}
}
private static IReadOnlyList<SigningSubject> ConvertSubjects(List<SignDsseSubjectDto> subjects)
{
if (subjects is null || subjects.Count == 0)
{
throw new SignerValidationException("subject_missing", "At least one subject is required.");
}
return subjects.Select(subject =>
{
if (subject.Digest is null || subject.Digest.Count == 0)
{
throw new SignerValidationException("subject_digest_invalid", $"Digest for subject '{subject.Name}' is required.");
}
return new SigningSubject(subject.Name, subject.Digest);
}).ToArray();
}
private static SigningOptions ConvertOptions(SignDsseOptionsDto? optionsDto)
{
if (optionsDto is null)
{
return new SigningOptions(SigningMode.Kms, null, "dsse+cert");
}
var mode = optionsDto.SigningMode switch
{
null or "" => SigningMode.Kms,
"kms" or "KMS" => SigningMode.Kms,
"keyless" or "KEYLESS" => SigningMode.Keyless,
_ => throw new SignerValidationException("signing_mode_invalid", $"Unsupported signing mode '{optionsDto.SigningMode}'."),
};
return new SigningOptions(mode, optionsDto.ExpirySeconds, optionsDto.ReturnBundle ?? "dsse+cert");
}
private static SignerPoEFormat ParsePoeFormat(string? format)
{
return format?.ToLowerInvariant() switch
{
"jwt" => SignerPoEFormat.Jwt,
"mtls" => SignerPoEFormat.Mtls,
_ => throw new SignerValidationException("poe_invalid", $"Unsupported PoE format '{format}'."),
};
}
private static SignDsseResponseDto ConvertOutcome(SigningOutcome outcome)
{
var signatures = outcome.Bundle.Envelope.Signatures
.Select(signature => new SignDsseSignatureDto(signature.Signature, signature.KeyId))
.ToArray();
var bundle = new SignDsseBundleDto(
new SignDsseEnvelopeDto(
outcome.Bundle.Envelope.PayloadType,
outcome.Bundle.Envelope.Payload,
signatures),
outcome.Bundle.Metadata.CertificateChain,
outcome.Bundle.Metadata.Identity.Mode,
new SignDsseIdentityDto(
outcome.Bundle.Metadata.Identity.Issuer,
outcome.Bundle.Metadata.Identity.Subject,
outcome.Bundle.Metadata.Identity.ExpiresAtUtc?.ToString("O")));
var policy = new SignDssePolicyDto(
outcome.Policy.Plan,
outcome.Policy.MaxArtifactBytes,
outcome.Policy.QpsRemaining);
return new SignDsseResponseDto(bundle, policy, outcome.AuditId);
}
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Signer.WebService.Security;
public static class StubBearerAuthenticationDefaults
{
public const string AuthenticationScheme = "StubBearer";
}
namespace StellaOps.Signer.WebService.Security;
public static class StubBearerAuthenticationDefaults
{
public const string AuthenticationScheme = "StubBearer";
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Signer.WebService.Security;
public sealed class StubBearerAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public StubBearerAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization) ||
!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.Fail("Missing bearer token."));
}
var token = authorization.Substring("Bearer ".Length).Trim();
if (token.Length == 0)
{
return Task.FromResult(AuthenticateResult.Fail("Bearer token is empty."));
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "stub-subject"),
new(StellaOpsClaimTypes.Subject, "stub-subject"),
new(StellaOpsClaimTypes.Tenant, "stub-tenant"),
new(StellaOpsClaimTypes.Scope, "signer.sign"),
new(StellaOpsClaimTypes.ScopeItem, "signer.sign"),
new(StellaOpsClaimTypes.Audience, "signer"),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Signer.WebService.Security;
public sealed class StubBearerAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public StubBearerAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization) ||
!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.Fail("Missing bearer token."));
}
var token = authorization.Substring("Bearer ".Length).Trim();
if (token.Length == 0)
{
return Task.FromResult(AuthenticateResult.Fail("Bearer token is empty."));
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "stub-subject"),
new(StellaOpsClaimTypes.Subject, "stub-subject"),
new(StellaOpsClaimTypes.Tenant, "stub-tenant"),
new(StellaOpsClaimTypes.Scope, "signer.sign"),
new(StellaOpsClaimTypes.ScopeItem, "signer.sign"),
new(StellaOpsClaimTypes.Audience, "signer"),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}