Builds the previously-aspirational Capsule create/seal/verify/export/replay
pipeline. Unblocks the former CAPSULE-001 task that lived (BLOCKED) in
SPRINT_20260408_005; carried over as CAPSULE-AUDIT-001 inside the new
SPRINT_20260422_002 (created + archived in same pass).
Pipeline:
- CapsuleManifest record: deterministic SBOM+feeds+reachability+policy+VEX
content-address bundle.
- CapsuleManifestCanonicalizer: mirrors AUDIT-007 algorithm byte-for-byte
(ordinal-sorted UTF-8 JSON via JsonDocument round-trip).
- ICapsuleSigner + EcdsaCapsuleSigner + NullCapsuleSigner: DSSE PAE
contract, DSSE payload type application/vnd.stellaops.decision-capsule+json.
Pattern-identical to IAuditBundleManifestSigner; defined locally rather
than cross-referencing IExportAttestationSigner (which lives inside
ExportCenter.WebService, not a shared library — future cleanup noted).
- CapsuleService: create / seal / verify / export (zip) / replay.
- PostgresCapsuleRepository (Dapper) with tenant RLS hookup.
Endpoints (all tenant-scoped, POST):
- POST /api/v1/evidence/capsules
- POST /api/v1/evidence/capsules/{id}/seal
- POST /api/v1/evidence/capsules/{id}/verify
- POST /api/v1/evidence/capsules/{id}/export (application/zip)
- POST /api/v1/evidence/capsules/{id}/replay
Storage: embedded migration 005_decision_capsules.sql creates
evidence_locker.decision_capsules (RLS-enforced) + indexes + CHECK
constraints. Auto-applied by existing EvidenceLockerMigrationRunner.
Audit (CAPSULE-AUDIT-001):
- 5 new AuditActions.Evidence constants (CreateCapsule/Seal/Verify/Export/Replay)
- Each endpoint chained with .Audited(AuditModules.Evidence, ...)
- contentHash surfaced on responses so AuditActionFilter propagates it
into details_jsonb.
Tests: 9 focused tests (determinism x3, sign+verify+tamper x3, null-signer
graceful degradation, pipeline round-trip, 404 on missing). Full
EvidenceLocker namespace sweep: 141/141, 0 failures.
Docs: docs/modules/evidence-locker/architecture.md §9bis (manifest schema,
DSSE payload type, storage, API surface, relationship to
release.run_capsule_replay_linkage).
Runtime curl+Timeline assertion deferred — running container image
predates these changes; rebuild pending. Structural wiring identical to
runtime-verified VerdictEndpoints (AUDIT-002 precedent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
component_architecture_evidence_locker.md - Stella Ops EvidenceLocker (2025Q4)
Sealed, immutable storage for vulnerability scan evidence and audit logs.
Scope. Implementation-ready architecture for EvidenceLocker: tamper-proof evidence chains for compliance and forensic analysis with content-addressable storage and cryptographic sealing.
0) Mission & boundaries
Mission. Provide immutable, sealed storage for vulnerability scan evidence, audit logs, and compliance artifacts. Ensure tamper-proof evidence chains with cryptographic verification for forensic analysis and regulatory compliance.
Boundaries.
- EvidenceLocker stores evidence; it does not generate verdicts.
- EvidenceLocker seals bundles; signing is delegated to Signer.
- Evidence is immutable once sealed; no modifications or deletions.
- Supports offline export for air-gapped compliance audits.
1) Solution & project layout
src/EvidenceLocker/StellaOps.EvidenceLocker/
├─ StellaOps.EvidenceLocker.Core/ # Sealing, verification, chain validation
│ ├─ Services/
│ │ ├─ ISealingService.cs # Sealing interface
│ │ ├─ SealingService.cs # Cryptographic sealing
│ │ ├─ IVerificationService.cs # Verification interface
│ │ └─ ChainValidator.cs # Evidence chain validation
│ └─ Models/
│ ├─ EvidenceBundle.cs # Bundle model
│ ├─ EvidenceItem.cs # Individual evidence item
│ └─ SealManifest.cs # Seal metadata
│
├─ StellaOps.EvidenceLocker.Infrastructure/ # Storage adapters, bundle management
│ ├─ Storage/
│ │ ├─ IEvidenceStore.cs # Storage interface
│ │ ├─ FileSystemStore.cs # Local filesystem
│ │ └─ ObjectStore.cs # S3/RustFS storage
│ └─ Persistence/
│ └─ PostgresRepository.cs # Metadata persistence
│
├─ StellaOps.EvidenceLocker.WebService/ # HTTP API for submission/retrieval
│ └─ Program.cs
│
├─ StellaOps.EvidenceLocker.Worker/ # Background sealing and archival
│ └─ SealingWorker.cs
│
└─ StellaOps.EvidenceLocker.Tests/ # Unit and integration tests
2) External dependencies
- PostgreSQL - Metadata storage (schema:
evidence_locker) - RustFS/S3 - Object storage for evidence bundles
- Signer - Cryptographic sealing operations
- Authority - Authentication and authorization
- ExportCenter - Evidence bundle export
3) Contracts & data model
3.1 EvidenceBundle
{
"bundleId": "eb-2025-01-15-abc123",
"tenantId": "tenant-xyz",
"createdAt": "2025-01-15T10:30:00.000000Z",
"sealedAt": "2025-01-15T10:30:05.000000Z",
"status": "sealed",
"items": [
{
"itemId": "item-001",
"type": "sbom",
"format": "cyclonedx-json",
"digest": "sha256:abc123...",
"size": 45678,
"casUri": "cas://evidence/items/abc123"
},
{
"itemId": "item-002",
"type": "scan-result",
"format": "stellaops-findings-v1",
"digest": "sha256:def456...",
"size": 12345,
"casUri": "cas://evidence/items/def456"
}
],
"seal": {
"algorithm": "sha256",
"rootHash": "sha256:fedcba...",
"signature": "base64...",
"keyId": "sha256:keyabc..."
},
"chain": {
"previousBundleId": "eb-2025-01-14-xyz789",
"previousRootHash": "sha256:prevhash...",
"sequenceNumber": 42
}
}
3.2 Evidence Item Types
| Type | Format | Description |
|---|---|---|
sbom |
cyclonedx-json, spdx-json | Software Bill of Materials |
scan-result |
stellaops-findings-v1 | Vulnerability scan findings |
policy-verdict |
stellaops-verdict-v1 | Policy evaluation result |
vex-statement |
openvex-v1 | VEX statement |
audit-log |
ndjson | Audit trail events |
attestation |
dsse-v1 | DSSE attestation envelope |
3.3 Seal Manifest
public sealed record SealManifest
{
public required string BundleId { get; init; }
public required string RootHash { get; init; }
public required string Algorithm { get; init; }
public required DateTimeOffset SealedAt { get; init; }
public required string KeyId { get; init; }
public required byte[] Signature { get; init; }
public required IReadOnlyList<string> ItemDigests { get; init; }
}
4) REST API (EvidenceLocker.WebService)
All under /api/v1/evidence. Auth: OpTok.
POST /bundles { items: EvidenceItem[] } → { bundleId, status: "pending" }
POST /bundles/{id}/items { item: EvidenceItem } → { itemId }
POST /bundles/{id}/seal → { status: "sealed", seal: SealManifest }
GET /bundles/{id} → { bundle: EvidenceBundle }
GET /bundles/{id}/items/{itemId} → binary content
GET /bundles/{id}/verify → { valid: bool, details }
GET /bundles?tenant={id}&from={date}&to={date} → { bundles: BundleSummary[] }
POST /export { bundleIds: string[], format: "zip"|"tar" } → { exportId }
GET /export/{id} → binary archive
GET /export/{id}/status → { status, progress }
POST /evidence { producer_bundle, raw_bom_path?, vex_refs[]? } → { evidence_id, evidence_score, stored }
GET /evidence/score?artifact_id={id} → { evidence_score, status }
GET /healthz | /readyz | /metrics
5) Configuration (YAML)
EvidenceLocker:
Postgres:
ConnectionString: "Host=postgres;Database=evidence_locker;..."
Storage:
Provider: "rustfs" # or "filesystem", "s3"
RustFs:
Endpoint: "http://rustfs:8080"
Bucket: "stellaops-evidence"
Filesystem:
BasePath: "/data/evidence"
Sealing:
Policy: "immediate" # or "batch"
BatchSize: 100
BatchIntervalSeconds: 60
Algorithm: "sha256"
Retention:
DefaultDays: 2555 # 7 years
ComplianceDays: 3650 # 10 years for regulated
Export:
MaxBundlesPerExport: 1000
CompressionLevel: 6
Authority:
Issuer: "https://authority.stellaops.local"
RequiredScopes: ["evidence:read", "evidence:write"]
6) Sealing Process
6.1 Sealing Flow
1. Bundle created (status: "pending")
└─ Items added with content digests
2. Sealing triggered (immediate or batch)
├─ Compute Merkle root from item digests
├─ Include chain pointer (previous bundle hash)
└─ Request signature from Signer
3. Bundle sealed (status: "sealed")
└─ Immutable; no further modifications
6.2 Chain Integrity
Evidence chains are linked via Merkle roots:
Bundle N-1 Bundle N Bundle N+1
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ rootHash: H1│◄────────────│ prevHash: H1│◄─────────────│ prevHash: H2│
│ seq: 41 │ │ rootHash: H2│ │ rootHash: H3│
└─────────────┘ │ seq: 42 │ │ seq: 43 │
└─────────────┘ └─────────────┘
7) Security & compliance
- Immutability: Sealed bundles cannot be modified
- Tamper detection: Merkle tree verification for all items
- Chain validation: Linked bundle verification
- Encryption at rest: Optional bundle encryption
- Access control: Tenant-scoped with Authority tokens
- Audit trail: All access logged
8) Performance targets
- Item ingestion: < 100ms P95 per item
- Sealing: < 500ms P95 per bundle (up to 100 items)
- Verification: < 200ms P95 per bundle
- Export: < 5s P95 for 100 bundles
9) Observability
Metrics:
evidence.bundles.created_total{tenant}evidence.bundles.sealed_total{tenant}evidence.items.ingested_total{type}evidence.verification.duration_secondsevidence.storage.bytes_total
Tracing: Spans for ingestion, sealing, verification, export.
9bis) Decision Capsule pipeline
Sprint SPRINT_20260422_002 CAPSULE-PIPELINE-001 introduces a content-addressed Decision Capsule that binds a release decision's input set (SBOM, vuln feeds, reachability evidence, policy version, derived VEX) into a deterministic, DSSE-signed manifest. The capsule becomes the verifiable replay anchor for a release decision.
9bis.1) Manifest & canonicalisation
- Type:
StellaOps.EvidenceLocker.Capsules.CapsuleManifest(record). - apiVersion / kind:
stella.ops/v1/DecisionCapsuleManifest. - DSSE payload type:
application/vnd.stellaops.decision-capsule+json(distinct from the audit-bundle payload type introduced by AUDIT-007). - Canonicalisation:
CapsuleManifestCanonicalizer.Canonicalize(manifest)— UTF-8, non-indented, keys ordinal-sorted. Algorithm mirrors the audit-bundle canonicaliser (AUDIT-007) so verifiers can share a single canonical-JSON path. - Content address: SHA-256 of the canonicalised bytes, surfaced as
contentHashon every API response and persisted alongside the manifest inevidence_locker.decision_capsules.
9bis.2) Storage
Table evidence_locker.decision_capsules (migration 005_decision_capsules.sql):
| column | type | notes |
|---|---|---|
capsule_id |
uuid (PK) |
|
tenant_id |
uuid |
RLS via evidence_locker_app.require_current_tenant() |
manifest_json |
text |
Canonical manifest bytes (stored as text for byte-exact replay) |
content_hash |
text |
lowercase-hex SHA-256 of manifest_json |
dsse_envelope |
text / NULL |
DSSE envelope JSON (omitted for unsealed or unsigned capsules) |
signing_status |
text |
unsealed | signed | unsigned | invalid |
assembly_inputs |
jsonb |
Opaque input references the capsule was assembled from |
sealed_at |
timestamptz |
Non-null once sealed |
Auto-migration applies on service startup via EvidenceLockerMigrationRunner (embedded resource; no manual SQL required).
9bis.3) Signing
The pipeline reuses the AUDIT-007 pattern: canonicalise the manifest, then delegate DSSE PAE signing to an abstraction (ICapsuleSigner). Default implementation is EcdsaCapsuleSigner (ECDSA P-256 / SHA-256) which produces an envelope shaped identically to the export / audit-bundle DSSE envelopes. When no signer is registered, NullCapsuleSigner returns an unsigned result with signing_status = "signer_not_registered" — same graceful degradation contract as AUDIT-007.
9bis.4) API surface
All endpoints live under /api/v1/evidence/capsules, are tenant-scoped, and emit audit events (module=evidence) via StellaOps.Audit.Emission:
| Method | Route | Audit action | Scope |
|---|---|---|---|
| POST | /api/v1/evidence/capsules |
create_capsule |
EvidenceCreate |
| POST | /api/v1/evidence/capsules/{id}/seal |
seal_capsule |
EvidenceCreate |
| POST | /api/v1/evidence/capsules/{id}/verify |
verify_capsule |
EvidenceRead |
| POST | /api/v1/evidence/capsules/{id}/export |
export_capsule |
EvidenceRead |
| POST | /api/v1/evidence/capsules/{id}/replay |
replay_capsule |
EvidenceRead |
Export returns a application/zip bundle with manifest.json, manifest.dsse.json (if sealed + signed), assembly-inputs.json, and metadata.json. Replay deserialises the sealed manifest, recomputes its content hash, and returns the reconstructed verdict.
9bis.5) Relationship to release.run_capsule_replay_linkage
The existing release.run_capsule_replay_linkage table (SPRINT_20260220_023 B23-RUN-06) continues to link a release run to a capsule by decision_capsule_id / capsule_hash. The Decision Capsule pipeline is the source of those fields: the content_hash this pipeline computes is what the linkage row's capsule_hash column references, and signature_status mirrors the capsule's signing_status.
10) Testing matrix
- Sealing tests: Correct Merkle root computation
- Chain tests: Linked bundle verification
- Tamper tests: Detection of modified items
- Export tests: Archive integrity verification
- Retention tests: Policy enforcement
Related Documentation
- Bundle packaging:
./bundle-packaging.md - Attestation contract:
./attestation-contract.md - Evidence bundle spec:
./evidence-bundle-v1.md - Evidence pack schema:
./guides/evidence-pack-schema.md - Promotion gate evidence contract:
./promotion-evidence-contract.md - Audit bundle index schema:
./schemas/audit-bundle-index.schema.json - ExportCenter:
../export-center/architecture.md - Attestor:
../attestor/architecture.md