Files
git.stella-ops.org/docs/adr/0043-fulcio-keyless-signing-optional-parameter.md
master a4badc275e UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization
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.
2025-12-29 19:12:38 +02:00

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

  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

// 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: 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

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 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

// 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 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