# 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 1. **Single Codebase**: Same `VerdictBuilderService` for both modes 2. **Runtime Configuration**: Deployment mode determined at startup 3. **No Breaking Changes**: Existing air-gap deployments must continue working 4. **Clear Separation**: Signing concerns separate from verdict building logic ## Decision **Add optional `IDsseSigner? signer` parameter to `VerdictBuilderService` constructor.** ### Rationale 1. **Dependency Injection Friendly**: Signer is injected (or not) based on deployment config 2. **Explicit Air-Gap Mode**: `null` signer clearly indicates air-gapped operation 3. **Single Implementation**: No need for separate `VerdictBuilderService` classes 4. **Production Signing Pipeline**: Even with signer available, production verdicts go through `StellaOps.Signer` service for Proof-of-Entitlement (PoE) validation ### Implementation ```csharp // VerdictBuilderService.cs public sealed class VerdictBuilderService : IVerdictBuilder { private readonly ILogger _logger; private readonly IDsseSigner? _signer; // Null for air-gap mode /// /// Creates a VerdictBuilderService. /// /// Logger instance /// Optional DSSE signer (e.g., KeylessDsseSigner for Fulcio). /// Null for air-gapped deployments. public VerdictBuilderService( ILogger 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 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) ```csharp // 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(); services.AddSingleton(); ``` #### Air-Gapped Deployment ```csharp // Program.cs services.AddVerdictBuilder(options => { options.SigningMode = VerdictSigningMode.AirGap; // No signer configured }); // Internal implementation: // IDsseSigner not registered services.AddSingleton(); ``` #### Long-Lived Key Deployment (Future) ```csharp // Program.cs services.AddVerdictBuilder(options => { options.SigningMode = VerdictSigningMode.LongLivedKey; options.KeyPath = "/etc/stellaops/signing-key.pem"; }); // Internal implementation: services.AddSingleton(); services.AddSingleton(); ``` ## Consequences ### Positive - ✅ **Single Codebase**: Same service for all deployment modes - ✅ **Clear Intent**: `null` signer 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 ```csharp 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 ```csharp public interface IVerdictSigningStrategy { Task 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 ```csharp public class VerdictBuilderService { private readonly VerdictBuilderOptions _options; public VerdictBuilderService(IOptions 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 ```csharp 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 ```csharp // StellaOps.Signer.Core.IDsseSigner public interface IDsseSigner { /// /// Signs a payload and returns DSSE envelope. /// Task 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 issuance - `IAmbientOidcTokenProvider` - OIDC token acquisition - `IRekorClient` - Transparency log submission (optional) **Workflow:** 1. Generate ephemeral key pair (ECDSA P-256) 2. Acquire OIDC token from ambient provider 3. Request certificate from Fulcio (binds identity to public key) 4. Sign payload with ephemeral private key 5. Create DSSE envelope with signature + certificate 6. Submit to Rekor transparency log (if configured) 7. Discard ephemeral private key ### Air-Gap Mode When `signer == null`: 1. Create DSSE envelope structure 2. Set `keyid` to `cgs:{hash}` (content-addressed identifier) 3. Set `sig` to `"unsigned:use-signer-service-for-production-signatures"` 4. 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: 1. **Caller Authentication**: Verify caller has permission to sign verdicts 2. **Proof-of-Entitlement**: Validate caller owns/operates the artifacts being assessed 3. **Quota Enforcement**: Rate-limit signing operations per tenant 4. **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 ```csharp // 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(); var service = new VerdictBuilderService(logger, mockSigner.Object); // Note: Current implementation creates unsigned envelope // Production signing happens via Signer service pipeline } ``` ### Integration Tests ```csharp [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 verdicts - `verdict_builder.signing_mode{mode="keyless"}` - Count of keyless verdicts - `verdict_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