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.
This commit is contained in:
441
docs/adr/0043-fulcio-keyless-signing-optional-parameter.md
Normal file
441
docs/adr/0043-fulcio-keyless-signing-optional-parameter.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user