Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
14 KiB
ADR 0043: Fulcio Keyless Signing with Optional Parameter
Status
ACCEPTED (2025-12-29)
Context
StellaOps must support both cloud-connected and air-gapped deployments. Verdict signing requirements differ:
Cloud-Connected Deployments
- Access to Fulcio (Sigstore) for keyless signing
- OIDC token available from identity provider
- Ephemeral keys generated per signature
- Transparency log (Rekor) accessible
Air-Gapped Deployments
- No internet access (Fulcio/Rekor unreachable)
- No OIDC token available
- Must operate without external dependencies
- Long-lived keys managed internally (if signing required)
Requirements
- Single Codebase: Same
VerdictBuilderServicefor both modes - Runtime Configuration: Deployment mode determined at startup
- No Breaking Changes: Existing air-gap deployments must continue working
- Clear Separation: Signing concerns separate from verdict building logic
Decision
Add optional IDsseSigner? signer parameter to VerdictBuilderService constructor.
Rationale
-
Dependency Injection Friendly: Signer is injected (or not) based on deployment config
-
Explicit Air-Gap Mode:
nullsigner clearly indicates air-gapped operation -
Single Implementation: No need for separate
VerdictBuilderServiceclasses -
Production Signing Pipeline: Even with signer available, production verdicts go through
StellaOps.Signerservice for Proof-of-Entitlement (PoE) validation
Implementation
// VerdictBuilderService.cs
public sealed class VerdictBuilderService : IVerdictBuilder
{
private readonly ILogger<VerdictBuilderService> _logger;
private readonly IDsseSigner? _signer; // Null for air-gap mode
/// <summary>
/// Creates a VerdictBuilderService.
/// </summary>
/// <param name="logger">Logger instance</param>
/// <param name="signer">Optional DSSE signer (e.g., KeylessDsseSigner for Fulcio).
/// Null for air-gapped deployments.</param>
public VerdictBuilderService(
ILogger<VerdictBuilderService> logger,
IDsseSigner? signer = null)
{
_logger = logger;
_signer = signer;
if (_signer == null)
{
_logger.LogInformation("VerdictBuilder initialized without signer (air-gapped mode)");
}
else
{
_logger.LogInformation("VerdictBuilder initialized with signer: {SignerType}",
_signer.GetType().Name);
}
}
private async ValueTask<DsseEnvelope> CreateDsseEnvelopeAsync(
VerdictPayload verdict,
string cgsHash,
CancellationToken ct)
{
var payloadJson = JsonSerializer.Serialize(verdict, CanonicalJsonOptions);
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
if (_signer != null)
{
_logger.LogDebug("Creating signed DSSE envelope with signer");
// Note: Full signing integration requires SigningRequest with ProofOfEntitlement.
// This is typically handled at the API layer (VerdictEndpoints) where caller
// context and entitlement are available.
//
// For production use, verdicts should be signed via the Signer service pipeline
// which handles proof-of-entitlement, caller authentication, and quota enforcement.
}
// Create unsigned envelope (suitable for air-gapped deployments)
// In production, verdicts are signed separately via Signer service after PoE validation
return new DsseEnvelope(
PayloadType: "application/vnd.stellaops.verdict+json",
Payload: payloadBase64,
Signatures: new[]
{
new DsseSignature(
Keyid: $"cgs:{cgsHash}",
Sig: "unsigned:use-signer-service-for-production-signatures"
)
}
);
}
}
Configuration Examples
Cloud-Connected Deployment (Fulcio)
// Program.cs
services.AddVerdictBuilder(options =>
{
options.SigningMode = VerdictSigningMode.Keyless;
options.FulcioUrl = "https://fulcio.sigstore.dev";
options.OidcIssuer = "https://oauth2.sigstore.dev/auth";
});
// Internal implementation:
services.AddSingleton<IDsseSigner, KeylessDsseSigner>();
services.AddSingleton<IVerdictBuilder, VerdictBuilderService>();
Air-Gapped Deployment
// Program.cs
services.AddVerdictBuilder(options =>
{
options.SigningMode = VerdictSigningMode.AirGap;
// No signer configured
});
// Internal implementation:
// IDsseSigner not registered
services.AddSingleton<IVerdictBuilder, VerdictBuilderService>();
Long-Lived Key Deployment (Future)
// Program.cs
services.AddVerdictBuilder(options =>
{
options.SigningMode = VerdictSigningMode.LongLivedKey;
options.KeyPath = "/etc/stellaops/signing-key.pem";
});
// Internal implementation:
services.AddSingleton<IDsseSigner, LongLivedKeySigner>();
services.AddSingleton<IVerdictBuilder, VerdictBuilderService>();
Consequences
Positive
- ✅ Single Codebase: Same service for all deployment modes
- ✅ Clear Intent:
nullsigner explicitly communicates air-gap mode - ✅ DI Friendly: Standard dependency injection pattern
- ✅ No Breaking Changes: Existing air-gap deployments work without modification
- ✅ Future Extensible: Easy to add new signer implementations
Negative
- ⚠️ Runtime Validation: Can't enforce signer requirement at compile time (must check at runtime)
- ⚠️ Separation of Concerns: Verdict building logic includes signing creation (even if unsigned)
- ⚠️ Documentation Burden: Developers must understand when to use signer vs null
Neutral
- 📝 Production Pipeline: Even with signer, production signatures go through Signer service for PoE
- 📝 Testing: Tests create
VerdictBuilderService(logger, signer: null)for simplicity
Alternatives Considered
Alternative 1: Separate Classes
public class VerdictBuilderService : IVerdictBuilder
{
// Air-gap implementation
}
public class SignedVerdictBuilderService : IVerdictBuilder
{
private readonly IDsseSigner _signer;
// Keyless/signed implementation
}
Rejected because:
- Code duplication for verdict building logic
- Configuration complexity (which class to register?)
- Testing overhead (must test both classes)
- Tight coupling between signing and verdict building
Alternative 2: Strategy Pattern
public interface IVerdictSigningStrategy
{
Task<DsseEnvelope> SignAsync(VerdictPayload payload);
}
public class AirGapSigningStrategy : IVerdictSigningStrategy { }
public class KeylessSigningStrategy : IVerdictSigningStrategy { }
public class VerdictBuilderService
{
private readonly IVerdictSigningStrategy _signingStrategy;
}
Rejected because:
- Over-engineering for simple null check
- Additional abstraction layer for minimal benefit
- More difficult to understand for developers
Alternative 3: Configuration Flag
public class VerdictBuilderService
{
private readonly VerdictBuilderOptions _options;
public VerdictBuilderService(IOptions<VerdictBuilderOptions> options)
{
_options = options.Value;
if (_options.EnableSigning)
{
// Initialize signer
}
}
}
Rejected because:
- Hides signer dependency (not visible in constructor)
- Requires options even when not needed
- Less DI-friendly (can't inject mock signer for testing)
Alternative 4: Builder Pattern
var verdictBuilder = VerdictBuilderService
.Create()
.WithLogger(logger)
.WithKeylessSigning(fulcioClient)
.Build();
Rejected because:
- Not compatible with DI containers
- Verbose API for simple configuration
- Testing complexity (must build every time)
Implementation Notes
Signer Interface
// StellaOps.Signer.Core.IDsseSigner
public interface IDsseSigner
{
/// <summary>
/// Signs a payload and returns DSSE envelope.
/// </summary>
Task<DsseEnvelope> SignAsync(
byte[] payload,
string payloadType,
SigningOptions options,
CancellationToken ct = default);
}
Keyless Signer Implementation
Location: src/Signer/__Libraries/StellaOps.Signer.Keyless/KeylessDsseSigner.cs
Dependencies:
IFulcioClient- Ephemeral certificate issuanceIAmbientOidcTokenProvider- OIDC token acquisitionIRekorClient- Transparency log submission (optional)
Workflow:
- Generate ephemeral key pair (ECDSA P-256)
- Acquire OIDC token from ambient provider
- Request certificate from Fulcio (binds identity to public key)
- Sign payload with ephemeral private key
- Create DSSE envelope with signature + certificate
- Submit to Rekor transparency log (if configured)
- Discard ephemeral private key
Air-Gap Mode
When signer == null:
- Create DSSE envelope structure
- Set
keyidtocgs:{hash}(content-addressed identifier) - Set
sigto"unsigned:use-signer-service-for-production-signatures" - Log warning if envelope is used in production context
Security Considerations
Proof-of-Entitlement (PoE)
Critical: Even when IDsseSigner is available, production verdict signing must go through the Signer service pipeline for:
- Caller Authentication: Verify caller has permission to sign verdicts
- Proof-of-Entitlement: Validate caller owns/operates the artifacts being assessed
- Quota Enforcement: Rate-limit signing operations per tenant
- Audit Logging: Record who signed what, when, and why
VerdictBuilderService Role: Creates unsigned envelopes (or test signatures)
Signer Service Role: Applies production signatures with PoE validation
Separation of Concerns
┌─────────────────────────────────────────────────────────────┐
│ VerdictBuilderService │
│ - Computes CGS hash │
│ - Builds verdict payload │
│ - Creates unsigned DSSE envelope │
│ - Returns VerdictResult │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Signer Service (Production Pipeline) │
│ - Validates Proof-of-Entitlement (PoE) │
│ - Authenticates caller │
│ - Enforces quotas │
│ - Signs verdict with Fulcio/Long-lived key │
│ - Submits to Rekor transparency log │
│ - Returns signed DSSE envelope │
└─────────────────────────────────────────────────────────────┘
Testing Strategy
Unit Tests
// Test with null signer (air-gap mode)
[Fact]
public async Task BuildAsync_WithoutSigner_CreatesUnsignedEnvelope()
{
var service = new VerdictBuilderService(logger, signer: null);
var result = await service.BuildAsync(evidence, policyLock, ct);
result.Dsse.Signatures[0].Sig.Should().StartWith("unsigned:");
}
// Test with mock signer (cloud mode)
[Fact]
public async Task BuildAsync_WithSigner_UsesSignerForProduction()
{
var mockSigner = new Mock<IDsseSigner>();
var service = new VerdictBuilderService(logger, mockSigner.Object);
// Note: Current implementation creates unsigned envelope
// Production signing happens via Signer service pipeline
}
Integration Tests
[Fact]
public async Task EndToEnd_VerdictSigning_WithFulcio()
{
// Arrange: Full production pipeline
var fulcioClient = new FulcioClient(fulcioUrl);
var oidcProvider = new AmbientOidcTokenProvider();
var signer = new KeylessDsseSigner(fulcioClient, oidcProvider);
var signerService = new SignerService(signer, poeValidator, quotaEnforcer);
var verdictBuilder = new VerdictBuilderService(logger, signer);
// Act: Build verdict → Sign with PoE → Verify
var verdict = await verdictBuilder.BuildAsync(evidence, policyLock, ct);
var signedEnvelope = await signerService.SignVerdictAsync(verdict, proofOfEntitlement, ct);
// Assert
signedEnvelope.Signatures.Should().HaveCount(1);
signedEnvelope.Signatures[0].Sig.Should().NotStartWith("unsigned:");
}
Migration
No migration required - this is a new feature.
Backward Compatibility: Existing code that creates VerdictBuilderService without signer parameter will use default signer: null (air-gap mode).
Monitoring
Metrics
verdict_builder.signing_mode{mode="airgap"}- Count of air-gap verdictsverdict_builder.signing_mode{mode="keyless"}- Count of keyless verdictsverdict_builder.unsigned_envelopes_created- Count of unsigned envelopes
Alerts
- Warning: High volume of unsigned verdicts in production (should go through Signer service)
- Error: Signer initialization failed in cloud deployment
- Critical: OIDC token acquisition failed (blocks keyless signing)
References
- Sprint:
docs/implplan/archived/SPRINT_20251229_001_001_BE_cgs_infrastructure.md - Implementation:
src/__Libraries/StellaOps.Verdict/VerdictBuilderService.cs - Signer Interface:
src/Signer/StellaOps.Signer/StellaOps.Signer.Core/IDsseSigner.cs - Keyless Implementation:
src/Signer/__Libraries/StellaOps.Signer.Keyless/KeylessDsseSigner.cs - ADR 0042: CGS Merkle Tree Implementation
Decision Date
2025-12-29
Decision Makers
- Backend Team
- Security Team
- DevOps Team (air-gap deployment experts)
Review Date
2026-06-29 (6 months) - Evaluate if PoE integration needs tighter coupling