Restructure solution layout by module
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Signer.WebService.Security;
|
||||
|
||||
public static class StubBearerAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "StubBearer";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user