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.
442 lines
14 KiB
Markdown
442 lines
14 KiB
Markdown
# 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<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)
|
|
|
|
```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<IDsseSigner, KeylessDsseSigner>();
|
|
services.AddSingleton<IVerdictBuilder, VerdictBuilderService>();
|
|
```
|
|
|
|
#### Air-Gapped Deployment
|
|
|
|
```csharp
|
|
// 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)
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```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<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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```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
|
|
{
|
|
/// <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
|
|
|
|
```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<IDsseSigner>();
|
|
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
|