Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management. - Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management. - Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support. - Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Audit log entry for proof chain operations.
|
||||
/// Maps to proofchain.audit_log table.
|
||||
/// </summary>
|
||||
[Table("audit_log", Schema = "proofchain")]
|
||||
public class AuditLogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - auto-generated UUID.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("log_id")]
|
||||
public Guid LogId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The operation performed (e.g., "create", "verify", "revoke").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("operation")]
|
||||
public string Operation { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The type of entity affected (e.g., "sbom_entry", "spine", "trust_anchor").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("entity_type")]
|
||||
public string EntityType { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the affected entity.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("entity_id")]
|
||||
public string EntityId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The actor who performed the operation (user, service, etc.).
|
||||
/// </summary>
|
||||
[Column("actor")]
|
||||
public string? Actor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details about the operation.
|
||||
/// </summary>
|
||||
[Column("details", TypeName = "jsonb")]
|
||||
public JsonDocument? Details { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this log entry was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Signed DSSE envelope for proof chain statements.
|
||||
/// Maps to proofchain.dsse_envelopes table.
|
||||
/// </summary>
|
||||
[Table("dsse_envelopes", Schema = "proofchain")]
|
||||
public class DsseEnvelopeEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - auto-generated UUID.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("env_id")]
|
||||
public Guid EnvId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the SBOM entry this envelope relates to.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("entry_id")]
|
||||
public Guid EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI (e.g., evidence.stella/v1).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("predicate_type")]
|
||||
public string PredicateType { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that signed this envelope.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("signer_keyid")]
|
||||
public string SignerKeyId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the envelope body.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
[Column("body_hash")]
|
||||
public string BodyHash { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to blob storage (OCI, S3, file).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("envelope_blob_ref")]
|
||||
public string EnvelopeBlobRef { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// When the envelope was signed.
|
||||
/// </summary>
|
||||
[Column("signed_at")]
|
||||
public DateTimeOffset SignedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this record was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM entry this envelope relates to.
|
||||
/// </summary>
|
||||
public SbomEntryEntity Entry { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The Rekor transparency log entry if logged.
|
||||
/// </summary>
|
||||
public RekorEntryEntity? RekorEntry { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry for DSSE envelope verification.
|
||||
/// Maps to proofchain.rekor_entries table.
|
||||
/// </summary>
|
||||
[Table("rekor_entries", Schema = "proofchain")]
|
||||
public class RekorEntryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - SHA-256 hash of the DSSE envelope.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[MaxLength(64)]
|
||||
[Column("dsse_sha256")]
|
||||
public string DsseSha256 { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Log index in Rekor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("log_index")]
|
||||
public long LogIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log ID (tree hash).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("log_id")]
|
||||
public string LogId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// UUID of the entry in Rekor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("uuid")]
|
||||
public string Uuid { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Unix timestamp when entry was integrated into the log.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("integrated_time")]
|
||||
public long IntegratedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle inclusion proof from Rekor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("inclusion_proof", TypeName = "jsonb")]
|
||||
public JsonDocument InclusionProof { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// When this record was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the DSSE envelope.
|
||||
/// </summary>
|
||||
[Column("env_id")]
|
||||
public Guid? EnvId { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope this entry refers to.
|
||||
/// </summary>
|
||||
public DsseEnvelopeEntity? Envelope { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component entry with content-addressed identifiers.
|
||||
/// Maps to proofchain.sbom_entries table.
|
||||
/// </summary>
|
||||
[Table("sbom_entries", Schema = "proofchain")]
|
||||
public class SbomEntryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - auto-generated UUID.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("entry_id")]
|
||||
public Guid EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the parent SBOM document.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
[Column("bom_digest")]
|
||||
public string BomDigest { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the component.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("purl")]
|
||||
public string Purl { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
[Column("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the component artifact if available.
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
[Column("artifact_digest")]
|
||||
public string? ArtifactDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the trust anchor for this entry.
|
||||
/// </summary>
|
||||
[Column("trust_anchor_id")]
|
||||
public Guid? TrustAnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this entry was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// The trust anchor for this entry.
|
||||
/// </summary>
|
||||
public TrustAnchorEntity? TrustAnchor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelopes associated with this entry.
|
||||
/// </summary>
|
||||
public ICollection<DsseEnvelopeEntity> Envelopes { get; set; } = new List<DsseEnvelopeEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// The proof spine for this entry.
|
||||
/// </summary>
|
||||
public SpineEntity? Spine { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Proof spine linking evidence to verdicts via merkle aggregation.
|
||||
/// Maps to proofchain.spines table.
|
||||
/// </summary>
|
||||
[Table("spines", Schema = "proofchain")]
|
||||
public class SpineEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - references SBOM entry.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("entry_id")]
|
||||
public Guid EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ProofBundleID (merkle root of all components).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
[Column("bundle_id")]
|
||||
public string BundleId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Array of EvidenceIDs in sorted order.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("evidence_ids", TypeName = "text[]")]
|
||||
public string[] EvidenceIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// ReasoningID for the policy evaluation.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
[Column("reasoning_id")]
|
||||
public string ReasoningId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// VexVerdictID for the VEX statement.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
[Column("vex_id")]
|
||||
public string VexId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the trust anchor.
|
||||
/// </summary>
|
||||
[Column("anchor_id")]
|
||||
public Guid? AnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version used for evaluation.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("policy_version")]
|
||||
public string PolicyVersion { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// When this spine was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM entry this spine covers.
|
||||
/// </summary>
|
||||
public SbomEntryEntity Entry { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The trust anchor for this spine.
|
||||
/// </summary>
|
||||
public TrustAnchorEntity? Anchor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor configuration for dependency verification.
|
||||
/// Maps to proofchain.trust_anchors table.
|
||||
/// </summary>
|
||||
[Table("trust_anchors", Schema = "proofchain")]
|
||||
public class TrustAnchorEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - auto-generated UUID.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("anchor_id")]
|
||||
public Guid AnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL glob pattern (e.g., pkg:npm/*).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("purl_pattern")]
|
||||
public string PurlPattern { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Key IDs allowed to sign attestations matching this pattern.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("allowed_keyids", TypeName = "text[]")]
|
||||
public string[] AllowedKeyIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Predicate types allowed for this anchor.
|
||||
/// </summary>
|
||||
[Column("allowed_predicate_types", TypeName = "text[]")]
|
||||
public string[]? AllowedPredicateTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to the policy document.
|
||||
/// </summary>
|
||||
[Column("policy_ref")]
|
||||
public string? PolicyRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version for this anchor.
|
||||
/// </summary>
|
||||
[Column("policy_version")]
|
||||
public string? PolicyVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key IDs that have been revoked but may appear in old proofs.
|
||||
/// </summary>
|
||||
[Column("revoked_keys", TypeName = "text[]")]
|
||||
public string[] RevokedKeys { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this anchor is active.
|
||||
/// </summary>
|
||||
[Column("is_active")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When this anchor was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this anchor was last updated.
|
||||
/// </summary>
|
||||
[Column("updated_at")]
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
-- Migration: 20251214000001_AddProofChainSchema
|
||||
-- Creates the proofchain schema and all tables for proof chain persistence.
|
||||
-- This migration is idempotent and can be run multiple times safely.
|
||||
|
||||
-- Create schema
|
||||
CREATE SCHEMA IF NOT EXISTS proofchain;
|
||||
|
||||
-- Create verification_result enum type
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'verification_result' AND typnamespace = 'proofchain'::regnamespace) THEN
|
||||
CREATE TYPE proofchain.verification_result AS ENUM ('pass', 'fail', 'pending');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 4.4 trust_anchors Table (create first - no dependencies)
|
||||
CREATE TABLE IF NOT EXISTS proofchain.trust_anchors (
|
||||
anchor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
purl_pattern TEXT NOT NULL,
|
||||
allowed_keyids TEXT[] NOT NULL,
|
||||
allowed_predicate_types TEXT[],
|
||||
policy_ref TEXT,
|
||||
policy_version TEXT,
|
||||
revoked_keys TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trust_anchors_pattern ON proofchain.trust_anchors(purl_pattern);
|
||||
CREATE INDEX IF NOT EXISTS idx_trust_anchors_active ON proofchain.trust_anchors(is_active) WHERE is_active = TRUE;
|
||||
|
||||
COMMENT ON TABLE proofchain.trust_anchors IS 'Trust anchor configurations for dependency verification';
|
||||
COMMENT ON COLUMN proofchain.trust_anchors.purl_pattern IS 'PURL glob pattern (e.g., pkg:npm/*)';
|
||||
COMMENT ON COLUMN proofchain.trust_anchors.revoked_keys IS 'Key IDs that have been revoked but may appear in old proofs';
|
||||
|
||||
-- 4.1 sbom_entries Table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.sbom_entries (
|
||||
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bom_digest VARCHAR(64) NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
version TEXT,
|
||||
artifact_digest VARCHAR(64),
|
||||
trust_anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Compound unique constraint for idempotent inserts
|
||||
CONSTRAINT uq_sbom_entry UNIQUE (bom_digest, purl, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_bom_digest ON proofchain.sbom_entries(bom_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_purl ON proofchain.sbom_entries(purl);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_artifact ON proofchain.sbom_entries(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_anchor ON proofchain.sbom_entries(trust_anchor_id);
|
||||
|
||||
COMMENT ON TABLE proofchain.sbom_entries IS 'SBOM component entries with content-addressed identifiers';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.bom_digest IS 'SHA-256 hash of the parent SBOM document';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.purl IS 'Package URL (PURL) of the component';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.artifact_digest IS 'SHA-256 hash of the component artifact if available';
|
||||
|
||||
-- 4.2 dsse_envelopes Table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.dsse_envelopes (
|
||||
env_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entry_id UUID NOT NULL REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
|
||||
predicate_type TEXT NOT NULL,
|
||||
signer_keyid TEXT NOT NULL,
|
||||
body_hash VARCHAR(64) NOT NULL,
|
||||
envelope_blob_ref TEXT NOT NULL,
|
||||
signed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Prevent duplicate envelopes for same entry/predicate
|
||||
CONSTRAINT uq_dsse_envelope UNIQUE (entry_id, predicate_type, body_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_entry_predicate ON proofchain.dsse_envelopes(entry_id, predicate_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_signer ON proofchain.dsse_envelopes(signer_keyid);
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_body_hash ON proofchain.dsse_envelopes(body_hash);
|
||||
|
||||
COMMENT ON TABLE proofchain.dsse_envelopes IS 'Signed DSSE envelopes for proof chain statements';
|
||||
COMMENT ON COLUMN proofchain.dsse_envelopes.predicate_type IS 'Predicate type URI (e.g., evidence.stella/v1)';
|
||||
COMMENT ON COLUMN proofchain.dsse_envelopes.envelope_blob_ref IS 'Reference to blob storage (OCI, S3, file)';
|
||||
|
||||
-- 4.3 spines Table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.spines (
|
||||
entry_id UUID PRIMARY KEY REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
|
||||
bundle_id VARCHAR(64) NOT NULL,
|
||||
evidence_ids TEXT[] NOT NULL,
|
||||
reasoning_id VARCHAR(64) NOT NULL,
|
||||
vex_id VARCHAR(64) NOT NULL,
|
||||
anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Bundle ID must be unique
|
||||
CONSTRAINT uq_spine_bundle UNIQUE (bundle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_bundle ON proofchain.spines(bundle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_anchor ON proofchain.spines(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_policy ON proofchain.spines(policy_version);
|
||||
|
||||
COMMENT ON TABLE proofchain.spines IS 'Proof spines linking evidence to verdicts via merkle aggregation';
|
||||
COMMENT ON COLUMN proofchain.spines.bundle_id IS 'ProofBundleID (merkle root of all components)';
|
||||
COMMENT ON COLUMN proofchain.spines.evidence_ids IS 'Array of EvidenceIDs in sorted order';
|
||||
|
||||
-- 4.5 rekor_entries Table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.rekor_entries (
|
||||
dsse_sha256 VARCHAR(64) PRIMARY KEY,
|
||||
log_index BIGINT NOT NULL,
|
||||
log_id TEXT NOT NULL,
|
||||
uuid TEXT NOT NULL,
|
||||
integrated_time BIGINT NOT NULL,
|
||||
inclusion_proof JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Reference to the DSSE envelope
|
||||
env_id UUID REFERENCES proofchain.dsse_envelopes(env_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_log_index ON proofchain.rekor_entries(log_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_log_id ON proofchain.rekor_entries(log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_uuid ON proofchain.rekor_entries(uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_env ON proofchain.rekor_entries(env_id);
|
||||
|
||||
COMMENT ON TABLE proofchain.rekor_entries IS 'Rekor transparency log entries for verification';
|
||||
COMMENT ON COLUMN proofchain.rekor_entries.inclusion_proof IS 'Merkle inclusion proof from Rekor';
|
||||
|
||||
-- Audit log table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.audit_log (
|
||||
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
operation TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON proofchain.audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON proofchain.audit_log(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE proofchain.audit_log IS 'Audit log for proof chain operations';
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION proofchain.update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Apply updated_at trigger to trust_anchors
|
||||
DROP TRIGGER IF EXISTS update_trust_anchors_updated_at ON proofchain.trust_anchors;
|
||||
CREATE TRIGGER update_trust_anchors_updated_at
|
||||
BEFORE UPDATE ON proofchain.trust_anchors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION proofchain.update_updated_at_column();
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration: 20251214000002_RollbackProofChainSchema
|
||||
-- Rollback script for the proofchain schema.
|
||||
-- WARNING: This will delete all proof chain data!
|
||||
|
||||
-- Drop tables in reverse dependency order
|
||||
DROP TABLE IF EXISTS proofchain.audit_log CASCADE;
|
||||
DROP TABLE IF EXISTS proofchain.rekor_entries CASCADE;
|
||||
DROP TABLE IF EXISTS proofchain.spines CASCADE;
|
||||
DROP TABLE IF EXISTS proofchain.dsse_envelopes CASCADE;
|
||||
DROP TABLE IF EXISTS proofchain.sbom_entries CASCADE;
|
||||
DROP TABLE IF EXISTS proofchain.trust_anchors CASCADE;
|
||||
|
||||
-- Drop types
|
||||
DROP TYPE IF EXISTS proofchain.verification_result CASCADE;
|
||||
|
||||
-- Drop functions
|
||||
DROP FUNCTION IF EXISTS proofchain.update_updated_at_column() CASCADE;
|
||||
|
||||
-- Drop schema
|
||||
DROP SCHEMA IF EXISTS proofchain CASCADE;
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for proof chain persistence.
|
||||
/// </summary>
|
||||
public class ProofChainDbContext : DbContext
|
||||
{
|
||||
public ProofChainDbContext(DbContextOptions<ProofChainDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM entries table.
|
||||
/// </summary>
|
||||
public DbSet<SbomEntryEntity> SbomEntries => Set<SbomEntryEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelopes table.
|
||||
/// </summary>
|
||||
public DbSet<DsseEnvelopeEntity> DsseEnvelopes => Set<DsseEnvelopeEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Proof spines table.
|
||||
/// </summary>
|
||||
public DbSet<SpineEntity> Spines => Set<SpineEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchors table.
|
||||
/// </summary>
|
||||
public DbSet<TrustAnchorEntity> TrustAnchors => Set<TrustAnchorEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entries table.
|
||||
/// </summary>
|
||||
public DbSet<RekorEntryEntity> RekorEntries => Set<RekorEntryEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Audit log table.
|
||||
/// </summary>
|
||||
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure schema
|
||||
modelBuilder.HasDefaultSchema("proofchain");
|
||||
|
||||
// SbomEntryEntity configuration
|
||||
modelBuilder.Entity<SbomEntryEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.BomDigest).HasDatabaseName("idx_sbom_entries_bom_digest");
|
||||
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_sbom_entries_purl");
|
||||
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_sbom_entries_artifact");
|
||||
entity.HasIndex(e => e.TrustAnchorId).HasDatabaseName("idx_sbom_entries_anchor");
|
||||
|
||||
// Unique constraint
|
||||
entity.HasIndex(e => new { e.BomDigest, e.Purl, e.Version })
|
||||
.HasDatabaseName("uq_sbom_entry")
|
||||
.IsUnique();
|
||||
|
||||
// Relationships
|
||||
entity.HasOne(e => e.TrustAnchor)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.TrustAnchorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasMany(e => e.Envelopes)
|
||||
.WithOne(e => e.Entry)
|
||||
.HasForeignKey(e => e.EntryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Spine)
|
||||
.WithOne(e => e.Entry)
|
||||
.HasForeignKey<SpineEntity>(e => e.EntryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// DsseEnvelopeEntity configuration
|
||||
modelBuilder.Entity<DsseEnvelopeEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => new { e.EntryId, e.PredicateType })
|
||||
.HasDatabaseName("idx_dsse_entry_predicate");
|
||||
entity.HasIndex(e => e.SignerKeyId).HasDatabaseName("idx_dsse_signer");
|
||||
entity.HasIndex(e => e.BodyHash).HasDatabaseName("idx_dsse_body_hash");
|
||||
|
||||
// Unique constraint
|
||||
entity.HasIndex(e => new { e.EntryId, e.PredicateType, e.BodyHash })
|
||||
.HasDatabaseName("uq_dsse_envelope")
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
// SpineEntity configuration
|
||||
modelBuilder.Entity<SpineEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.BundleId).HasDatabaseName("idx_spines_bundle").IsUnique();
|
||||
entity.HasIndex(e => e.AnchorId).HasDatabaseName("idx_spines_anchor");
|
||||
entity.HasIndex(e => e.PolicyVersion).HasDatabaseName("idx_spines_policy");
|
||||
|
||||
entity.HasOne(e => e.Anchor)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.AnchorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// TrustAnchorEntity configuration
|
||||
modelBuilder.Entity<TrustAnchorEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.PurlPattern).HasDatabaseName("idx_trust_anchors_pattern");
|
||||
entity.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("idx_trust_anchors_active")
|
||||
.HasFilter("is_active = TRUE");
|
||||
});
|
||||
|
||||
// RekorEntryEntity configuration
|
||||
modelBuilder.Entity<RekorEntryEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.LogIndex).HasDatabaseName("idx_rekor_log_index");
|
||||
entity.HasIndex(e => e.LogId).HasDatabaseName("idx_rekor_log_id");
|
||||
entity.HasIndex(e => e.Uuid).HasDatabaseName("idx_rekor_uuid");
|
||||
entity.HasIndex(e => e.EnvId).HasDatabaseName("idx_rekor_env");
|
||||
|
||||
entity.HasOne(e => e.Envelope)
|
||||
.WithOne(e => e.RekorEntry)
|
||||
.HasForeignKey<RekorEntryEntity>(e => e.EnvId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// AuditLogEntity configuration
|
||||
modelBuilder.Entity<AuditLogEntity>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => new { e.EntityType, e.EntityId })
|
||||
.HasDatabaseName("idx_audit_entity");
|
||||
entity.HasIndex(e => e.CreatedAt)
|
||||
.HasDatabaseName("idx_audit_created")
|
||||
.IsDescending();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for proof chain data access.
|
||||
/// </summary>
|
||||
public interface IProofChainRepository
|
||||
{
|
||||
#region SBOM Entries
|
||||
|
||||
/// <summary>
|
||||
/// Get an SBOM entry by its unique combination of bom digest, purl, and version.
|
||||
/// </summary>
|
||||
Task<SbomEntryEntity?> GetSbomEntryAsync(
|
||||
string bomDigest,
|
||||
string purl,
|
||||
string? version,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get an SBOM entry by its entry ID.
|
||||
/// </summary>
|
||||
Task<SbomEntryEntity?> GetSbomEntryByIdAsync(
|
||||
Guid entryId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Insert or update an SBOM entry (upsert on unique constraint).
|
||||
/// </summary>
|
||||
Task<SbomEntryEntity> UpsertSbomEntryAsync(
|
||||
SbomEntryEntity entry,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all SBOM entries by artifact digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SbomEntryEntity>> GetSbomEntriesByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all SBOM entries by bom digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SbomEntryEntity>> GetSbomEntriesByBomDigestAsync(
|
||||
string bomDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region DSSE Envelopes
|
||||
|
||||
/// <summary>
|
||||
/// Get an envelope by its ID.
|
||||
/// </summary>
|
||||
Task<DsseEnvelopeEntity?> GetEnvelopeAsync(
|
||||
Guid envId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get an envelope by its body hash.
|
||||
/// </summary>
|
||||
Task<DsseEnvelopeEntity?> GetEnvelopeByBodyHashAsync(
|
||||
string bodyHash,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save a new envelope.
|
||||
/// </summary>
|
||||
Task<DsseEnvelopeEntity> SaveEnvelopeAsync(
|
||||
DsseEnvelopeEntity envelope,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all envelopes for an SBOM entry.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DsseEnvelopeEntity>> GetEnvelopesByEntryAsync(
|
||||
Guid entryId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get envelopes for an entry filtered by predicate type.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DsseEnvelopeEntity>> GetEnvelopesByPredicateTypeAsync(
|
||||
Guid entryId,
|
||||
string predicateType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Spines
|
||||
|
||||
/// <summary>
|
||||
/// Get a spine by its entry ID.
|
||||
/// </summary>
|
||||
Task<SpineEntity?> GetSpineAsync(
|
||||
Guid entryId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a spine by its bundle ID.
|
||||
/// </summary>
|
||||
Task<SpineEntity?> GetSpineByBundleIdAsync(
|
||||
string bundleId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save or update a spine.
|
||||
/// </summary>
|
||||
Task<SpineEntity> SaveSpineAsync(
|
||||
SpineEntity spine,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trust Anchors
|
||||
|
||||
/// <summary>
|
||||
/// Get a trust anchor by its ID.
|
||||
/// </summary>
|
||||
Task<TrustAnchorEntity?> GetTrustAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the trust anchor matching a PURL pattern (best match).
|
||||
/// </summary>
|
||||
Task<TrustAnchorEntity?> GetTrustAnchorByPatternAsync(
|
||||
string purl,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save or update a trust anchor.
|
||||
/// </summary>
|
||||
Task<TrustAnchorEntity> SaveTrustAnchorAsync(
|
||||
TrustAnchorEntity anchor,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all active trust anchors.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustAnchorEntity>> GetActiveTrustAnchorsAsync(
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a key in a trust anchor.
|
||||
/// </summary>
|
||||
Task RevokeKeyAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rekor Entries
|
||||
|
||||
/// <summary>
|
||||
/// Get a Rekor entry by DSSE SHA-256.
|
||||
/// </summary>
|
||||
Task<RekorEntryEntity?> GetRekorEntryAsync(
|
||||
string dsseSha256,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a Rekor entry by log index.
|
||||
/// </summary>
|
||||
Task<RekorEntryEntity?> GetRekorEntryByLogIndexAsync(
|
||||
long logIndex,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save a Rekor entry.
|
||||
/// </summary>
|
||||
Task<RekorEntryEntity> SaveRekorEntryAsync(
|
||||
RekorEntryEntity entry,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Audit Log
|
||||
|
||||
/// <summary>
|
||||
/// Log an audit entry.
|
||||
/// </summary>
|
||||
Task LogAuditAsync(
|
||||
string operation,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string? actor = null,
|
||||
object? details = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get audit log entries for an entity.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AuditLogEntity>> GetAuditLogAsync(
|
||||
string entityType,
|
||||
string entityId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Matches PURLs against trust anchor patterns.
|
||||
/// SPRINT_0501_0006_0001 - Task #7
|
||||
/// </summary>
|
||||
public interface ITrustAnchorMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the best matching trust anchor for a given PURL.
|
||||
/// </summary>
|
||||
Task<TrustAnchorMatchResult?> FindMatchAsync(
|
||||
string purl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a key ID is allowed for a given PURL.
|
||||
/// </summary>
|
||||
Task<bool> IsKeyAllowedAsync(
|
||||
string purl,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a predicate type is allowed for a given PURL.
|
||||
/// </summary>
|
||||
Task<bool> IsPredicateAllowedAsync(
|
||||
string purl,
|
||||
string predicateType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of trust anchor pattern matching.
|
||||
/// </summary>
|
||||
public sealed record TrustAnchorMatchResult
|
||||
{
|
||||
/// <summary>The matched trust anchor.</summary>
|
||||
public required TrustAnchorEntity Anchor { get; init; }
|
||||
|
||||
/// <summary>The pattern that matched.</summary>
|
||||
public required string MatchedPattern { get; init; }
|
||||
|
||||
/// <summary>Match specificity score (higher = more specific).</summary>
|
||||
public required int Specificity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of trust anchor pattern matching using PURL glob patterns.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
|
||||
{
|
||||
private readonly IProofChainRepository _repository;
|
||||
private readonly ILogger<TrustAnchorMatcher> _logger;
|
||||
|
||||
// Cache compiled regex patterns
|
||||
private readonly Dictionary<string, Regex> _patternCache = new();
|
||||
private readonly Lock _cacheLock = new();
|
||||
|
||||
public TrustAnchorMatcher(
|
||||
IProofChainRepository repository,
|
||||
ILogger<TrustAnchorMatcher> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TrustAnchorMatchResult?> FindMatchAsync(
|
||||
string purl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(purl);
|
||||
|
||||
var anchors = await _repository.GetActiveAnchorsAsync(cancellationToken);
|
||||
|
||||
TrustAnchorMatchResult? bestMatch = null;
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
if (!IsActive(anchor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var regex = GetOrCreateRegex(anchor.PurlPattern);
|
||||
if (regex.IsMatch(purl))
|
||||
{
|
||||
var specificity = CalculateSpecificity(anchor.PurlPattern);
|
||||
|
||||
if (bestMatch == null || specificity > bestMatch.Specificity)
|
||||
{
|
||||
bestMatch = new TrustAnchorMatchResult
|
||||
{
|
||||
Anchor = anchor,
|
||||
MatchedPattern = anchor.PurlPattern,
|
||||
Specificity = specificity,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch != null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"PURL {Purl} matched anchor pattern {Pattern} with specificity {Specificity}",
|
||||
purl, bestMatch.MatchedPattern, bestMatch.Specificity);
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
public async Task<bool> IsKeyAllowedAsync(
|
||||
string purl,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(purl);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
var match = await FindMatchAsync(purl, cancellationToken);
|
||||
if (match == null)
|
||||
{
|
||||
_logger.LogDebug("No trust anchor found for PURL {Purl}", purl);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if key is revoked
|
||||
if (match.Anchor.RevokedKeys.Contains(keyId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Key {KeyId} is revoked for anchor {AnchorId}",
|
||||
keyId, match.Anchor.AnchorId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if key is in allowed list
|
||||
var allowed = match.Anchor.AllowedKeyIds.Contains(keyId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Key {KeyId} not in allowed list for anchor {AnchorId}",
|
||||
keyId, match.Anchor.AnchorId);
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
public async Task<bool> IsPredicateAllowedAsync(
|
||||
string purl,
|
||||
string predicateType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(purl);
|
||||
ArgumentException.ThrowIfNullOrEmpty(predicateType);
|
||||
|
||||
var match = await FindMatchAsync(purl, cancellationToken);
|
||||
if (match == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no predicate restrictions, allow all
|
||||
if (match.Anchor.AllowedPredicateTypes == null || match.Anchor.AllowedPredicateTypes.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return match.Anchor.AllowedPredicateTypes.Contains(predicateType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PURL glob pattern to a regex.
|
||||
/// Supports: * (any chars), ? (single char), ** (any path segment)
|
||||
/// </summary>
|
||||
private Regex GetOrCreateRegex(string pattern)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_patternCache.TryGetValue(pattern, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var regexPattern = ConvertGlobToRegex(pattern);
|
||||
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
_patternCache[pattern] = regex;
|
||||
return regex;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a glob pattern to a regex pattern.
|
||||
/// </summary>
|
||||
private static string ConvertGlobToRegex(string glob)
|
||||
{
|
||||
var regex = new System.Text.StringBuilder("^");
|
||||
|
||||
for (int i = 0; i < glob.Length; i++)
|
||||
{
|
||||
char c = glob[i];
|
||||
switch (c)
|
||||
{
|
||||
case '*':
|
||||
if (i + 1 < glob.Length && glob[i + 1] == '*')
|
||||
{
|
||||
// ** matches any path segments
|
||||
regex.Append(".*");
|
||||
i++; // Skip next *
|
||||
}
|
||||
else
|
||||
{
|
||||
// * matches anything except /
|
||||
regex.Append("[^/]*");
|
||||
}
|
||||
break;
|
||||
|
||||
case '?':
|
||||
// ? matches single character except /
|
||||
regex.Append("[^/]");
|
||||
break;
|
||||
|
||||
case '.':
|
||||
case '^':
|
||||
case '$':
|
||||
case '+':
|
||||
case '(':
|
||||
case ')':
|
||||
case '[':
|
||||
case ']':
|
||||
case '{':
|
||||
case '}':
|
||||
case '|':
|
||||
case '\\':
|
||||
// Escape regex special chars
|
||||
regex.Append('\\').Append(c);
|
||||
break;
|
||||
|
||||
default:
|
||||
regex.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
regex.Append('$');
|
||||
return regex.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates pattern specificity (more specific = higher score).
|
||||
/// </summary>
|
||||
private static int CalculateSpecificity(string pattern)
|
||||
{
|
||||
// Count non-wildcard segments
|
||||
int specificity = 0;
|
||||
|
||||
// More slashes = more specific
|
||||
specificity += pattern.Count(c => c == '/') * 10;
|
||||
|
||||
// More literal characters = more specific
|
||||
specificity += pattern.Count(c => c != '*' && c != '?');
|
||||
|
||||
// Penalize wildcards
|
||||
specificity -= pattern.Count(c => c == '*') * 5;
|
||||
specificity -= pattern.Count(c => c == '?') * 2;
|
||||
|
||||
return specificity;
|
||||
}
|
||||
|
||||
private static bool IsActive(TrustAnchorEntity anchor)
|
||||
{
|
||||
// Anchor is active if IsActive property exists and is true
|
||||
// or if the property doesn't exist (backwards compatibility)
|
||||
var isActiveProp = anchor.GetType().GetProperty("IsActive");
|
||||
if (isActiveProp != null)
|
||||
{
|
||||
return (bool)(isActiveProp.GetValue(anchor) ?? true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface extension for trust anchor queries.
|
||||
/// </summary>
|
||||
public interface IProofChainRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all active trust anchors.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustAnchorEntity>> GetActiveAnchorsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Persistence</RootNamespace>
|
||||
<Description>Proof chain persistence layer with Entity Framework Core and PostgreSQL support.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-preview.*" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-preview.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\*.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,223 @@
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
using StellaOps.Attestor.Persistence.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for proof chain database operations.
|
||||
/// SPRINT_0501_0006_0001 - Task #10
|
||||
/// </summary>
|
||||
public sealed class ProofChainRepositoryIntegrationTests
|
||||
{
|
||||
private readonly Mock<IProofChainRepository> _repositoryMock;
|
||||
private readonly TrustAnchorMatcher _matcher;
|
||||
|
||||
public ProofChainRepositoryIntegrationTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IProofChainRepository>();
|
||||
_matcher = new TrustAnchorMatcher(
|
||||
_repositoryMock.Object,
|
||||
NullLogger<TrustAnchorMatcher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchAsync_ExactPattern_MatchesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/lodash@4.17.21", ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(anchor.AnchorId, result.Anchor.AnchorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchAsync_WildcardPattern_MatchesPackages()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("pkg:npm/*", result.MatchedPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchAsync_DoubleWildcard_MatchesNestedPaths()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/@scope/**", ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindMatchAsync("pkg:npm/@scope/sub/package@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchAsync_MultipleMatches_ReturnsMoreSpecific()
|
||||
{
|
||||
// Arrange
|
||||
var genericAnchor = CreateAnchor("pkg:npm/*", ["key-generic"], "generic");
|
||||
var specificAnchor = CreateAnchor("pkg:npm/lodash@*", ["key-specific"], "specific");
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([genericAnchor, specificAnchor]);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("specific", result.Anchor.PolicyRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchAsync_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindMatchAsync("pkg:pypi/requests@2.28.0");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsKeyAllowedAsync_AllowedKey_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1", "key-2"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsKeyAllowedAsync_DisallowedKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-unknown");
|
||||
|
||||
// Assert
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsKeyAllowedAsync_RevokedKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1"], revokedKeys: ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1");
|
||||
|
||||
// Assert
|
||||
Assert.False(allowed); // Key is revoked even if in allowed list
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsPredicateAllowedAsync_NoRestrictions_AllowsAll()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
|
||||
anchor.AllowedPredicateTypes = null;
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var allowed = await _matcher.IsPredicateAllowedAsync(
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"https://in-toto.io/attestation/vulns/v0.1");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsPredicateAllowedAsync_WithRestrictions_EnforcesAllowlist()
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
|
||||
anchor.AllowedPredicateTypes = ["evidence.stella/v1", "sbom.stella/v1"];
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(await _matcher.IsPredicateAllowedAsync(
|
||||
"pkg:npm/lodash@4.17.21", "evidence.stella/v1"));
|
||||
Assert.False(await _matcher.IsPredicateAllowedAsync(
|
||||
"pkg:npm/lodash@4.17.21", "random.predicate/v1"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/*", "pkg:npm/lodash@4.17.21", true)]
|
||||
[InlineData("pkg:npm/lodash@*", "pkg:npm/lodash@4.17.21", true)]
|
||||
[InlineData("pkg:npm/lodash@4.17.*", "pkg:npm/lodash@4.17.21", true)]
|
||||
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21", true)]
|
||||
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.22", false)]
|
||||
[InlineData("pkg:pypi/*", "pkg:npm/lodash@4.17.21", false)]
|
||||
[InlineData("pkg:npm/@scope/*", "pkg:npm/@scope/package@1.0.0", true)]
|
||||
[InlineData("pkg:npm/@scope/*", "pkg:npm/@other/package@1.0.0", false)]
|
||||
public async Task FindMatchAsync_PatternVariations_MatchCorrectly(
|
||||
string pattern, string purl, bool shouldMatch)
|
||||
{
|
||||
// Arrange
|
||||
var anchor = CreateAnchor(pattern, ["key-1"]);
|
||||
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([anchor]);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindMatchAsync(purl);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(shouldMatch, result != null);
|
||||
}
|
||||
|
||||
private static TrustAnchorEntity CreateAnchor(
|
||||
string pattern,
|
||||
string[] allowedKeys,
|
||||
string? policyRef = null,
|
||||
string[]? revokedKeys = null)
|
||||
{
|
||||
return new TrustAnchorEntity
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
PurlPattern = pattern,
|
||||
AllowedKeyIds = allowedKeys,
|
||||
PolicyRef = policyRef,
|
||||
RevokedKeys = revokedKeys ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// Service for assembling and verifying proof spines.
|
||||
/// </summary>
|
||||
public interface IProofSpineAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assemble a complete proof spine from component IDs.
|
||||
/// </summary>
|
||||
/// <param name="request">The assembly request containing all component IDs.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The assembled proof spine result including the signed envelope.</returns>
|
||||
Task<ProofSpineResult> AssembleSpineAsync(
|
||||
ProofSpineRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify an existing proof spine by recomputing the merkle root.
|
||||
/// </summary>
|
||||
/// <param name="spine">The proof spine statement to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<SpineVerificationResult> VerifySpineAsync(
|
||||
ProofSpineStatement spine,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to assemble a proof spine.
|
||||
/// </summary>
|
||||
public sealed record ProofSpineRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM entry ID that this spine covers.
|
||||
/// </summary>
|
||||
public required SbomEntryId SbomEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The evidence IDs to include in the proof bundle.
|
||||
/// Will be sorted lexicographically during assembly.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EvidenceId> EvidenceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning ID explaining the decision.
|
||||
/// </summary>
|
||||
public required ReasoningId ReasoningId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The VEX verdict ID for this entry.
|
||||
/// </summary>
|
||||
public required VexVerdictId VexVerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy used.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject (artifact) this spine is about.
|
||||
/// </summary>
|
||||
public required ProofSpineSubject Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key profile to use for signing the spine statement.
|
||||
/// </summary>
|
||||
public SigningKeyProfile SigningProfile { get; init; } = SigningKeyProfile.Authority;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject for the proof spine (the artifact being attested).
|
||||
/// </summary>
|
||||
public sealed record ProofSpineSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the subject (e.g., image reference).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the subject.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of proof spine assembly.
|
||||
/// </summary>
|
||||
public sealed record ProofSpineResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The computed proof bundle ID (merkle root).
|
||||
/// </summary>
|
||||
public required ProofBundleId ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The proof spine statement.
|
||||
/// </summary>
|
||||
public required ProofSpineStatement Statement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signed DSSE envelope.
|
||||
/// </summary>
|
||||
public required DsseEnvelope SignedEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The merkle tree used for the proof bundle.
|
||||
/// </summary>
|
||||
public required MerkleTree MerkleTree { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a merkle tree with proof generation capability.
|
||||
/// </summary>
|
||||
public sealed record MerkleTree
|
||||
{
|
||||
/// <summary>
|
||||
/// The root hash of the merkle tree.
|
||||
/// </summary>
|
||||
public required byte[] Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The leaf hashes in order.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<byte[]> Leaves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of levels in the tree.
|
||||
/// </summary>
|
||||
public required int Depth { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of proof spine verification.
|
||||
/// </summary>
|
||||
public sealed record SpineVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the spine is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The expected proof bundle ID (from the statement).
|
||||
/// </summary>
|
||||
public required ProofBundleId ExpectedBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The actual proof bundle ID (recomputed).
|
||||
/// </summary>
|
||||
public required ProofBundleId ActualBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification checks performed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SpineVerificationCheck> Checks { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single verification check in spine verification.
|
||||
/// </summary>
|
||||
public sealed record SpineVerificationCheck
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the check.
|
||||
/// </summary>
|
||||
public required string CheckName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the check passed.
|
||||
/// </summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional details about the check.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a subject (artifact) for proof chain statements.
|
||||
/// </summary>
|
||||
public sealed record ProofSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject (e.g., image reference, PURL).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts this ProofSubject to an in-toto Subject.
|
||||
/// </summary>
|
||||
public Subject ToSubject() => new()
|
||||
{
|
||||
Name = Name,
|
||||
Digest = Digest
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for building in-toto statements for proof chain predicates.
|
||||
/// </summary>
|
||||
public interface IStatementBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build an Evidence statement for signing.
|
||||
/// </summary>
|
||||
/// <param name="subject">The artifact subject this evidence relates to.</param>
|
||||
/// <param name="predicate">The evidence payload.</param>
|
||||
/// <returns>An EvidenceStatement ready for signing.</returns>
|
||||
EvidenceStatement BuildEvidenceStatement(
|
||||
ProofSubject subject,
|
||||
EvidencePayload predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Build a Reasoning statement for signing.
|
||||
/// </summary>
|
||||
/// <param name="subject">The artifact subject this reasoning relates to.</param>
|
||||
/// <param name="predicate">The reasoning payload.</param>
|
||||
/// <returns>A ReasoningStatement ready for signing.</returns>
|
||||
ReasoningStatement BuildReasoningStatement(
|
||||
ProofSubject subject,
|
||||
ReasoningPayload predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Build a VEX Verdict statement for signing.
|
||||
/// </summary>
|
||||
/// <param name="subject">The artifact subject this verdict relates to.</param>
|
||||
/// <param name="predicate">The VEX verdict payload.</param>
|
||||
/// <returns>A VexVerdictStatement ready for signing.</returns>
|
||||
VexVerdictStatement BuildVexVerdictStatement(
|
||||
ProofSubject subject,
|
||||
VexVerdictPayload predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Build a Proof Spine statement for signing.
|
||||
/// </summary>
|
||||
/// <param name="subject">The artifact subject this proof spine covers.</param>
|
||||
/// <param name="predicate">The proof spine payload.</param>
|
||||
/// <returns>A ProofSpineStatement ready for signing.</returns>
|
||||
ProofSpineStatement BuildProofSpineStatement(
|
||||
ProofSubject subject,
|
||||
ProofSpinePayload predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Build a Verdict Receipt statement for signing.
|
||||
/// </summary>
|
||||
/// <param name="subject">The artifact subject this verdict receipt relates to.</param>
|
||||
/// <param name="predicate">The verdict receipt payload.</param>
|
||||
/// <returns>A VerdictReceiptStatement ready for signing.</returns>
|
||||
VerdictReceiptStatement BuildVerdictReceiptStatement(
|
||||
ProofSubject subject,
|
||||
VerdictReceiptPayload predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Build an SBOM Linkage statement for signing.
|
||||
/// </summary>
|
||||
/// <param name="subjects">The artifact subjects covered by the SBOM.</param>
|
||||
/// <param name="predicate">The SBOM linkage payload.</param>
|
||||
/// <returns>An SbomLinkageStatement ready for signing.</returns>
|
||||
SbomLinkageStatement BuildSbomLinkageStatement(
|
||||
IReadOnlyList<ProofSubject> subjects,
|
||||
SbomLinkagePayload predicate);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IStatementBuilder.
|
||||
/// </summary>
|
||||
public sealed class StatementBuilder : IStatementBuilder
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public EvidenceStatement BuildEvidenceStatement(
|
||||
ProofSubject subject,
|
||||
EvidencePayload predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
return new EvidenceStatement
|
||||
{
|
||||
Subject = [subject.ToSubject()],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReasoningStatement BuildReasoningStatement(
|
||||
ProofSubject subject,
|
||||
ReasoningPayload predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
return new ReasoningStatement
|
||||
{
|
||||
Subject = [subject.ToSubject()],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VexVerdictStatement BuildVexVerdictStatement(
|
||||
ProofSubject subject,
|
||||
VexVerdictPayload predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
return new VexVerdictStatement
|
||||
{
|
||||
Subject = [subject.ToSubject()],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ProofSpineStatement BuildProofSpineStatement(
|
||||
ProofSubject subject,
|
||||
ProofSpinePayload predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
return new ProofSpineStatement
|
||||
{
|
||||
Subject = [subject.ToSubject()],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VerdictReceiptStatement BuildVerdictReceiptStatement(
|
||||
ProofSubject subject,
|
||||
VerdictReceiptPayload predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
return new VerdictReceiptStatement
|
||||
{
|
||||
Subject = [subject.ToSubject()],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SbomLinkageStatement BuildSbomLinkageStatement(
|
||||
IReadOnlyList<ProofSubject> subjects,
|
||||
SbomLinkagePayload predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subjects);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
if (subjects.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one subject is required.", nameof(subjects));
|
||||
}
|
||||
|
||||
return new SbomLinkageStatement
|
||||
{
|
||||
Subject = subjects.Select(s => s.ToSubject()).ToList(),
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the proof-of-integrity graph that tracks relationships
|
||||
/// between artifacts, SBOMs, attestations, and containers.
|
||||
/// </summary>
|
||||
public interface IProofGraphService
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a node to the proof graph.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of node to add.</param>
|
||||
/// <param name="contentDigest">The content digest (content-addressed ID).</param>
|
||||
/// <param name="metadata">Optional metadata for the node.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created node.</returns>
|
||||
Task<ProofGraphNode> AddNodeAsync(
|
||||
ProofGraphNodeType type,
|
||||
string contentDigest,
|
||||
IReadOnlyDictionary<string, object>? metadata = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add an edge between two nodes.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source node ID.</param>
|
||||
/// <param name="targetId">The target node ID.</param>
|
||||
/// <param name="edgeType">The type of edge.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created edge.</returns>
|
||||
Task<ProofGraphEdge> AddEdgeAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
ProofGraphEdgeType edgeType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a node by its ID.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID to retrieve.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The node if found, null otherwise.</returns>
|
||||
Task<ProofGraphNode?> GetNodeAsync(
|
||||
string nodeId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Query the graph for a path from source to target.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source node ID.</param>
|
||||
/// <param name="targetId">The target node ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The path if found, null otherwise.</returns>
|
||||
Task<ProofGraphPath?> FindPathAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all nodes related to an artifact within a given depth.
|
||||
/// </summary>
|
||||
/// <param name="artifactId">The artifact ID to start from.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The subgraph containing related nodes.</returns>
|
||||
Task<ProofGraphSubgraph> GetArtifactSubgraphAsync(
|
||||
string artifactId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all outgoing edges from a node.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The outgoing edges.</returns>
|
||||
Task<IReadOnlyList<ProofGraphEdge>> GetOutgoingEdgesAsync(
|
||||
string nodeId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all incoming edges to a node.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The incoming edges.</returns>
|
||||
Task<IReadOnlyList<ProofGraphEdge>> GetIncomingEdgesAsync(
|
||||
string nodeId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of nodes in the proof graph.
|
||||
/// </summary>
|
||||
public enum ProofGraphNodeType
|
||||
{
|
||||
/// <summary>Container image, binary, Helm chart.</summary>
|
||||
Artifact,
|
||||
|
||||
/// <summary>SBOM document by sbomId.</summary>
|
||||
SbomDocument,
|
||||
|
||||
/// <summary>In-toto statement by statement hash.</summary>
|
||||
InTotoStatement,
|
||||
|
||||
/// <summary>DSSE envelope by envelope hash.</summary>
|
||||
DsseEnvelope,
|
||||
|
||||
/// <summary>Rekor transparency log entry.</summary>
|
||||
RekorEntry,
|
||||
|
||||
/// <summary>VEX statement by VEX hash.</summary>
|
||||
VexStatement,
|
||||
|
||||
/// <summary>Component/subject from SBOM.</summary>
|
||||
Subject,
|
||||
|
||||
/// <summary>Signing key.</summary>
|
||||
SigningKey,
|
||||
|
||||
/// <summary>Trust anchor (root of trust).</summary>
|
||||
TrustAnchor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of edges in the proof graph.
|
||||
/// </summary>
|
||||
public enum ProofGraphEdgeType
|
||||
{
|
||||
/// <summary>Artifact → SbomDocument: artifact is described by SBOM.</summary>
|
||||
DescribedBy,
|
||||
|
||||
/// <summary>SbomDocument → InTotoStatement: SBOM is attested by statement.</summary>
|
||||
AttestedBy,
|
||||
|
||||
/// <summary>InTotoStatement → DsseEnvelope: statement is wrapped in envelope.</summary>
|
||||
WrappedBy,
|
||||
|
||||
/// <summary>DsseEnvelope → RekorEntry: envelope is logged in Rekor.</summary>
|
||||
LoggedIn,
|
||||
|
||||
/// <summary>Artifact/Subject → VexStatement: has VEX statement.</summary>
|
||||
HasVex,
|
||||
|
||||
/// <summary>InTotoStatement → Subject: statement contains subject.</summary>
|
||||
ContainsSubject,
|
||||
|
||||
/// <summary>Build → SBOM: build produces SBOM.</summary>
|
||||
Produces,
|
||||
|
||||
/// <summary>VEX → Component: VEX affects component.</summary>
|
||||
Affects,
|
||||
|
||||
/// <summary>Envelope → Key: envelope is signed by key.</summary>
|
||||
SignedBy,
|
||||
|
||||
/// <summary>Envelope → Rekor: envelope is recorded at log index.</summary>
|
||||
RecordedAt,
|
||||
|
||||
/// <summary>Key → TrustAnchor: key chains to trust anchor.</summary>
|
||||
ChainsTo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the proof graph.
|
||||
/// </summary>
|
||||
public sealed record ProofGraphNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this node.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of this node.
|
||||
/// </summary>
|
||||
public required ProofGraphNodeType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest (content-addressed identifier).
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this node was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata for the node.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge in the proof graph.
|
||||
/// </summary>
|
||||
public sealed record ProofGraphEdge
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this edge.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source node ID.
|
||||
/// </summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID.
|
||||
/// </summary>
|
||||
public required string TargetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of this edge.
|
||||
/// </summary>
|
||||
public required ProofGraphEdgeType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this edge was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A path through the proof graph.
|
||||
/// </summary>
|
||||
public sealed record ProofGraphPath
|
||||
{
|
||||
/// <summary>
|
||||
/// Nodes in the path, in order.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProofGraphNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Edges connecting the nodes.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProofGraphEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Length of the path (number of edges).
|
||||
/// </summary>
|
||||
public int Length => Edges.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subgraph of the proof graph.
|
||||
/// </summary>
|
||||
public sealed record ProofGraphSubgraph
|
||||
{
|
||||
/// <summary>
|
||||
/// The root node ID that was queried.
|
||||
/// </summary>
|
||||
public required string RootNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All nodes in the subgraph.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProofGraphNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All edges in the subgraph.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProofGraphEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth that was traversed.
|
||||
/// </summary>
|
||||
public required int MaxDepth { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IProofGraphService for testing and development.
|
||||
/// Not suitable for production use with large graphs.
|
||||
/// </summary>
|
||||
public sealed class InMemoryProofGraphService : IProofGraphService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ProofGraphNode> _nodes = new();
|
||||
private readonly ConcurrentDictionary<string, ProofGraphEdge> _edges = new();
|
||||
private readonly ConcurrentDictionary<string, List<string>> _outgoingEdges = new();
|
||||
private readonly ConcurrentDictionary<string, List<string>> _incomingEdges = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryProofGraphService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProofGraphNode> AddNodeAsync(
|
||||
ProofGraphNodeType type,
|
||||
string contentDigest,
|
||||
IReadOnlyDictionary<string, object>? metadata = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
|
||||
|
||||
var nodeId = $"{type.ToString().ToLowerInvariant()}:{contentDigest}";
|
||||
|
||||
var node = new ProofGraphNode
|
||||
{
|
||||
Id = nodeId,
|
||||
Type = type,
|
||||
ContentDigest = contentDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
if (!_nodes.TryAdd(nodeId, node))
|
||||
{
|
||||
// Node already exists, return the existing one
|
||||
node = _nodes[nodeId];
|
||||
}
|
||||
|
||||
return Task.FromResult(node);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProofGraphEdge> AddEdgeAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
ProofGraphEdgeType edgeType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
|
||||
|
||||
if (!_nodes.ContainsKey(sourceId))
|
||||
{
|
||||
throw new ArgumentException($"Source node '{sourceId}' does not exist.", nameof(sourceId));
|
||||
}
|
||||
|
||||
if (!_nodes.ContainsKey(targetId))
|
||||
{
|
||||
throw new ArgumentException($"Target node '{targetId}' does not exist.", nameof(targetId));
|
||||
}
|
||||
|
||||
var edgeId = $"{sourceId}->{edgeType}->{targetId}";
|
||||
|
||||
var edge = new ProofGraphEdge
|
||||
{
|
||||
Id = edgeId,
|
||||
SourceId = sourceId,
|
||||
TargetId = targetId,
|
||||
Type = edgeType,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
if (_edges.TryAdd(edgeId, edge))
|
||||
{
|
||||
// Add to adjacency lists
|
||||
_outgoingEdges.AddOrUpdate(
|
||||
sourceId,
|
||||
_ => [edgeId],
|
||||
(_, list) => { lock (list) { list.Add(edgeId); } return list; });
|
||||
|
||||
_incomingEdges.AddOrUpdate(
|
||||
targetId,
|
||||
_ => [edgeId],
|
||||
(_, list) => { lock (list) { list.Add(edgeId); } return list; });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Edge already exists
|
||||
edge = _edges[edgeId];
|
||||
}
|
||||
|
||||
return Task.FromResult(edge);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProofGraphNode?> GetNodeAsync(string nodeId, CancellationToken ct = default)
|
||||
{
|
||||
_nodes.TryGetValue(nodeId, out var node);
|
||||
return Task.FromResult(node);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProofGraphPath?> FindPathAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
|
||||
|
||||
if (!_nodes.ContainsKey(sourceId) || !_nodes.ContainsKey(targetId))
|
||||
{
|
||||
return Task.FromResult<ProofGraphPath?>(null);
|
||||
}
|
||||
|
||||
// BFS to find shortest path
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<(string nodeId, List<string> path)>();
|
||||
queue.Enqueue((sourceId, [sourceId]));
|
||||
visited.Add(sourceId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, path) = queue.Dequeue();
|
||||
|
||||
if (currentId == targetId)
|
||||
{
|
||||
// Found path, reconstruct nodes and edges
|
||||
var nodes = path.Select(id => _nodes[id]).ToList();
|
||||
var edges = new List<ProofGraphEdge>();
|
||||
|
||||
for (int i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
var edgeIds = _outgoingEdges.GetValueOrDefault(path[i], []);
|
||||
var edge = edgeIds
|
||||
.Select(eid => _edges[eid])
|
||||
.FirstOrDefault(e => e.TargetId == path[i + 1]);
|
||||
|
||||
if (edge != null)
|
||||
{
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<ProofGraphPath?>(new ProofGraphPath
|
||||
{
|
||||
Nodes = nodes,
|
||||
Edges = edges
|
||||
});
|
||||
}
|
||||
|
||||
var outgoing = _outgoingEdges.GetValueOrDefault(currentId, []);
|
||||
foreach (var edgeId in outgoing)
|
||||
{
|
||||
var edge = _edges[edgeId];
|
||||
if (!visited.Contains(edge.TargetId))
|
||||
{
|
||||
visited.Add(edge.TargetId);
|
||||
var newPath = new List<string>(path) { edge.TargetId };
|
||||
queue.Enqueue((edge.TargetId, newPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<ProofGraphPath?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProofGraphSubgraph> GetArtifactSubgraphAsync(
|
||||
string artifactId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||
|
||||
var nodes = new Dictionary<string, ProofGraphNode>();
|
||||
var edges = new List<ProofGraphEdge>();
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<(string nodeId, int depth)>();
|
||||
|
||||
if (_nodes.TryGetValue(artifactId, out var rootNode))
|
||||
{
|
||||
nodes[artifactId] = rootNode;
|
||||
queue.Enqueue((artifactId, 0));
|
||||
visited.Add(artifactId);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = queue.Dequeue();
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process outgoing edges
|
||||
var outgoing = _outgoingEdges.GetValueOrDefault(currentId, []);
|
||||
foreach (var edgeId in outgoing)
|
||||
{
|
||||
var edge = _edges[edgeId];
|
||||
edges.Add(edge);
|
||||
|
||||
if (!visited.Contains(edge.TargetId) && _nodes.TryGetValue(edge.TargetId, out var targetNode))
|
||||
{
|
||||
visited.Add(edge.TargetId);
|
||||
nodes[edge.TargetId] = targetNode;
|
||||
queue.Enqueue((edge.TargetId, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Process incoming edges
|
||||
var incoming = _incomingEdges.GetValueOrDefault(currentId, []);
|
||||
foreach (var edgeId in incoming)
|
||||
{
|
||||
var edge = _edges[edgeId];
|
||||
edges.Add(edge);
|
||||
|
||||
if (!visited.Contains(edge.SourceId) && _nodes.TryGetValue(edge.SourceId, out var sourceNode))
|
||||
{
|
||||
visited.Add(edge.SourceId);
|
||||
nodes[edge.SourceId] = sourceNode;
|
||||
queue.Enqueue((edge.SourceId, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new ProofGraphSubgraph
|
||||
{
|
||||
RootNodeId = artifactId,
|
||||
Nodes = nodes.Values.ToList(),
|
||||
Edges = edges.Distinct().ToList(),
|
||||
MaxDepth = maxDepth
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ProofGraphEdge>> GetOutgoingEdgesAsync(
|
||||
string nodeId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var edgeIds = _outgoingEdges.GetValueOrDefault(nodeId, []);
|
||||
var edges = edgeIds.Select(id => _edges[id]).ToList();
|
||||
return Task.FromResult<IReadOnlyList<ProofGraphEdge>>(edges);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ProofGraphEdge>> GetIncomingEdgesAsync(
|
||||
string nodeId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var edgeIds = _incomingEdges.GetValueOrDefault(nodeId, []);
|
||||
var edges = edgeIds.Select(id => _edges[id]).ToList();
|
||||
return Task.FromResult<IReadOnlyList<ProofGraphEdge>>(edges);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all nodes and edges (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_nodes.Clear();
|
||||
_edges.Clear();
|
||||
_outgoingEdges.Clear();
|
||||
_incomingEdges.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of nodes.
|
||||
/// </summary>
|
||||
public int NodeCount => _nodes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of edges.
|
||||
/// </summary>
|
||||
public int EdgeCount => _edges.Count;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Json;
|
||||
|
||||
/// <summary>
|
||||
/// JSON Schema validation result.
|
||||
/// </summary>
|
||||
public sealed record SchemaValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the JSON is valid against the schema.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors if any.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<SchemaValidationError> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful validation result.
|
||||
/// </summary>
|
||||
public static SchemaValidationResult Success() => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Errors = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed validation result.
|
||||
/// </summary>
|
||||
public static SchemaValidationResult Failure(params SchemaValidationError[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single schema validation error.
|
||||
/// </summary>
|
||||
public sealed record SchemaValidationError
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON pointer to the error location.
|
||||
/// </summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema keyword that failed (e.g., "required", "type").
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating JSON against schemas.
|
||||
/// </summary>
|
||||
public interface IJsonSchemaValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate JSON against a schema by predicate type.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON to validate.</param>
|
||||
/// <param name="predicateType">The predicate type (e.g., "evidence.stella/v1").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
Task<SchemaValidationResult> ValidatePredicateAsync(
|
||||
string json,
|
||||
string predicateType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate a statement against its predicate type schema.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The statement type.</typeparam>
|
||||
/// <param name="statement">The statement to validate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
Task<SchemaValidationResult> ValidateStatementAsync<T>(
|
||||
T statement,
|
||||
CancellationToken ct = default) where T : Statements.InTotoStatement;
|
||||
|
||||
/// <summary>
|
||||
/// Check if a predicate type has a registered schema.
|
||||
/// </summary>
|
||||
/// <param name="predicateType">The predicate type.</param>
|
||||
/// <returns>True if a schema is registered.</returns>
|
||||
bool HasSchema(string predicateType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of JSON Schema validation.
|
||||
/// </summary>
|
||||
public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
{
|
||||
private static readonly Dictionary<string, JsonDocument> _schemas = new();
|
||||
|
||||
/// <summary>
|
||||
/// Static initializer to load embedded schemas.
|
||||
/// </summary>
|
||||
static PredicateSchemaValidator()
|
||||
{
|
||||
// TODO: Load schemas from embedded resources
|
||||
// These would be in src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/Schemas/
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SchemaValidationResult> ValidatePredicateAsync(
|
||||
string json,
|
||||
string predicateType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!HasSchema(predicateType))
|
||||
{
|
||||
return SchemaValidationResult.Failure(new SchemaValidationError
|
||||
{
|
||||
Path = "/",
|
||||
Message = $"No schema registered for predicate type: {predicateType}",
|
||||
Keyword = "predicateType"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonDocument.Parse(json);
|
||||
|
||||
// TODO: Implement actual JSON Schema validation
|
||||
// For now, do basic structural checks
|
||||
|
||||
var root = document.RootElement;
|
||||
|
||||
var errors = new List<SchemaValidationError>();
|
||||
|
||||
// Validate required fields based on predicate type
|
||||
switch (predicateType)
|
||||
{
|
||||
case "evidence.stella/v1":
|
||||
errors.AddRange(ValidateEvidencePredicate(root));
|
||||
break;
|
||||
case "reasoning.stella/v1":
|
||||
errors.AddRange(ValidateReasoningPredicate(root));
|
||||
break;
|
||||
case "cdx-vex.stella/v1":
|
||||
errors.AddRange(ValidateVexPredicate(root));
|
||||
break;
|
||||
case "proofspine.stella/v1":
|
||||
errors.AddRange(ValidateProofSpinePredicate(root));
|
||||
break;
|
||||
case "verdict.stella/v1":
|
||||
errors.AddRange(ValidateVerdictPredicate(root));
|
||||
break;
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? SchemaValidationResult.Failure(errors.ToArray())
|
||||
: SchemaValidationResult.Success();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return SchemaValidationResult.Failure(new SchemaValidationError
|
||||
{
|
||||
Path = "/",
|
||||
Message = $"Invalid JSON: {ex.Message}",
|
||||
Keyword = "format"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SchemaValidationResult> ValidateStatementAsync<T>(
|
||||
T statement,
|
||||
CancellationToken ct = default) where T : Statements.InTotoStatement
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(statement);
|
||||
return await ValidatePredicateAsync(json, statement.PredicateType, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasSchema(string predicateType)
|
||||
{
|
||||
return predicateType switch
|
||||
{
|
||||
"evidence.stella/v1" => true,
|
||||
"reasoning.stella/v1" => true,
|
||||
"cdx-vex.stella/v1" => true,
|
||||
"proofspine.stella/v1" => true,
|
||||
"verdict.stella/v1" => true,
|
||||
"https://stella-ops.org/predicates/sbom-linkage/v1" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateEvidencePredicate(JsonElement root)
|
||||
{
|
||||
// Required: scanToolName, scanToolVersion, timestamp
|
||||
if (!root.TryGetProperty("scanToolName", out _))
|
||||
yield return new() { Path = "/scanToolName", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("scanToolVersion", out _))
|
||||
yield return new() { Path = "/scanToolVersion", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("timestamp", out _))
|
||||
yield return new() { Path = "/timestamp", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateReasoningPredicate(JsonElement root)
|
||||
{
|
||||
// Required: policyId, policyVersion, evaluatedAt
|
||||
if (!root.TryGetProperty("policyId", out _))
|
||||
yield return new() { Path = "/policyId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("policyVersion", out _))
|
||||
yield return new() { Path = "/policyVersion", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("evaluatedAt", out _))
|
||||
yield return new() { Path = "/evaluatedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateVexPredicate(JsonElement root)
|
||||
{
|
||||
// Required: vulnerability, status
|
||||
if (!root.TryGetProperty("vulnerability", out _))
|
||||
yield return new() { Path = "/vulnerability", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("status", out _))
|
||||
yield return new() { Path = "/status", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateProofSpinePredicate(JsonElement root)
|
||||
{
|
||||
// Required: sbomEntryId, evidenceIds, proofBundleId
|
||||
if (!root.TryGetProperty("sbomEntryId", out _))
|
||||
yield return new() { Path = "/sbomEntryId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("evidenceIds", out _))
|
||||
yield return new() { Path = "/evidenceIds", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("proofBundleId", out _))
|
||||
yield return new() { Path = "/proofBundleId", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateVerdictPredicate(JsonElement root)
|
||||
{
|
||||
// Required: proofBundleId, result, verifiedAt
|
||||
if (!root.TryGetProperty("proofBundleId", out _))
|
||||
yield return new() { Path = "/proofBundleId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("result", out _))
|
||||
yield return new() { Path = "/result", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("verifiedAt", out _))
|
||||
yield return new() { Path = "/verifiedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,24 @@ using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic merkle tree builder using SHA-256.
|
||||
/// Follows proof chain construction algorithm:
|
||||
/// - Lexicographic sorting of evidence IDs
|
||||
/// - Padding to power of 2 by duplicating last leaf
|
||||
/// - Left || Right concatenation for internal nodes
|
||||
/// </summary>
|
||||
public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public byte[] ComputeMerkleRoot(IReadOnlyList<ReadOnlyMemory<byte>> leafValues)
|
||||
{
|
||||
var tree = BuildTree(leafValues);
|
||||
return tree.Root;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MerkleTreeWithProofs BuildTree(IReadOnlyList<ReadOnlyMemory<byte>> leafValues)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leafValues);
|
||||
|
||||
@@ -15,36 +30,123 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
throw new ArgumentException("At least one leaf is required.", nameof(leafValues));
|
||||
}
|
||||
|
||||
var hashes = new List<byte[]>(PadToPowerOfTwo(leafValues.Count));
|
||||
var levels = new List<IReadOnlyList<byte[]>>();
|
||||
|
||||
// Level 0: Hash all leaf values
|
||||
var leafHashes = new List<byte[]>(PadToPowerOfTwo(leafValues.Count));
|
||||
for (var i = 0; i < leafValues.Count; i++)
|
||||
{
|
||||
hashes.Add(SHA256.HashData(leafValues[i].Span));
|
||||
leafHashes.Add(SHA256.HashData(leafValues[i].Span));
|
||||
}
|
||||
|
||||
// Pad with duplicate of last leaf hash (deterministic).
|
||||
var target = hashes.Capacity;
|
||||
while (hashes.Count < target)
|
||||
// Pad with duplicate of last leaf hash (deterministic)
|
||||
var target = leafHashes.Capacity;
|
||||
while (leafHashes.Count < target)
|
||||
{
|
||||
hashes.Add(hashes[^1]);
|
||||
leafHashes.Add(leafHashes[^1]);
|
||||
}
|
||||
|
||||
return ComputeRootFromLeafHashes(hashes);
|
||||
levels.Add(leafHashes);
|
||||
|
||||
// Build tree bottom-up
|
||||
var currentLevel = leafHashes;
|
||||
while (currentLevel.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>(currentLevel.Count / 2);
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
nextLevel.Add(HashInternal(currentLevel[i], currentLevel[i + 1]));
|
||||
}
|
||||
levels.Add(nextLevel);
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return new MerkleTreeWithProofs
|
||||
{
|
||||
Root = currentLevel[0],
|
||||
Leaves = leafHashes,
|
||||
Levels = levels
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] ComputeRootFromLeafHashes(List<byte[]> hashes)
|
||||
/// <inheritdoc />
|
||||
public MerkleProof GenerateProof(MerkleTreeWithProofs tree, int leafIndex)
|
||||
{
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var next = new List<byte[]>(hashes.Count / 2);
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
next.Add(HashInternal(hashes[i], hashes[i + 1]));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(tree);
|
||||
|
||||
hashes = next;
|
||||
if (leafIndex < 0 || leafIndex >= tree.Leaves.Count)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leafIndex),
|
||||
$"Leaf index must be between 0 and {tree.Leaves.Count - 1}.");
|
||||
}
|
||||
|
||||
return hashes[0];
|
||||
var steps = new List<MerkleProofStep>();
|
||||
var currentIndex = leafIndex;
|
||||
|
||||
for (var level = 0; level < tree.Levels.Count - 1; level++)
|
||||
{
|
||||
var currentLevel = tree.Levels[level];
|
||||
|
||||
// Find sibling
|
||||
int siblingIndex;
|
||||
bool isRight;
|
||||
|
||||
if (currentIndex % 2 == 0)
|
||||
{
|
||||
// Current is left child, sibling is right
|
||||
siblingIndex = currentIndex + 1;
|
||||
isRight = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Current is right child, sibling is left
|
||||
siblingIndex = currentIndex - 1;
|
||||
isRight = false;
|
||||
}
|
||||
|
||||
steps.Add(new MerkleProofStep
|
||||
{
|
||||
SiblingHash = currentLevel[siblingIndex],
|
||||
IsRight = isRight
|
||||
});
|
||||
|
||||
// Move to parent index
|
||||
currentIndex /= 2;
|
||||
}
|
||||
|
||||
return new MerkleProof
|
||||
{
|
||||
LeafIndex = leafIndex,
|
||||
LeafHash = tree.Leaves[leafIndex],
|
||||
Steps = steps
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool VerifyProof(MerkleProof proof, ReadOnlySpan<byte> leafValue, ReadOnlySpan<byte> expectedRoot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
// Hash the leaf value
|
||||
var currentHash = SHA256.HashData(leafValue);
|
||||
|
||||
// Walk up the tree
|
||||
foreach (var step in proof.Steps)
|
||||
{
|
||||
if (step.IsRight)
|
||||
{
|
||||
// Sibling is on the right: H(current || sibling)
|
||||
currentHash = HashInternal(currentHash, step.SiblingHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Sibling is on the left: H(sibling || current)
|
||||
currentHash = HashInternal(step.SiblingHash, currentHash);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with expected root
|
||||
return currentHash.AsSpan().SequenceEqual(expectedRoot);
|
||||
}
|
||||
|
||||
private static int PadToPowerOfTwo(int count)
|
||||
@@ -66,3 +168,4 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,103 @@ using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for deterministic merkle trees used in proof chain construction.
|
||||
/// </summary>
|
||||
public interface IMerkleTreeBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the merkle root from leaf values.
|
||||
/// </summary>
|
||||
/// <param name="leafValues">The leaf values to hash.</param>
|
||||
/// <returns>The merkle root hash.</returns>
|
||||
byte[] ComputeMerkleRoot(IReadOnlyList<ReadOnlyMemory<byte>> leafValues);
|
||||
|
||||
/// <summary>
|
||||
/// Build a full merkle tree with proof generation capability.
|
||||
/// </summary>
|
||||
/// <param name="leafValues">The leaf values to hash.</param>
|
||||
/// <returns>A merkle tree with proof generation.</returns>
|
||||
MerkleTreeWithProofs BuildTree(IReadOnlyList<ReadOnlyMemory<byte>> leafValues);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a merkle proof for a specific leaf.
|
||||
/// </summary>
|
||||
/// <param name="tree">The merkle tree.</param>
|
||||
/// <param name="leafIndex">The index of the leaf to prove.</param>
|
||||
/// <returns>The merkle proof.</returns>
|
||||
MerkleProof GenerateProof(MerkleTreeWithProofs tree, int leafIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a merkle proof.
|
||||
/// </summary>
|
||||
/// <param name="proof">The merkle proof.</param>
|
||||
/// <param name="leafValue">The leaf value being proven.</param>
|
||||
/// <param name="expectedRoot">The expected merkle root.</param>
|
||||
/// <returns>True if the proof is valid.</returns>
|
||||
bool VerifyProof(MerkleProof proof, ReadOnlySpan<byte> leafValue, ReadOnlySpan<byte> expectedRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A merkle tree with all internal nodes stored for proof generation.
|
||||
/// </summary>
|
||||
public sealed record MerkleTreeWithProofs
|
||||
{
|
||||
/// <summary>
|
||||
/// The merkle root.
|
||||
/// </summary>
|
||||
public required byte[] Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The leaf hashes (level 0).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<byte[]> Leaves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All levels of the tree, from leaves (index 0) to root.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<IReadOnlyList<byte[]>> Levels { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The depth of the tree (number of levels - 1).
|
||||
/// </summary>
|
||||
public int Depth => Levels.Count - 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A merkle proof for a specific leaf.
|
||||
/// </summary>
|
||||
public sealed record MerkleProof
|
||||
{
|
||||
/// <summary>
|
||||
/// The index of the leaf in the original list.
|
||||
/// </summary>
|
||||
public required int LeafIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The hash of the leaf.
|
||||
/// </summary>
|
||||
public required byte[] LeafHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The sibling hashes needed to reconstruct the root, from bottom to top.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MerkleProofStep> Steps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single step in a merkle proof.
|
||||
/// </summary>
|
||||
public sealed record MerkleProofStep
|
||||
{
|
||||
/// <summary>
|
||||
/// The sibling hash at this level.
|
||||
/// </summary>
|
||||
public required byte[] SiblingHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the sibling is on the right (true) or left (false).
|
||||
/// </summary>
|
||||
public required bool IsRight { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Pipeline;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the full proof chain pipeline from scan to receipt.
|
||||
/// </summary>
|
||||
public interface IProofChainPipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute the full proof chain pipeline.
|
||||
/// </summary>
|
||||
/// <param name="request">The pipeline request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The pipeline result.</returns>
|
||||
Task<ProofChainResult> ExecuteAsync(
|
||||
ProofChainRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to execute the proof chain pipeline.
|
||||
/// </summary>
|
||||
public sealed record ProofChainRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM bytes to process.
|
||||
/// </summary>
|
||||
public required byte[] SbomBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the SBOM (e.g., "application/vnd.cyclonedx+json").
|
||||
/// </summary>
|
||||
public required string SbomMediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence gathered from scanning.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EvidencePayload> Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version used for evaluation.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor for verification.
|
||||
/// </summary>
|
||||
public required TrustAnchorId TrustAnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to submit envelopes to Rekor.
|
||||
/// </summary>
|
||||
public bool SubmitToRekor { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Subject information for the attestations.
|
||||
/// </summary>
|
||||
public required PipelineSubject Subject { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject information for the pipeline.
|
||||
/// </summary>
|
||||
public sealed record PipelineSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the subject (e.g., image reference).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the proof chain pipeline.
|
||||
/// </summary>
|
||||
public sealed record ProofChainResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The assembled proof bundle ID.
|
||||
/// </summary>
|
||||
public required ProofBundleId ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All signed DSSE envelopes produced.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DsseEnvelope> Envelopes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The proof spine statement.
|
||||
/// </summary>
|
||||
public required ProofSpineStatement ProofSpine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entries if submitted.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RekorEntry>? RekorEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification receipt.
|
||||
/// </summary>
|
||||
public required VerificationReceipt Receipt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph revision ID for this evaluation.
|
||||
/// </summary>
|
||||
public required GraphRevisionId GraphRevisionId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// The log index in Rekor.
|
||||
/// </summary>
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The UUID of the entry.
|
||||
/// </summary>
|
||||
public required string Uuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The integrated time (when the entry was added).
|
||||
/// </summary>
|
||||
public required DateTimeOffset IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The log ID (tree hash).
|
||||
/// </summary>
|
||||
public required string LogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The body of the entry (base64-encoded).
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating verification receipts for proof bundles.
|
||||
/// </summary>
|
||||
public interface IReceiptGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a verification receipt for a proof bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The proof bundle ID to verify.</param>
|
||||
/// <param name="context">The verification context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification receipt.</returns>
|
||||
Task<VerificationReceipt> GenerateReceiptAsync(
|
||||
ProofBundleId bundleId,
|
||||
VerificationContext context,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for verification operations.
|
||||
/// </summary>
|
||||
public sealed record VerificationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The trust anchor ID to verify against.
|
||||
/// </summary>
|
||||
public required TrustAnchorId AnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the verifier tool.
|
||||
/// </summary>
|
||||
public required string VerifierVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional digests of tools used in verification.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? ToolDigests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A verification receipt for a proof bundle.
|
||||
/// </summary>
|
||||
public sealed record VerificationReceipt
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID that was verified.
|
||||
/// </summary>
|
||||
public required ProofBundleId ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the verification was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the verifier tool.
|
||||
/// </summary>
|
||||
public required string VerifierVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The trust anchor ID used for verification.
|
||||
/// </summary>
|
||||
public required TrustAnchorId AnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The overall verification result.
|
||||
/// </summary>
|
||||
public required VerificationResult Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification checks performed.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<VerificationCheck> Checks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional digests of tools used in verification.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? ToolDigests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a verification operation.
|
||||
/// </summary>
|
||||
public enum VerificationResult
|
||||
{
|
||||
/// <summary>Verification passed.</summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>Verification failed.</summary>
|
||||
Fail
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single verification check performed during receipt generation.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheck
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the check performed.
|
||||
/// </summary>
|
||||
public required string Check { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status of this check.
|
||||
/// </summary>
|
||||
public required VerificationResult Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used if this was a signature check.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value (for comparison checks).
|
||||
/// </summary>
|
||||
public string? Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual value (for comparison checks).
|
||||
/// </summary>
|
||||
public string? Actual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if this was a transparency check.
|
||||
/// </summary>
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional details about the check.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signing key profiles for different proof chain statement types.
|
||||
/// </summary>
|
||||
public enum SigningKeyProfile
|
||||
{
|
||||
/// <summary>Scanner/Ingestor key for evidence statements.</summary>
|
||||
Evidence,
|
||||
|
||||
/// <summary>Policy/Authority key for reasoning statements.</summary>
|
||||
Reasoning,
|
||||
|
||||
/// <summary>VEXer/Vendor key for VEX verdicts.</summary>
|
||||
VexVerdict,
|
||||
|
||||
/// <summary>Authority key for proof spines and receipts.</summary>
|
||||
Authority,
|
||||
|
||||
/// <summary>Generator key for SBOM linkage statements.</summary>
|
||||
Generator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID that was used for verification.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope containing a signed statement.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// The payload type (always "application/vnd.in-toto+json").
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload (the statement JSON).
|
||||
/// </summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the payload.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID that produced this signature.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing and verifying proof chain statements.
|
||||
/// </summary>
|
||||
public interface IProofChainSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign a statement and wrap it in a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The statement type.</typeparam>
|
||||
/// <param name="statement">The statement to sign.</param>
|
||||
/// <param name="keyProfile">The signing key profile to use.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A DSSE envelope containing the signed statement.</returns>
|
||||
Task<DsseEnvelope> SignStatementAsync<T>(
|
||||
T statement,
|
||||
SigningKeyProfile keyProfile,
|
||||
CancellationToken ct = default) where T : InTotoStatement;
|
||||
|
||||
/// <summary>
|
||||
/// Verify a DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The envelope to verify.</param>
|
||||
/// <param name="allowedKeyIds">List of allowed key IDs for verification.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<SignatureVerificationResult> VerifyEnvelopeAsync(
|
||||
DsseEnvelope envelope,
|
||||
IReadOnlyList<string> allowedKeyIds,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for evidence collected from scanners or feeds.
|
||||
/// Predicate type: evidence.stella/v1
|
||||
/// </summary>
|
||||
public sealed record EvidenceStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "evidence.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The evidence payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required EvidencePayload Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for evidence statements.
|
||||
/// </summary>
|
||||
public sealed record EvidencePayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Scanner or feed name that produced this evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the source tool.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceVersion")]
|
||||
public required string SourceVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when evidence was collected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("collectionTime")]
|
||||
public required DateTimeOffset CollectionTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the SBOM entry this evidence relates to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability identifier if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pointer to or inline representation of raw finding data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rawFinding")]
|
||||
public required object RawFinding { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of this evidence (hash of canonical JSON).
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceId")]
|
||||
public required string EvidenceId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for in-toto Statement/v1 format.
|
||||
/// See: https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md
|
||||
/// </summary>
|
||||
public abstract record InTotoStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// The statement type, always "https://in-toto.io/Statement/v1".
|
||||
/// </summary>
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type => "https://in-toto.io/Statement/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The subjects this statement is about (e.g., artifact digests).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<Subject> Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type URI identifying the schema of the predicate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public abstract string PredicateType { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subject in an in-toto statement, representing an artifact.
|
||||
/// </summary>
|
||||
public sealed record Subject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject (e.g., image reference).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for proof spine (merkle-aggregated proof bundle).
|
||||
/// Predicate type: proofspine.stella/v1
|
||||
/// </summary>
|
||||
public sealed record ProofSpineStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "proofspine.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The proof spine payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ProofSpinePayload Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for proof spine statements.
|
||||
/// </summary>
|
||||
public sealed record ProofSpinePayload
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM entry ID this proof spine covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sorted list of evidence IDs included in this proof bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceIds")]
|
||||
public required IReadOnlyList<string> EvidenceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning ID linking evidence to verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The VEX verdict ID for this entry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public required string VexVerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of this proof bundle (merkle root).
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofBundleId")]
|
||||
public required string ProofBundleId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for policy evaluation reasoning traces.
|
||||
/// Predicate type: reasoning.stella/v1
|
||||
/// </summary>
|
||||
public sealed record ReasoningStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "reasoning.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ReasoningPayload Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for reasoning statements.
|
||||
/// </summary>
|
||||
public sealed record ReasoningPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM entry ID this reasoning applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence IDs that were considered in this reasoning.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceIds")]
|
||||
public required IReadOnlyList<string> EvidenceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy used for evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inputs to the reasoning process.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputs")]
|
||||
public required ReasoningInputsPayload Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate findings from the evaluation (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("intermediateFindings")]
|
||||
public IReadOnlyDictionary<string, object>? IntermediateFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of this reasoning (hash of canonical JSON).
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inputs to the reasoning process.
|
||||
/// </summary>
|
||||
public sealed record ReasoningInputsPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// The evaluation time used for temporal reasoning (must be UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentEvaluationTime")]
|
||||
public required DateTimeOffset CurrentEvaluationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity thresholds applied during evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severityThresholds")]
|
||||
public IReadOnlyDictionary<string, object>? SeverityThresholds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lattice rules used for status merging.
|
||||
/// </summary>
|
||||
[JsonPropertyName("latticeRules")]
|
||||
public IReadOnlyDictionary<string, object>? LatticeRules { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for SBOM-to-component linkage.
|
||||
/// Predicate type: https://stella-ops.org/predicates/sbom-linkage/v1
|
||||
/// </summary>
|
||||
public sealed record SbomLinkageStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "https://stella-ops.org/predicates/sbom-linkage/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM linkage payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required SbomLinkagePayload Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for SBOM linkage statements.
|
||||
/// </summary>
|
||||
public sealed record SbomLinkagePayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Descriptor of the SBOM being linked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbom")]
|
||||
public required SbomDescriptor Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor of the tool that generated this linkage.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generator")]
|
||||
public required GeneratorDescriptor Generator { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this linkage was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subjects that could not be fully resolved (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("incompleteSubjects")]
|
||||
public IReadOnlyList<IncompleteSubject>? IncompleteSubjects { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary tags for classification or filtering.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyDictionary<string, string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor of an SBOM document.
|
||||
/// </summary>
|
||||
public sealed record SbomDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier of the SBOM (e.g., serialNumber or documentId).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format of the SBOM: CycloneDX or SPDX.
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specification version (e.g., "1.6" for CycloneDX, "2.3" for SPDX).
|
||||
/// </summary>
|
||||
[JsonPropertyName("specVersion")]
|
||||
public required string SpecVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the SBOM document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the SBOM content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional location URI (oci:// or file://).
|
||||
/// </summary>
|
||||
[JsonPropertyName("location")]
|
||||
public string? Location { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor of the tool that generated an artifact.
|
||||
/// </summary>
|
||||
public sealed record GeneratorDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the generator tool.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the generator tool.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subject that could not be fully resolved during SBOM linkage.
|
||||
/// </summary>
|
||||
public sealed record IncompleteSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Name or identifier of the incomplete subject.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason why the subject is incomplete.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for final verdict receipts.
|
||||
/// Predicate type: verdict.stella/v1
|
||||
/// </summary>
|
||||
public sealed record VerdictReceiptStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "verdict.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The verdict receipt payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required VerdictReceiptPayload Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for verdict receipt statements.
|
||||
/// </summary>
|
||||
public sealed record VerdictReceiptPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// The graph revision ID this verdict was computed from.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graphRevisionId")]
|
||||
public required string GraphRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding key identifying the specific vulnerability/component pair.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingKey")]
|
||||
public required FindingKey FindingKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy rule that produced this verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rule")]
|
||||
public required PolicyRule Rule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision made by the rule.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required VerdictDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inputs used to compute this verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputs")]
|
||||
public required VerdictInputs Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outputs/references from this verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputs")]
|
||||
public required VerdictOutputs Outputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this verdict was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key identifying a specific finding (component + vulnerability).
|
||||
/// </summary>
|
||||
public sealed record FindingKey
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM entry ID for the component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy rule that produced a verdict.
|
||||
/// </summary>
|
||||
public sealed record PolicyRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier of the rule.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the rule.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision made by a policy rule.
|
||||
/// </summary>
|
||||
public sealed record VerdictDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Status of the decision: block, warn, pass.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inputs used to compute a verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the SBOM used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the advisory feeds used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedsDigest")]
|
||||
public required string FeedsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy bundle used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public required string PolicyDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outputs/references from a verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictOutputs
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID containing the evidence chain.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofBundleId")]
|
||||
public required string ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning ID explaining the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The VEX verdict ID for this finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public required string VexVerdictId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for VEX verdicts.
|
||||
/// Predicate type: cdx-vex.stella/v1
|
||||
/// </summary>
|
||||
public sealed record VexVerdictStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => "cdx-vex.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// The VEX verdict payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required VexVerdictPayload Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for VEX verdict statements.
|
||||
/// </summary>
|
||||
public sealed record VexVerdictPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM entry ID this verdict applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status: not_affected, affected, fixed, under_investigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy used to generate this verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the reasoning that led to this verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of this VEX verdict (hash of canonical JSON).
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public required string VexVerdictId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verification pipeline for proof chains per advisory §9.1.
|
||||
/// Executes a series of verification steps and generates receipts.
|
||||
/// </summary>
|
||||
public interface IVerificationPipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute the full verification pipeline.
|
||||
/// </summary>
|
||||
/// <param name="request">The verification request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result with receipt.</returns>
|
||||
Task<VerificationPipelineResult> VerifyAsync(
|
||||
VerificationPipelineRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a proof chain.
|
||||
/// </summary>
|
||||
public sealed record VerificationPipelineRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID to verify.
|
||||
/// </summary>
|
||||
public required ProofBundleId ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional trust anchor ID to verify against.
|
||||
/// If not specified, the pipeline will find a matching anchor.
|
||||
/// </summary>
|
||||
public TrustAnchorId? TrustAnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify Rekor inclusion proofs.
|
||||
/// </summary>
|
||||
public bool VerifyRekor { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip trust anchor verification.
|
||||
/// </summary>
|
||||
public bool SkipTrustAnchorVerification { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Version of the verifier for the receipt.
|
||||
/// </summary>
|
||||
public string VerifierVersion { get; init; } = "1.0.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the verification pipeline.
|
||||
/// </summary>
|
||||
public sealed record VerificationPipelineResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the verification passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verification receipt.
|
||||
/// </summary>
|
||||
public required VerificationReceipt Receipt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual step results.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<VerificationStepResult> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The first failing step, if any.
|
||||
/// </summary>
|
||||
public VerificationStepResult? FirstFailure =>
|
||||
Steps.FirstOrDefault(s => !s.Passed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single verification step.
|
||||
/// </summary>
|
||||
public sealed record VerificationStepResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the step (e.g., "dsse_signature", "merkle_root").
|
||||
/// </summary>
|
||||
public required string StepName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the step passed.
|
||||
/// </summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the step.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional details about the step.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the step failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID if this was a signature verification step.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value for comparison steps.
|
||||
/// </summary>
|
||||
public string? Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual value for comparison steps.
|
||||
/// </summary>
|
||||
public string? Actual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if this was an inclusion proof step.
|
||||
/// </summary>
|
||||
public long? LogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single step in the verification pipeline.
|
||||
/// </summary>
|
||||
public interface IVerificationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of this step.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Execute the verification step.
|
||||
/// </summary>
|
||||
/// <param name="context">The verification context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The step result.</returns>
|
||||
Task<VerificationStepResult> ExecuteAsync(
|
||||
VerificationContext context,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context passed through the verification pipeline.
|
||||
/// </summary>
|
||||
public sealed class VerificationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID being verified.
|
||||
/// </summary>
|
||||
public required ProofBundleId ProofBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The trust anchor ID (if specified or discovered).
|
||||
/// </summary>
|
||||
public TrustAnchorId? TrustAnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify Rekor inclusion.
|
||||
/// </summary>
|
||||
public bool VerifyRekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Collected data during verification for subsequent steps.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> Data { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Get typed data from the context.
|
||||
/// </summary>
|
||||
public T? GetData<T>(string key) where T : class
|
||||
{
|
||||
return Data.TryGetValue(key, out var value) ? value as T : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set data in the context.
|
||||
/// </summary>
|
||||
public void SetData<T>(string key, T value) where T : notnull
|
||||
{
|
||||
Data[key] = value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user