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
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user