Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,32 @@
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);
public sealed record VerifyReferrersResponseDto(bool Trusted, string? TrustedSigner);

View File

@@ -0,0 +1,289 @@
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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
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();
group.MapPost("/sign/dsse", SignDsseAsync);
group.MapGet("/verify/referrers", VerifyReferrersAsync);
return endpoints;
}
private static async Task<IResult> SignDsseAsync(
HttpContext httpContext,
[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 response = ConvertOutcome(outcome);
return Json(response);
}
catch (SignerValidationException ex)
{
logger.LogWarning(ex, "Validation failure while signing DSSE.");
return CreateProblem(ex.Code, ex.Message, StatusCodes.Status400BadRequest);
}
catch (SignerAuthorizationException ex)
{
logger.LogWarning(ex, "Authorization failure while signing DSSE.");
return CreateProblem(ex.Code, ex.Message, StatusCodes.Status403Forbidden);
}
catch (SignerReleaseVerificationException ex)
{
logger.LogWarning(ex, "Release verification failed.");
return CreateProblem(ex.Code, ex.Message, StatusCodes.Status403Forbidden);
}
catch (SignerQuotaException ex)
{
logger.LogWarning(ex, "Quota enforcement rejected request.");
return CreateProblem(ex.Code, ex.Message, StatusCodes.Status429TooManyRequests);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error while signing DSSE.");
return CreateProblem("signing_unavailable", "Internal server error.", StatusCodes.Status500InternalServerError);
}
}
private static async Task<IResult> VerifyReferrersAsync(
[FromQuery] string digest,
IReleaseIntegrityVerifier verifier,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(digest))
{
return CreateProblem("invalid_digest", "Digest parameter is required.", StatusCodes.Status400BadRequest);
}
try
{
var verification = await verifier.VerifyAsync(digest.Trim(), cancellationToken).ConfigureAwait(false);
var response = new VerifyReferrersResponseDto(verification.Trusted, verification.ReleaseSigner);
return Json(response);
}
catch (SignerReleaseVerificationException ex)
{
return CreateProblem(ex.Code, ex.Message, StatusCodes.Status400BadRequest);
}
}
private static IResult CreateProblem(string type, string detail, int statusCode)
{
var problem = new ProblemDetails
{
Type = type,
Detail = detail,
Status = statusCode,
};
return Json(problem, statusCode);
}
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static IResult Json(object value, int statusCode = StatusCodes.Status200OK)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Text(payload, "application/json", Encoding.UTF8, statusCode);
}
private static CallerContext BuildCallerContext(HttpContext context)
{
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);
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authentication;
using StellaOps.Signer.Infrastructure;
using StellaOps.Signer.Infrastructure.Options;
using StellaOps.Signer.WebService.Endpoints;
using StellaOps.Signer.WebService.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLogging();
builder.Services.AddAuthentication(StubBearerAuthenticationDefaults.AuthenticationScheme)
.AddScheme<AuthenticationSchemeOptions, StubBearerAuthenticationHandler>(
StubBearerAuthenticationDefaults.AuthenticationScheme,
_ => { });
builder.Services.AddAuthorization();
builder.Services.AddSignerPipeline();
builder.Services.Configure<SignerEntitlementOptions>(options =>
{
options.Tokens["valid-poe"] = new SignerEntitlementDefinition(
LicenseId: "LIC-TEST",
CustomerId: "CUST-TEST",
Plan: "pro",
MaxArtifactBytes: 128 * 1024,
QpsLimit: 5,
QpsRemaining: 5,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddHours(1));
});
builder.Services.Configure<SignerReleaseVerificationOptions>(options =>
{
options.TrustedScannerDigests.Add("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
});
builder.Services.Configure<SignerCryptoOptions>(_ => { });
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", () => Results.Ok("StellaOps Signer service ready."));
app.MapSignerEndpoints();
app.Run();
public partial class Program;

View File

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

View File

@@ -0,0 +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));
}
}

View File

@@ -0,0 +1,30 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\StellaOps.Signer.Infrastructure\StellaOps.Signer.Infrastructure.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>