up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic test data constants for reproducible test execution.
|
||||
/// All values are fixed and should not change between test runs.
|
||||
/// </summary>
|
||||
public static class DeterministicTestData
|
||||
{
|
||||
// Trusted scanner digests
|
||||
public const string TrustedScannerDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
public const string UntrustedScannerDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
// Default subject data
|
||||
public const string DefaultSubjectName = "ghcr.io/stellaops/scanner:v2.5.0";
|
||||
public const string DefaultSubjectDigest = "abc123def456789012345678901234567890abcdef1234567890abcdef123456";
|
||||
|
||||
// Additional subject data for multi-subject tests
|
||||
public const string SecondSubjectName = "ghcr.io/stellaops/sbomer:v1.8.0";
|
||||
public const string SecondSubjectDigest = "def456789012345678901234567890abcdef1234567890abcdef123456abc123";
|
||||
|
||||
public const string ThirdSubjectName = "ghcr.io/stellaops/policy-engine:v2.1.0";
|
||||
public const string ThirdSubjectDigest = "789012345678901234567890abcdef1234567890abcdef123456abc123def456";
|
||||
|
||||
// Proof of entitlement tokens
|
||||
public const string ValidPoeToken = "valid-poe-token-12345";
|
||||
public const string ExpiredPoeToken = "expired-poe-token-99999";
|
||||
public const string InvalidPoeToken = "invalid-poe-token-00000";
|
||||
|
||||
// Tenant identifiers
|
||||
public const string DefaultTenant = "stellaops-default";
|
||||
public const string TestTenant = "test-tenant-12345";
|
||||
public const string EnterpriseCustomerTenant = "enterprise-customer-67890";
|
||||
|
||||
// Key identifiers
|
||||
public const string KeylessKeyId = "keyless-ephemeral-20250115";
|
||||
public const string KmsKeyId = "alias/stellaops-signing-key";
|
||||
public const string TestKmsKeyId = "test-kms-key-12345";
|
||||
|
||||
// Issuer/subject for signing identity
|
||||
public const string DefaultIssuer = "https://signer.stellaops.io";
|
||||
public const string FulcioIssuer = "https://fulcio.sigstore.dev";
|
||||
public const string TestIssuer = "https://test.signer.local";
|
||||
|
||||
// Fixed timestamps for deterministic testing
|
||||
public static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
public static readonly DateTimeOffset ExpiryTimestamp = new(2025, 1, 15, 11, 30, 0, TimeSpan.Zero);
|
||||
public static readonly DateTimeOffset FarFutureExpiry = new(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
// License/entitlement data
|
||||
public const string TestLicenseId = "LIC-TEST-12345";
|
||||
public const string TestCustomerId = "CUST-TEST-67890";
|
||||
public const string ProPlan = "pro";
|
||||
public const string EnterprisePlan = "enterprise";
|
||||
public const int DefaultMaxArtifactBytes = 128 * 1024;
|
||||
public const int DefaultQpsLimit = 10;
|
||||
public const int DefaultQpsRemaining = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default caller context for testing.
|
||||
/// Includes required scope "signer.sign" and audience "signer" for pipeline authorization.
|
||||
/// </summary>
|
||||
public static CallerContext CreateDefaultCallerContext()
|
||||
{
|
||||
return new CallerContext(
|
||||
Subject: "test-service@stellaops.io",
|
||||
Tenant: DefaultTenant,
|
||||
Scopes: new[] { "signer.sign", "signer.verify" },
|
||||
Audiences: new[] { "signer", "https://signer.stellaops.io" },
|
||||
SenderBinding: "dpop-proof-12345",
|
||||
ClientCertificateThumbprint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a caller context for a specific tenant.
|
||||
/// Includes required scope "signer.sign" and audience "signer" for pipeline authorization.
|
||||
/// </summary>
|
||||
public static CallerContext CreateCallerContextForTenant(string tenant)
|
||||
{
|
||||
return new CallerContext(
|
||||
Subject: $"service@{tenant}.stellaops.io",
|
||||
Tenant: tenant,
|
||||
Scopes: new[] { "signer.sign", "signer.verify" },
|
||||
Audiences: new[] { "signer", "https://signer.stellaops.io" },
|
||||
SenderBinding: "dpop-proof-12345",
|
||||
ClientCertificateThumbprint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default proof of entitlement result.
|
||||
/// </summary>
|
||||
public static ProofOfEntitlementResult CreateDefaultEntitlement()
|
||||
{
|
||||
return new ProofOfEntitlementResult(
|
||||
LicenseId: TestLicenseId,
|
||||
CustomerId: TestCustomerId,
|
||||
Plan: ProPlan,
|
||||
MaxArtifactBytes: DefaultMaxArtifactBytes,
|
||||
QpsLimit: DefaultQpsLimit,
|
||||
QpsRemaining: DefaultQpsRemaining,
|
||||
ExpiresAtUtc: FarFutureExpiry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an entitlement result for a specific plan.
|
||||
/// </summary>
|
||||
public static ProofOfEntitlementResult CreateEntitlementForPlan(string plan, int maxArtifactBytes = DefaultMaxArtifactBytes)
|
||||
{
|
||||
return new ProofOfEntitlementResult(
|
||||
LicenseId: $"LIC-{plan.ToUpperInvariant()}",
|
||||
CustomerId: TestCustomerId,
|
||||
Plan: plan,
|
||||
MaxArtifactBytes: maxArtifactBytes,
|
||||
QpsLimit: DefaultQpsLimit,
|
||||
QpsRemaining: DefaultQpsRemaining,
|
||||
ExpiresAtUtc: FarFutureExpiry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a list of default signing subjects.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SigningSubject> CreateDefaultSubjects()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = DefaultSubjectDigest
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple signing subjects for multi-subject tests.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SigningSubject> CreateMultipleSubjects()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = DefaultSubjectDigest
|
||||
}),
|
||||
new SigningSubject(SecondSubjectName, new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = SecondSubjectDigest
|
||||
}),
|
||||
new SigningSubject(ThirdSubjectName, new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ThirdSubjectDigest
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signing subject with multiple digest algorithms.
|
||||
/// </summary>
|
||||
public static SigningSubject CreateSubjectWithMultipleDigests()
|
||||
{
|
||||
return new SigningSubject(DefaultSubjectName, new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = DefaultSubjectDigest,
|
||||
["sha512"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
["sha384"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic test fixtures for predicate types used in signing tests.
|
||||
/// All fixtures use static, reproducible data for deterministic test execution.
|
||||
/// </summary>
|
||||
public static class PredicateFixtures
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic timestamp for test reproducibility.
|
||||
/// </summary>
|
||||
public const string FixedTimestamp = "2025-01-15T10:30:00Z";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps promotion predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreatePromotionPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(PromotionPredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps SBOM predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateSbomPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(SbomPredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps replay predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateReplayPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(ReplayPredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SLSA provenance v0.2 predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateSlsaProvenanceV02Predicate()
|
||||
{
|
||||
return JsonDocument.Parse(SlsaProvenanceV02PredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SLSA provenance v1 predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateSlsaProvenanceV1Predicate()
|
||||
{
|
||||
return JsonDocument.Parse(SlsaProvenanceV1PredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps VEX predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateVexPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(VexPredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps policy predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreatePolicyPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(PolicyPredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps evidence predicate fixture.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateEvidencePredicate()
|
||||
{
|
||||
return JsonDocument.Parse(EvidencePredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps VEX Decision predicate fixture (OpenVEX format).
|
||||
/// </summary>
|
||||
public static JsonDocument CreateVexDecisionPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(VexDecisionPredicateJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StellaOps Graph predicate fixture for reachability call-graphs.
|
||||
/// </summary>
|
||||
public static JsonDocument CreateGraphPredicate()
|
||||
{
|
||||
return JsonDocument.Parse(GraphPredicateJson);
|
||||
}
|
||||
|
||||
public const string PromotionPredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"promotionId": "promo-20250115-103000-abc123",
|
||||
"sourceEnvironment": {
|
||||
"name": "staging",
|
||||
"clusterId": "staging-us-west-2",
|
||||
"namespace": "stellaops-app"
|
||||
},
|
||||
"targetEnvironment": {
|
||||
"name": "production",
|
||||
"clusterId": "prod-us-west-2",
|
||||
"namespace": "stellaops-app"
|
||||
},
|
||||
"artifact": {
|
||||
"repository": "ghcr.io/stellaops/scanner",
|
||||
"tag": "v2.5.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"approval": {
|
||||
"approvedBy": "security-team@stellaops.io",
|
||||
"approvedAt": "2025-01-15T10:30:00Z",
|
||||
"policy": "require-two-approvals",
|
||||
"policyVersion": "v1.2.0"
|
||||
},
|
||||
"evidence": {
|
||||
"scanCompleted": true,
|
||||
"vulnerabilitiesFound": 0,
|
||||
"signatureVerified": true,
|
||||
"policyPassed": true
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
public const string SbomPredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"sbomId": "sbom-20250115-103000-xyz789",
|
||||
"format": "spdx-json",
|
||||
"formatVersion": "3.0.1",
|
||||
"generator": {
|
||||
"tool": "stellaops-sbomer",
|
||||
"version": "1.8.0",
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
},
|
||||
"artifact": {
|
||||
"repository": "ghcr.io/stellaops/scanner",
|
||||
"tag": "v2.5.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"packages": {
|
||||
"total": 127,
|
||||
"direct": 24,
|
||||
"transitive": 103
|
||||
},
|
||||
"licenses": {
|
||||
"approved": ["MIT", "Apache-2.0", "BSD-3-Clause"],
|
||||
"flagged": [],
|
||||
"unknown": 2
|
||||
},
|
||||
"vulnerabilities": {
|
||||
"critical": 0,
|
||||
"high": 0,
|
||||
"medium": 3,
|
||||
"low": 12,
|
||||
"informational": 5
|
||||
},
|
||||
"checksums": {
|
||||
"sbomSha256": "fedcba987654321098765432109876543210fedcba987654321098765432109876",
|
||||
"contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
public const string ReplayPredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"replayId": "replay-20250115-103000-def456",
|
||||
"originalScanId": "scan-20250114-090000-original",
|
||||
"mode": "verification",
|
||||
"inputs": {
|
||||
"manifestDigest": "sha256:manifest123456789012345678901234567890abcdef12345678901234567890",
|
||||
"feedPins": {
|
||||
"nvd": "2025-01-14T00:00:00Z",
|
||||
"osv": "2025-01-14T00:00:00Z"
|
||||
},
|
||||
"policyVersion": "v2.1.0",
|
||||
"toolVersions": {
|
||||
"trivy": "0.58.0",
|
||||
"grype": "0.87.0",
|
||||
"syft": "1.20.0"
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"startedAt": "2025-01-15T10:00:00Z",
|
||||
"completedAt": "2025-01-15T10:30:00Z",
|
||||
"durationSeconds": 1800,
|
||||
"workerCount": 4,
|
||||
"deterministic": true
|
||||
},
|
||||
"outputs": {
|
||||
"layersProcessed": 12,
|
||||
"merkleRoot": "sha256:merkle0123456789abcdef0123456789abcdef0123456789abcdef01234567",
|
||||
"outputDigest": "sha256:output0123456789abcdef0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"verification": {
|
||||
"inputHashMatch": true,
|
||||
"outputHashMatch": true,
|
||||
"determinismScore": 1.0,
|
||||
"diffPaths": []
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
public const string SlsaProvenanceV02PredicateJson = """
|
||||
{
|
||||
"builder": {
|
||||
"id": "https://github.com/stellaops/scanner/.github/workflows/build.yml@refs/tags/v2.5.0"
|
||||
},
|
||||
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
|
||||
"digest": {
|
||||
"sha1": "abc123def456789012345678901234567890abcd"
|
||||
},
|
||||
"entryPoint": ".github/workflows/build.yml"
|
||||
},
|
||||
"parameters": {},
|
||||
"environment": {
|
||||
"github_actor": "stellaops-bot",
|
||||
"github_event_name": "push",
|
||||
"github_ref": "refs/tags/v2.5.0",
|
||||
"github_repository": "stellaops/scanner",
|
||||
"github_run_id": "12345678901",
|
||||
"github_run_number": "456",
|
||||
"github_sha": "abc123def456789012345678901234567890abcd"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationId": "12345678901-456",
|
||||
"buildStartedOn": "2025-01-15T10:00:00Z",
|
||||
"buildFinishedOn": "2025-01-15T10:30:00Z",
|
||||
"completeness": {
|
||||
"parameters": true,
|
||||
"environment": true,
|
||||
"materials": true
|
||||
},
|
||||
"reproducible": true
|
||||
},
|
||||
"materials": [
|
||||
{
|
||||
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
|
||||
"digest": {
|
||||
"sha1": "abc123def456789012345678901234567890abcd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "pkg:golang/github.com/stellaops/go-sdk@v1.5.0",
|
||||
"digest": {
|
||||
"sha256": "fedcba987654321098765432109876543210fedcba987654321098765432109876"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
public const string SlsaProvenanceV1PredicateJson = """
|
||||
{
|
||||
"buildDefinition": {
|
||||
"buildType": "https://slsa.dev/container-based-build/v0.1",
|
||||
"externalParameters": {
|
||||
"repository": "https://github.com/stellaops/scanner",
|
||||
"ref": "refs/tags/v2.5.0"
|
||||
},
|
||||
"internalParameters": {
|
||||
"workflow": ".github/workflows/build.yml"
|
||||
},
|
||||
"resolvedDependencies": [
|
||||
{
|
||||
"uri": "git+https://github.com/stellaops/scanner@refs/tags/v2.5.0",
|
||||
"digest": {
|
||||
"gitCommit": "abc123def456789012345678901234567890abcd"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"runDetails": {
|
||||
"builder": {
|
||||
"id": "https://github.com/stellaops/scanner/.github/workflows/build.yml@refs/tags/v2.5.0",
|
||||
"builderDependencies": [
|
||||
{
|
||||
"uri": "https://github.com/actions/runner-images/releases/tag/ubuntu22/20250110.1"
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"stellaops-builder": "1.0.0"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"invocationId": "https://github.com/stellaops/scanner/actions/runs/12345678901/attempts/1",
|
||||
"startedOn": "2025-01-15T10:00:00Z",
|
||||
"finishedOn": "2025-01-15T10:30:00Z"
|
||||
},
|
||||
"byproducts": []
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
public const string VexPredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"vexId": "vex-20250115-103000-ghi789",
|
||||
"artifact": {
|
||||
"repository": "ghcr.io/stellaops/scanner",
|
||||
"tag": "v2.5.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2024-12345",
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_present",
|
||||
"impact": "The affected function is not used in our build configuration.",
|
||||
"actionStatement": "No action required.",
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"vulnerability": "CVE-2024-67890",
|
||||
"status": "fixed",
|
||||
"justification": "component_not_present",
|
||||
"impact": "Dependency was removed in v2.4.0.",
|
||||
"actionStatement": "Upgrade to v2.4.0 or later.",
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"name": "StellaOps Security Team",
|
||||
"email": "security@stellaops.io"
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
public const string PolicyPredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"evaluationId": "eval-20250115-103000-jkl012",
|
||||
"policy": {
|
||||
"id": "stellaops-production-policy",
|
||||
"version": "v2.1.0",
|
||||
"digest": "sha256:policy0123456789abcdef0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"artifact": {
|
||||
"repository": "ghcr.io/stellaops/scanner",
|
||||
"tag": "v2.5.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"result": {
|
||||
"passed": true,
|
||||
"score": 98,
|
||||
"threshold": 85
|
||||
},
|
||||
"rules": {
|
||||
"evaluated": 42,
|
||||
"passed": 41,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"warnings": 3
|
||||
},
|
||||
"violations": [],
|
||||
"warnings": [
|
||||
{
|
||||
"ruleId": "warn-sbom-completeness",
|
||||
"message": "SBOM completeness is 97%, recommended minimum is 99%.",
|
||||
"severity": "low"
|
||||
}
|
||||
],
|
||||
"evidence": {
|
||||
"sbomVerified": true,
|
||||
"signatureVerified": true,
|
||||
"provenanceVerified": true,
|
||||
"vulnerabilityScanPassed": true
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
public const string EvidencePredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"evidenceId": "evidence-20250115-103000-mno345",
|
||||
"artifact": {
|
||||
"repository": "ghcr.io/stellaops/scanner",
|
||||
"tag": "v2.5.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"chain": [
|
||||
{
|
||||
"type": "provenance",
|
||||
"digest": "sha256:prov0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
"issuer": "https://github.com/stellaops/scanner/.github/workflows/build.yml",
|
||||
"timestamp": "2025-01-15T10:15:00Z"
|
||||
},
|
||||
{
|
||||
"type": "sbom",
|
||||
"digest": "sha256:sbom0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
"issuer": "stellaops-sbomer",
|
||||
"timestamp": "2025-01-15T10:20:00Z"
|
||||
},
|
||||
{
|
||||
"type": "vulnerability-scan",
|
||||
"digest": "sha256:scan0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
"issuer": "stellaops-scanner",
|
||||
"timestamp": "2025-01-15T10:25:00Z"
|
||||
},
|
||||
{
|
||||
"type": "policy-evaluation",
|
||||
"digest": "sha256:eval0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
"issuer": "stellaops-policy-engine",
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"aggregated": {
|
||||
"trustLevel": "high",
|
||||
"completeness": 1.0,
|
||||
"validFrom": "2025-01-15T10:00:00Z",
|
||||
"validUntil": "2025-07-15T10:00:00Z"
|
||||
},
|
||||
"verificationLog": {
|
||||
"verifiedAt": "2025-01-15T10:30:00Z",
|
||||
"verifiedBy": "stellaops-authority",
|
||||
"rekorLogIndex": 12345678,
|
||||
"transparencyLogId": "https://rekor.sigstore.dev"
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// VEX Decision predicate in OpenVEX format for policy decision signing.
|
||||
/// This is the per-finding OpenVEX statement used by the Policy Engine.
|
||||
/// </summary>
|
||||
public const string VexDecisionPredicateJson = """
|
||||
{
|
||||
"@context": "https://openvex.dev/ns/v0.2.0",
|
||||
"@id": "https://stellaops.io/vex/decision/20250115-103000-pqr678",
|
||||
"author": "StellaOps Policy Engine",
|
||||
"role": "automated-policy-engine",
|
||||
"timestamp": "2025-01-15T10:30:00Z",
|
||||
"version": 1,
|
||||
"tooling": "stellaops-policy-engine/v2.1.0",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": {
|
||||
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345",
|
||||
"name": "CVE-2024-12345",
|
||||
"description": "Buffer overflow in example library"
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z",
|
||||
"products": [
|
||||
{
|
||||
"@id": "pkg:oci/scanner@sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456",
|
||||
"identifiers": {
|
||||
"purl": "pkg:oci/scanner@sha256:abc123"
|
||||
},
|
||||
"subcomponents": [
|
||||
{
|
||||
"@id": "pkg:npm/lodash@4.17.20",
|
||||
"identifiers": {
|
||||
"purl": "pkg:npm/lodash@4.17.20"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"impact_statement": "The vulnerable function _.template() is not called in this build. Reachability analysis confirms no execution path reaches the affected code.",
|
||||
"action_statement": "No remediation required. Continue monitoring for status changes.",
|
||||
"status_notes": "Determined via static reachability analysis using stellaops-scanner v2.5.0",
|
||||
"supplier": "StellaOps Security Team"
|
||||
}
|
||||
],
|
||||
"stellaops_extensions": {
|
||||
"policy_id": "stellaops-production-policy",
|
||||
"policy_version": "v2.1.0",
|
||||
"evaluation_id": "eval-20250115-103000-jkl012",
|
||||
"reachability": {
|
||||
"analyzed": true,
|
||||
"confidence": 0.95,
|
||||
"graph_digest": "sha256:graph0123456789abcdef0123456789abcdef0123456789abcdef01234567",
|
||||
"method": "static-callgraph"
|
||||
},
|
||||
"evidence_refs": [
|
||||
{
|
||||
"type": "sbom",
|
||||
"digest": "sha256:sbom0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
},
|
||||
{
|
||||
"type": "scan-report",
|
||||
"digest": "sha256:scan0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
},
|
||||
{
|
||||
"type": "callgraph",
|
||||
"digest": "sha256:graph0123456789abcdef0123456789abcdef0123456789abcdef01234567"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Graph predicate for reachability call-graph attestations (richgraph-v1 schema).
|
||||
/// Used by Scanner to sign deterministic call-graph manifests.
|
||||
/// </summary>
|
||||
public const string GraphPredicateJson = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"schema": "richgraph-v1",
|
||||
"graphId": "graph-20250115-103000-stu901",
|
||||
"artifact": {
|
||||
"repository": "ghcr.io/stellaops/scanner",
|
||||
"tag": "v2.5.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"generation": {
|
||||
"tool": "stellaops-scanner",
|
||||
"toolVersion": "2.5.0",
|
||||
"generatedAt": "2025-01-15T10:30:00Z",
|
||||
"deterministic": true,
|
||||
"hashAlgorithm": "blake3"
|
||||
},
|
||||
"graph": {
|
||||
"rootNodes": [
|
||||
{
|
||||
"symbolId": "main:0x1000",
|
||||
"name": "main",
|
||||
"demangled": "main(int, char**)",
|
||||
"source": "native",
|
||||
"file": "src/main.c",
|
||||
"line": 42
|
||||
}
|
||||
],
|
||||
"nodes": {
|
||||
"total": 1247,
|
||||
"native": 823,
|
||||
"managed": 424
|
||||
},
|
||||
"edges": {
|
||||
"total": 3891,
|
||||
"direct": 2456,
|
||||
"indirect": 1435
|
||||
},
|
||||
"components": {
|
||||
"analyzed": 156,
|
||||
"purls": [
|
||||
"pkg:npm/lodash@4.17.20",
|
||||
"pkg:npm/express@4.18.2",
|
||||
"pkg:golang/github.com/stellaops/go-sdk@v1.5.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hashes": {
|
||||
"graphHash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"nodesHash": "blake3:fedcba987654321098765432109876543210fedcba987654321098765432109876",
|
||||
"edgesHash": "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
},
|
||||
"cas": {
|
||||
"location": "cas://reachability/graphs/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"bundleDigest": "sha256:bundle0123456789abcdef0123456789abcdef0123456789abcdef012345678"
|
||||
},
|
||||
"metadata": {
|
||||
"scanId": "scan-20250115-103000-original",
|
||||
"layersAnalyzed": 12,
|
||||
"initRootsIncluded": true,
|
||||
"purlResolutionEnabled": true
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating deterministic signing requests in tests.
|
||||
/// Uses fixed values to ensure reproducible test results.
|
||||
/// </summary>
|
||||
public sealed class SigningRequestBuilder
|
||||
{
|
||||
private List<SigningSubject> _subjects = new();
|
||||
private string _predicateType = PredicateTypes.SlsaProvenanceV02;
|
||||
private JsonDocument _predicate = PredicateFixtures.CreateSlsaProvenanceV02Predicate();
|
||||
private string _scannerImageDigest = DeterministicTestData.TrustedScannerDigest;
|
||||
private SignerPoEFormat _poeFormat = SignerPoEFormat.Jwt;
|
||||
private string _poeValue = DeterministicTestData.ValidPoeToken;
|
||||
private SigningMode _signingMode = SigningMode.Keyless;
|
||||
private int? _expirySeconds = 3600;
|
||||
private string _returnBundle = "dsse+cert";
|
||||
|
||||
public SigningRequestBuilder WithSubject(string name, string sha256Hash)
|
||||
{
|
||||
_subjects.Add(new SigningSubject(name, new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = sha256Hash
|
||||
}));
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithSubject(string name, Dictionary<string, string> digest)
|
||||
{
|
||||
_subjects.Add(new SigningSubject(name, digest));
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithDefaultSubject()
|
||||
{
|
||||
return WithSubject(
|
||||
DeterministicTestData.DefaultSubjectName,
|
||||
DeterministicTestData.DefaultSubjectDigest);
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithPredicateType(string predicateType)
|
||||
{
|
||||
_predicateType = predicateType;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithPredicate(JsonDocument predicate)
|
||||
{
|
||||
_predicate = predicate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithPromotionPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsPromotion;
|
||||
_predicate = PredicateFixtures.CreatePromotionPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithSbomPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsSbom;
|
||||
_predicate = PredicateFixtures.CreateSbomPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithReplayPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsReplay;
|
||||
_predicate = PredicateFixtures.CreateReplayPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithVexPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsVex;
|
||||
_predicate = PredicateFixtures.CreateVexPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithPolicyPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsPolicy;
|
||||
_predicate = PredicateFixtures.CreatePolicyPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithEvidencePredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsEvidence;
|
||||
_predicate = PredicateFixtures.CreateEvidencePredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithVexDecisionPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsVexDecision;
|
||||
_predicate = PredicateFixtures.CreateVexDecisionPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithGraphPredicate()
|
||||
{
|
||||
_predicateType = PredicateTypes.StellaOpsGraph;
|
||||
_predicate = PredicateFixtures.CreateGraphPredicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithSlsaProvenanceV02()
|
||||
{
|
||||
_predicateType = PredicateTypes.SlsaProvenanceV02;
|
||||
_predicate = PredicateFixtures.CreateSlsaProvenanceV02Predicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithSlsaProvenanceV1()
|
||||
{
|
||||
_predicateType = PredicateTypes.SlsaProvenanceV1;
|
||||
_predicate = PredicateFixtures.CreateSlsaProvenanceV1Predicate();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithScannerImageDigest(string digest)
|
||||
{
|
||||
_scannerImageDigest = digest;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithProofOfEntitlement(SignerPoEFormat format, string value)
|
||||
{
|
||||
_poeFormat = format;
|
||||
_poeValue = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithSigningMode(SigningMode mode)
|
||||
{
|
||||
_signingMode = mode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithKeylessMode()
|
||||
{
|
||||
_signingMode = SigningMode.Keyless;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithKmsMode()
|
||||
{
|
||||
_signingMode = SigningMode.Kms;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithExpirySeconds(int? expirySeconds)
|
||||
{
|
||||
_expirySeconds = expirySeconds;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequestBuilder WithReturnBundle(string returnBundle)
|
||||
{
|
||||
_returnBundle = returnBundle;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SigningRequest Build()
|
||||
{
|
||||
// Add default subject if none specified
|
||||
if (_subjects.Count == 0)
|
||||
{
|
||||
WithDefaultSubject();
|
||||
}
|
||||
|
||||
return new SigningRequest(
|
||||
Subjects: _subjects,
|
||||
PredicateType: _predicateType,
|
||||
Predicate: _predicate,
|
||||
ScannerImageDigest: _scannerImageDigest,
|
||||
ProofOfEntitlement: new ProofOfEntitlement(_poeFormat, _poeValue),
|
||||
Options: new SigningOptions(_signingMode, _expirySeconds, _returnBundle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder instance.
|
||||
/// </summary>
|
||||
public static SigningRequestBuilder Create() => new();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating deterministic test crypto providers and signing keys.
|
||||
/// Uses fixed seed data to ensure reproducible test results.
|
||||
/// </summary>
|
||||
public static class TestCryptoFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed test key ID for deterministic testing.
|
||||
/// </summary>
|
||||
public const string TestKeyId = "test-signing-key-12345";
|
||||
|
||||
/// <summary>
|
||||
/// Fixed keyless key ID for ephemeral signing.
|
||||
/// </summary>
|
||||
public const string KeylessKeyId = "keyless-ephemeral-20250115";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a DefaultCryptoProvider with a pre-registered test signing key.
|
||||
/// </summary>
|
||||
public static DefaultCryptoProvider CreateProviderWithTestKey()
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var signingKey = CreateDeterministicSigningKey(TestKeyId);
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a DefaultCryptoProvider with a keyless signing key.
|
||||
/// </summary>
|
||||
public static DefaultCryptoProvider CreateProviderWithKeylessKey()
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var signingKey = CreateDeterministicSigningKey(KeylessKeyId);
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a DefaultCryptoProvider with multiple signing keys.
|
||||
/// </summary>
|
||||
public static DefaultCryptoProvider CreateProviderWithMultipleKeys(params string[] keyIds)
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
foreach (var keyId in keyIds)
|
||||
{
|
||||
var signingKey = CreateDeterministicSigningKey(keyId);
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CryptoProviderRegistry with a test provider containing both test and keyless keys.
|
||||
/// </summary>
|
||||
public static ICryptoProviderRegistry CreateTestRegistry()
|
||||
{
|
||||
var provider = CreateProviderWithMultipleKeys(TestKeyId, KeylessKeyId);
|
||||
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CryptoProviderRegistry for keyless signing tests (includes both keys for flexibility).
|
||||
/// </summary>
|
||||
public static ICryptoProviderRegistry CreateKeylessRegistry()
|
||||
{
|
||||
var provider = CreateProviderWithMultipleKeys(TestKeyId, KeylessKeyId);
|
||||
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signing key with deterministic parameters.
|
||||
/// The key is generated from the keyId to ensure determinism.
|
||||
/// </summary>
|
||||
public static CryptoSigningKey CreateDeterministicSigningKey(string keyId)
|
||||
{
|
||||
// Generate a P-256 key pair - using the keyId as a seed for determinism
|
||||
// Note: In production this would use secure random generation
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
|
||||
return new CryptoSigningKey(
|
||||
reference: new CryptoKeyReference(keyId, "default"),
|
||||
algorithmId: SignatureAlgorithms.Es256,
|
||||
privateParameters: parameters,
|
||||
createdAt: DeterministicTestData.FixedTimestamp,
|
||||
expiresAt: DeterministicTestData.FarFutureExpiry,
|
||||
metadata: new System.Collections.Generic.Dictionary<string, string?>
|
||||
{
|
||||
["purpose"] = "testing",
|
||||
["environment"] = "unit-test"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CryptoKeyReference for the default test key.
|
||||
/// </summary>
|
||||
public static CryptoKeyReference CreateTestKeyReference()
|
||||
{
|
||||
return new CryptoKeyReference(TestKeyId, "default");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CryptoKeyReference for keyless mode.
|
||||
/// </summary>
|
||||
public static CryptoKeyReference CreateKeylessKeyReference()
|
||||
{
|
||||
return new CryptoKeyReference(KeylessKeyId, "default");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Infrastructure.Signing;
|
||||
using StellaOps.Signer.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for CryptoDsseSigner using real crypto providers.
|
||||
/// Tests signing workflows with deterministic fixture predicates.
|
||||
/// </summary>
|
||||
public sealed class CryptoDsseSignerIntegrationTests
|
||||
{
|
||||
private readonly ICryptoProviderRegistry _cryptoRegistry;
|
||||
private readonly ISigningKeyResolver _keyResolver;
|
||||
private readonly CryptoDsseSigner _signer;
|
||||
|
||||
public CryptoDsseSignerIntegrationTests()
|
||||
{
|
||||
_cryptoRegistry = TestCryptoFactory.CreateTestRegistry();
|
||||
_keyResolver = CreateTestKeyResolver();
|
||||
_signer = CreateSigner();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithPromotionPredicate_ProducesValidDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Envelope.Should().NotBeNull();
|
||||
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
bundle.Envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
bundle.Envelope.Signatures.Should().HaveCount(1);
|
||||
bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
|
||||
bundle.Metadata.Identity.Mode.Should().Be("keyless");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSbomPredicate_ProducesValidDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithSbomPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
|
||||
// Verify payload contains SBOM predicate
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.StellaOpsSbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithReplayPredicate_ProducesValidDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithReplayPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
|
||||
// Verify payload contains replay predicate
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.StellaOpsReplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSlsaProvenanceV02_ProducesValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithSlsaProvenanceV02()
|
||||
.WithKmsMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.Metadata.Identity.Mode.Should().Be("kms");
|
||||
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.SlsaProvenanceV02);
|
||||
doc.RootElement.GetProperty("_type").GetString()
|
||||
.Should().Be("https://in-toto.io/Statement/v0.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSlsaProvenanceV1_UsesStatementV1()
|
||||
{
|
||||
// Arrange - use predicate with v1 expected statement type
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithSlsaProvenanceV1()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.SlsaProvenanceV1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithMultipleSubjects_IncludesAllInPayload()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithSubject(DeterministicTestData.DefaultSubjectName, DeterministicTestData.DefaultSubjectDigest)
|
||||
.WithSubject(DeterministicTestData.SecondSubjectName, DeterministicTestData.SecondSubjectDigest)
|
||||
.WithSubject(DeterministicTestData.ThirdSubjectName, DeterministicTestData.ThirdSubjectDigest)
|
||||
.WithPromotionPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithVexPredicate_ProducesValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithVexPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.StellaOpsVex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithPolicyPredicate_ProducesValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPolicyPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.StellaOpsPolicy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithEvidencePredicate_ProducesValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithEvidencePredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
doc.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be(PredicateTypes.StellaOpsEvidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesBase64UrlEncodedSignature()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert - signature should be base64url (no + or / or =)
|
||||
var signature = bundle.Envelope.Signatures[0].Signature;
|
||||
signature.Should().NotContain("+");
|
||||
signature.Should().NotContain("/");
|
||||
signature.Should().NotContain("=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesBase64UrlEncodedPayload()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert - payload should be base64url (no + or / or =)
|
||||
var payload = bundle.Envelope.Payload;
|
||||
payload.Should().NotContain("+");
|
||||
payload.Should().NotContain("/");
|
||||
payload.Should().NotContain("=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_IncludesCertificateChainInMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.CertificateChain.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_SetsCorrectAlgorithmId()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_SetsProviderNameInMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
bundle.Metadata.ProviderName.Should().Be("default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithDifferentTenants_UsesCorrectKeyResolution()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller1 = DeterministicTestData.CreateCallerContextForTenant("tenant-a");
|
||||
var caller2 = DeterministicTestData.CreateCallerContextForTenant("tenant-b");
|
||||
|
||||
// Act
|
||||
var bundle1 = await _signer.SignAsync(request, entitlement, caller1, CancellationToken.None);
|
||||
var bundle2 = await _signer.SignAsync(request, entitlement, caller2, CancellationToken.None);
|
||||
|
||||
// Assert - both should produce valid bundles
|
||||
bundle1.Should().NotBeNull();
|
||||
bundle2.Should().NotBeNull();
|
||||
bundle1.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
|
||||
bundle2.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_Signature_IsVerifiable()
|
||||
{
|
||||
// Arrange
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var entitlement = DeterministicTestData.CreateDefaultEntitlement();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var bundle = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert - verify we can decode and re-verify the signature
|
||||
var payloadBytes = DecodeBase64Url(bundle.Envelope.Payload);
|
||||
var signatureBytes = DecodeBase64Url(bundle.Envelope.Signatures[0].Signature);
|
||||
|
||||
// Build PAE for verification
|
||||
var paeBytes = BuildPae(bundle.Envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Get signer for verification
|
||||
var keyReference = TestCryptoFactory.CreateKeylessKeyReference();
|
||||
var resolution = _cryptoRegistry.ResolveSigner(
|
||||
CryptoCapability.Verification,
|
||||
SignatureAlgorithms.Es256,
|
||||
keyReference);
|
||||
|
||||
var verified = await resolution.Signer.VerifyAsync(paeBytes, signatureBytes, CancellationToken.None);
|
||||
verified.Should().BeTrue();
|
||||
}
|
||||
|
||||
private CryptoDsseSigner CreateSigner()
|
||||
{
|
||||
var options = Options.Create(new DsseSignerOptions
|
||||
{
|
||||
DefaultIssuer = DeterministicTestData.DefaultIssuer,
|
||||
KeylessAlgorithm = SignatureAlgorithms.Es256,
|
||||
KmsAlgorithm = SignatureAlgorithms.Es256
|
||||
});
|
||||
|
||||
return new CryptoDsseSigner(
|
||||
_cryptoRegistry,
|
||||
_keyResolver,
|
||||
options,
|
||||
NullLogger<CryptoDsseSigner>.Instance);
|
||||
}
|
||||
|
||||
private ISigningKeyResolver CreateTestKeyResolver()
|
||||
{
|
||||
var keyResolver = Substitute.For<ISigningKeyResolver>();
|
||||
|
||||
keyResolver.ResolveKeyAsync(SigningMode.Keyless, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => ValueTask.FromResult(new SigningKeyResolution(
|
||||
TestCryptoFactory.KeylessKeyId,
|
||||
"default",
|
||||
DeterministicTestData.DefaultIssuer,
|
||||
callInfo.Arg<string>(), // tenant as subject
|
||||
DeterministicTestData.ExpiryTimestamp)));
|
||||
|
||||
keyResolver.ResolveKeyAsync(SigningMode.Kms, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(new SigningKeyResolution(
|
||||
TestCryptoFactory.TestKeyId,
|
||||
"default",
|
||||
DeterministicTestData.DefaultIssuer,
|
||||
"kms-service@stellaops.io",
|
||||
DeterministicTestData.FarFutureExpiry)));
|
||||
|
||||
return keyResolver;
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase64Url(string base64Url)
|
||||
{
|
||||
// Convert base64url to standard base64
|
||||
var base64 = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
// Add padding if needed
|
||||
switch (base64.Length % 4)
|
||||
{
|
||||
case 2: base64 += "=="; break;
|
||||
case 3: base64 += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var prefixBytes = Encoding.UTF8.GetBytes("DSSEv1");
|
||||
var typeLenStr = typeBytes.Length.ToString();
|
||||
var payloadLenStr = payload.Length.ToString();
|
||||
|
||||
var totalLen = prefixBytes.Length + 1 +
|
||||
typeLenStr.Length + 1 +
|
||||
typeBytes.Length + 1 +
|
||||
payloadLenStr.Length + 1 +
|
||||
payload.Length;
|
||||
|
||||
var pae = new byte[totalLen];
|
||||
var offset = 0;
|
||||
|
||||
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
|
||||
offset += prefixBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
|
||||
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
|
||||
offset += typeLenBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
|
||||
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
|
||||
offset += payloadLenBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
|
||||
|
||||
return pae;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Infrastructure.Options;
|
||||
using StellaOps.Signer.Infrastructure.Signing;
|
||||
using StellaOps.Signer.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the full signer pipeline using real crypto abstraction.
|
||||
/// </summary>
|
||||
public sealed class SignerPipelineIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignerPipeline_WithCryptoDsseSigner_ProducesValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Should().NotBeNull();
|
||||
outcome.Bundle.Should().NotBeNull();
|
||||
outcome.Bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
outcome.Bundle.Envelope.Signatures.Should().HaveCount(1);
|
||||
outcome.AuditId.Should().NotBeNullOrEmpty();
|
||||
outcome.Policy.Plan.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_WithSbomPredicate_ProducesValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithSbomPredicate()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Should().NotBeNull();
|
||||
outcome.Bundle.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_WithReplayPredicate_ProducesValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithReplayPredicate()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Should().NotBeNull();
|
||||
outcome.Bundle.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_TracksAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.AuditId.Should().NotBeNullOrEmpty();
|
||||
// Audit ID should be a valid GUID format
|
||||
Guid.TryParse(outcome.AuditId, out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_EnforcesPolicyCounters()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Policy.Should().NotBeNull();
|
||||
outcome.Policy.Plan.Should().Be(DeterministicTestData.ProPlan);
|
||||
outcome.Policy.MaxArtifactBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_WithKmsMode_UsesCorrectSigningIdentity()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithSlsaProvenanceV02()
|
||||
.WithKmsMode()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Bundle.Metadata.Identity.Mode.Should().Be("kms");
|
||||
outcome.Bundle.Metadata.Identity.Issuer.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_WithKeylessMode_UsesEphemeralKey()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.WithKeylessMode()
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Bundle.Metadata.Identity.Mode.Should().Be("keyless");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignerPipeline_RejectsUntrustedScannerDigest()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPromotionPredicate()
|
||||
.WithScannerImageDigest(DeterministicTestData.UntrustedScannerDigest)
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var act = async () => await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<SignerReleaseVerificationException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.StellaOpsPromotion)]
|
||||
[InlineData(PredicateTypes.StellaOpsSbom)]
|
||||
[InlineData(PredicateTypes.StellaOpsVex)]
|
||||
[InlineData(PredicateTypes.StellaOpsReplay)]
|
||||
[InlineData(PredicateTypes.StellaOpsPolicy)]
|
||||
[InlineData(PredicateTypes.StellaOpsEvidence)]
|
||||
[InlineData(PredicateTypes.StellaOpsVexDecision)]
|
||||
[InlineData(PredicateTypes.StellaOpsGraph)]
|
||||
public async Task SignerPipeline_SupportsAllStellaOpsPredicateTypes(string predicateType)
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceCollection();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var pipeline = provider.GetRequiredService<ISignerPipeline>();
|
||||
var predicate = GetPredicateForType(predicateType);
|
||||
var request = SigningRequestBuilder.Create()
|
||||
.WithDefaultSubject()
|
||||
.WithPredicateType(predicateType)
|
||||
.WithPredicate(predicate)
|
||||
.Build();
|
||||
var caller = DeterministicTestData.CreateDefaultCallerContext();
|
||||
|
||||
// Act
|
||||
var outcome = await pipeline.SignAsync(request, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
outcome.Should().NotBeNull();
|
||||
outcome.Bundle.Envelope.Signatures.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private static ServiceCollection CreateServiceCollection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Register logging
|
||||
services.AddLogging();
|
||||
|
||||
// Register time provider
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Register crypto registry with test keys
|
||||
services.AddSingleton<ICryptoProviderRegistry>(_ =>
|
||||
{
|
||||
var provider = TestCryptoFactory.CreateProviderWithMultipleKeys(
|
||||
TestCryptoFactory.TestKeyId,
|
||||
TestCryptoFactory.KeylessKeyId);
|
||||
return new CryptoProviderRegistry(new[] { provider }, new[] { provider.Name });
|
||||
});
|
||||
|
||||
// Register key resolver
|
||||
services.AddSingleton<ISigningKeyResolver>(sp =>
|
||||
{
|
||||
var keyResolver = Substitute.For<ISigningKeyResolver>();
|
||||
|
||||
keyResolver.ResolveKeyAsync(SigningMode.Keyless, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(new SigningKeyResolution(
|
||||
TestCryptoFactory.KeylessKeyId,
|
||||
"default",
|
||||
DeterministicTestData.DefaultIssuer)));
|
||||
|
||||
keyResolver.ResolveKeyAsync(SigningMode.Kms, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(new SigningKeyResolution(
|
||||
TestCryptoFactory.TestKeyId,
|
||||
"default",
|
||||
DeterministicTestData.DefaultIssuer)));
|
||||
|
||||
return keyResolver;
|
||||
});
|
||||
|
||||
// Register DSSE signer options
|
||||
services.Configure<DsseSignerOptions>(options =>
|
||||
{
|
||||
options.DefaultIssuer = DeterministicTestData.DefaultIssuer;
|
||||
options.KeylessAlgorithm = SignatureAlgorithms.Es256;
|
||||
options.KmsAlgorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
|
||||
// Register CryptoDsseSigner
|
||||
services.AddSingleton<IDsseSigner, CryptoDsseSigner>();
|
||||
|
||||
// Register stub services for pipeline dependencies
|
||||
services.AddSingleton<IProofOfEntitlementIntrospector>(sp =>
|
||||
{
|
||||
var introspector = Substitute.For<IProofOfEntitlementIntrospector>();
|
||||
introspector.IntrospectAsync(Arg.Any<ProofOfEntitlement>(), Arg.Any<CallerContext>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(DeterministicTestData.CreateDefaultEntitlement()));
|
||||
return introspector;
|
||||
});
|
||||
|
||||
services.AddSingleton<IReleaseIntegrityVerifier>(sp =>
|
||||
{
|
||||
var verifier = Substitute.For<IReleaseIntegrityVerifier>();
|
||||
verifier.VerifyAsync(DeterministicTestData.TrustedScannerDigest, Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(new ReleaseVerificationResult(true, "trusted-signer")));
|
||||
verifier.VerifyAsync(DeterministicTestData.UntrustedScannerDigest, Arg.Any<CancellationToken>())
|
||||
.Returns<ReleaseVerificationResult>(_ =>
|
||||
throw new SignerReleaseVerificationException("release_untrusted", "Scanner digest is not trusted."));
|
||||
return verifier;
|
||||
});
|
||||
|
||||
services.AddSingleton<ISignerQuotaService>(sp =>
|
||||
{
|
||||
var quotaService = Substitute.For<ISignerQuotaService>();
|
||||
quotaService.EnsureWithinLimitsAsync(
|
||||
Arg.Any<SigningRequest>(),
|
||||
Arg.Any<ProofOfEntitlementResult>(),
|
||||
Arg.Any<CallerContext>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
return quotaService;
|
||||
});
|
||||
|
||||
services.AddSingleton<ISignerAuditSink>(sp =>
|
||||
{
|
||||
var auditSink = Substitute.For<ISignerAuditSink>();
|
||||
auditSink.WriteAsync(
|
||||
Arg.Any<SigningRequest>(),
|
||||
Arg.Any<SigningBundle>(),
|
||||
Arg.Any<ProofOfEntitlementResult>(),
|
||||
Arg.Any<CallerContext>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => ValueTask.FromResult(Guid.NewGuid().ToString()));
|
||||
return auditSink;
|
||||
});
|
||||
|
||||
// Register the pipeline
|
||||
services.AddSingleton<ISignerPipeline, SignerPipeline>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static System.Text.Json.JsonDocument GetPredicateForType(string predicateType)
|
||||
{
|
||||
return predicateType switch
|
||||
{
|
||||
PredicateTypes.StellaOpsPromotion => PredicateFixtures.CreatePromotionPredicate(),
|
||||
PredicateTypes.StellaOpsSbom => PredicateFixtures.CreateSbomPredicate(),
|
||||
PredicateTypes.StellaOpsVex => PredicateFixtures.CreateVexPredicate(),
|
||||
PredicateTypes.StellaOpsReplay => PredicateFixtures.CreateReplayPredicate(),
|
||||
PredicateTypes.StellaOpsPolicy => PredicateFixtures.CreatePolicyPredicate(),
|
||||
PredicateTypes.StellaOpsEvidence => PredicateFixtures.CreateEvidencePredicate(),
|
||||
PredicateTypes.StellaOpsVexDecision => PredicateFixtures.CreateVexDecisionPredicate(),
|
||||
PredicateTypes.StellaOpsGraph => PredicateFixtures.CreateGraphPredicate(),
|
||||
_ => PredicateFixtures.CreateSlsaProvenanceV02Predicate()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NSubstitute;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Infrastructure.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Signing;
|
||||
|
||||
public sealed class CryptoDsseSignerTests
|
||||
{
|
||||
private readonly ICryptoProviderRegistry _mockRegistry;
|
||||
private readonly ISigningKeyResolver _mockKeyResolver;
|
||||
private readonly ICryptoSigner _mockCryptoSigner;
|
||||
private readonly DsseSignerOptions _options;
|
||||
private readonly CryptoDsseSigner _signer;
|
||||
|
||||
public CryptoDsseSignerTests()
|
||||
{
|
||||
_mockRegistry = Substitute.For<ICryptoProviderRegistry>();
|
||||
_mockKeyResolver = Substitute.For<ISigningKeyResolver>();
|
||||
_mockCryptoSigner = Substitute.For<ICryptoSigner>();
|
||||
|
||||
_options = new DsseSignerOptions
|
||||
{
|
||||
DefaultIssuer = "https://test.stellaops.io",
|
||||
KeylessAlgorithm = SignatureAlgorithms.Es256
|
||||
};
|
||||
|
||||
_signer = new CryptoDsseSigner(
|
||||
_mockRegistry,
|
||||
_mockKeyResolver,
|
||||
Options.Create(_options),
|
||||
NullLogger<CryptoDsseSigner>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesValidDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
var keyResolution = new SigningKeyResolution("test-key-id", "default");
|
||||
var signatureBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
|
||||
_mockKeyResolver
|
||||
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(keyResolution));
|
||||
|
||||
_mockCryptoSigner.KeyId.Returns("test-key-id");
|
||||
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
|
||||
_mockCryptoSigner
|
||||
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(signatureBytes));
|
||||
_mockCryptoSigner
|
||||
.ExportPublicJsonWebKey()
|
||||
.Returns(new JsonWebKey { KeyId = "test-key-id", Kty = "EC" });
|
||||
|
||||
_mockRegistry
|
||||
.ResolveSigner(
|
||||
Arg.Any<CryptoCapability>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CryptoKeyReference>(),
|
||||
Arg.Any<string?>())
|
||||
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "default"));
|
||||
|
||||
// Act
|
||||
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Envelope.Should().NotBeNull();
|
||||
result.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
result.Envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
result.Envelope.Signatures.Should().HaveCount(1);
|
||||
result.Envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
|
||||
result.Envelope.Signatures[0].KeyId.Should().Be("test-key-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_SetsCorrectSigningMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
var keyResolution = new SigningKeyResolution(
|
||||
"kms-key-123",
|
||||
"default",
|
||||
"https://custom.issuer.io",
|
||||
"service-account@tenant.stellaops.io",
|
||||
DateTimeOffset.UtcNow.AddHours(1));
|
||||
var signatureBytes = new byte[] { 0xAB, 0xCD };
|
||||
|
||||
_mockKeyResolver
|
||||
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(keyResolution));
|
||||
|
||||
_mockCryptoSigner.KeyId.Returns("kms-key-123");
|
||||
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
|
||||
_mockCryptoSigner
|
||||
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(signatureBytes));
|
||||
_mockCryptoSigner
|
||||
.ExportPublicJsonWebKey()
|
||||
.Returns(new JsonWebKey { KeyId = "kms-key-123", Kty = "EC" });
|
||||
|
||||
_mockRegistry
|
||||
.ResolveSigner(
|
||||
Arg.Any<CryptoCapability>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CryptoKeyReference>(),
|
||||
Arg.Any<string?>())
|
||||
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "kms-provider"));
|
||||
|
||||
// Act
|
||||
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Metadata.Should().NotBeNull();
|
||||
result.Metadata.ProviderName.Should().Be("kms-provider");
|
||||
result.Metadata.AlgorithmId.Should().Be(SignatureAlgorithms.Es256);
|
||||
result.Metadata.Identity.Should().NotBeNull();
|
||||
result.Metadata.Identity.Issuer.Should().Be("https://custom.issuer.io");
|
||||
result.Metadata.Identity.Subject.Should().Be("service-account@tenant.stellaops.io");
|
||||
result.Metadata.Identity.Mode.Should().Be("keyless");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UsesKmsMode_WhenRequested()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest(SigningMode.Kms);
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
var keyResolution = new SigningKeyResolution("kms-key-abc", "kms-provider");
|
||||
var signatureBytes = new byte[] { 0x11, 0x22, 0x33 };
|
||||
|
||||
_mockKeyResolver
|
||||
.ResolveKeyAsync(SigningMode.Kms, caller.Tenant, Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(keyResolution));
|
||||
|
||||
_mockCryptoSigner.KeyId.Returns("kms-key-abc");
|
||||
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
|
||||
_mockCryptoSigner
|
||||
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(signatureBytes));
|
||||
_mockCryptoSigner
|
||||
.ExportPublicJsonWebKey()
|
||||
.Returns(new JsonWebKey { KeyId = "kms-key-abc", Kty = "EC" });
|
||||
|
||||
_mockRegistry
|
||||
.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<CryptoKeyReference>(k => k.KeyId == "kms-key-abc"),
|
||||
"kms-provider")
|
||||
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "kms-provider"));
|
||||
|
||||
// Act
|
||||
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Metadata.Identity.Mode.Should().Be("kms");
|
||||
await _mockKeyResolver.Received(1).ResolveKeyAsync(SigningMode.Kms, caller.Tenant, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesCosignCompatibleBase64Url()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
var keyResolution = new SigningKeyResolution("test-key");
|
||||
// Use signature bytes that would produce + and / in standard base64
|
||||
var signatureBytes = new byte[] { 0xFB, 0xFF, 0xFE, 0x00, 0x01 };
|
||||
|
||||
_mockKeyResolver
|
||||
.ResolveKeyAsync(Arg.Any<SigningMode>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(keyResolution));
|
||||
|
||||
_mockCryptoSigner.KeyId.Returns("test-key");
|
||||
_mockCryptoSigner.AlgorithmId.Returns(SignatureAlgorithms.Es256);
|
||||
_mockCryptoSigner
|
||||
.SignAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(signatureBytes));
|
||||
_mockCryptoSigner
|
||||
.ExportPublicJsonWebKey()
|
||||
.Returns(new JsonWebKey { KeyId = "test-key", Kty = "EC" });
|
||||
|
||||
_mockRegistry
|
||||
.ResolveSigner(
|
||||
Arg.Any<CryptoCapability>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CryptoKeyReference>(),
|
||||
Arg.Any<string?>())
|
||||
.Returns(new CryptoSignerResolution(_mockCryptoSigner, "default"));
|
||||
|
||||
// Act
|
||||
var result = await _signer.SignAsync(request, entitlement, caller, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var signature = result.Envelope.Signatures[0].Signature;
|
||||
signature.Should().NotContain("+");
|
||||
signature.Should().NotContain("/");
|
||||
signature.Should().NotContain("=");
|
||||
// Verify payload is also base64url encoded
|
||||
result.Envelope.Payload.Should().NotContain("+");
|
||||
result.Envelope.Payload.Should().NotContain("/");
|
||||
result.Envelope.Payload.Should().NotEndWith("=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ThrowsArgumentNullException_WhenRequestIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entitlement = CreateEntitlement();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _signer.SignAsync(null!, entitlement, caller, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.Where(e => e.ParamName == "request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ThrowsArgumentNullException_WhenEntitlementIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
var caller = CreateCallerContext();
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _signer.SignAsync(request, null!, caller, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.Where(e => e.ParamName == "entitlement");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ThrowsArgumentNullException_WhenCallerIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
var entitlement = CreateEntitlement();
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _signer.SignAsync(request, entitlement, null!, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.Where(e => e.ParamName == "caller");
|
||||
}
|
||||
|
||||
private static SigningRequest CreateSigningRequest(SigningMode mode = SigningMode.Keyless)
|
||||
{
|
||||
var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}""");
|
||||
return new SigningRequest(
|
||||
Subjects:
|
||||
[
|
||||
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abc123def456"
|
||||
})
|
||||
],
|
||||
PredicateType: "https://slsa.dev/provenance/v0.2",
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:scanner123",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."),
|
||||
Options: new SigningOptions(mode, 3600, "bundle"));
|
||||
}
|
||||
|
||||
private static ProofOfEntitlementResult CreateEntitlement()
|
||||
{
|
||||
return new ProofOfEntitlementResult(
|
||||
LicenseId: "lic-123",
|
||||
CustomerId: "cust-456",
|
||||
Plan: "enterprise",
|
||||
MaxArtifactBytes: 100_000_000,
|
||||
QpsLimit: 100,
|
||||
QpsRemaining: 95,
|
||||
ExpiresAtUtc: DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
private static CallerContext CreateCallerContext()
|
||||
{
|
||||
return new CallerContext(
|
||||
Subject: "user@example.com",
|
||||
Tenant: "test-tenant",
|
||||
Scopes: ["signer.sign"],
|
||||
Audiences: ["signer"],
|
||||
SenderBinding: null,
|
||||
ClientCertificateThumbprint: null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Infrastructure.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Signing;
|
||||
|
||||
public sealed class DefaultSigningKeyResolverTests
|
||||
{
|
||||
private readonly DsseSignerOptions _options;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DefaultSigningKeyResolver _resolver;
|
||||
|
||||
public DefaultSigningKeyResolverTests()
|
||||
{
|
||||
_options = new DsseSignerOptions
|
||||
{
|
||||
DefaultIssuer = "https://test.stellaops.io",
|
||||
PreferredProvider = "test-provider"
|
||||
};
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 11, 26, 12, 0, 0, TimeSpan.Zero));
|
||||
_resolver = new DefaultSigningKeyResolver(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<DefaultSigningKeyResolver>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_KeylessMode_ReturnsEphemeralKey()
|
||||
{
|
||||
// Act
|
||||
var result = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.KeyId.Should().StartWith("ephemeral:tenant-123:");
|
||||
result.ProviderHint.Should().Be("test-provider");
|
||||
result.Issuer.Should().Be("https://test.stellaops.io");
|
||||
result.Subject.Should().Be("keyless:tenant-123");
|
||||
result.ExpiresAtUtc.Should().NotBeNull();
|
||||
result.ExpiresAtUtc!.Value.Should().Be(_timeProvider.GetUtcNow().AddMinutes(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_KeylessMode_GeneratesUniqueKeyIds()
|
||||
{
|
||||
// Act
|
||||
var result1 = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
|
||||
var result2 = await _resolver.ResolveKeyAsync(SigningMode.Keyless, "tenant-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result1.KeyId.Should().NotBe(result2.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_KmsMode_ReturnsDefaultKmsKey()
|
||||
{
|
||||
// Arrange
|
||||
_options.DefaultKmsKeyId = "projects/test/locations/global/keyRings/ring/cryptoKeys/key";
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-456", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.KeyId.Should().Be("projects/test/locations/global/keyRings/ring/cryptoKeys/key");
|
||||
result.ProviderHint.Should().Be("test-provider");
|
||||
result.Issuer.Should().Be("https://test.stellaops.io");
|
||||
result.Subject.Should().Be("kms:tenant-456");
|
||||
result.ExpiresAtUtc.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_KmsMode_UsesTenantSpecificKey()
|
||||
{
|
||||
// Arrange
|
||||
_options.DefaultKmsKeyId = "default-key";
|
||||
_options.TenantKmsKeys = new Dictionary<string, string>
|
||||
{
|
||||
["tenant-special"] = "tenant-special-key"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-special", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.KeyId.Should().Be("tenant-special-key");
|
||||
result.Subject.Should().Be("kms:tenant-special");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_KmsMode_FallsBackToDefaultKey()
|
||||
{
|
||||
// Arrange
|
||||
_options.DefaultKmsKeyId = "fallback-key";
|
||||
_options.TenantKmsKeys = new Dictionary<string, string>
|
||||
{
|
||||
["other-tenant"] = "other-tenant-key"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-without-mapping", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.KeyId.Should().Be("fallback-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_KmsMode_ThrowsWhenNoKeyConfigured()
|
||||
{
|
||||
// Arrange
|
||||
_options.DefaultKmsKeyId = null;
|
||||
_options.TenantKmsKeys.Clear();
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Kms, "tenant-123", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.Where(e => e.Message.Contains("No KMS key configured") && e.Message.Contains("tenant-123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_ThrowsArgumentException_WhenTenantIsEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Keyless, "", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.Where(e => e.ParamName == "tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_ThrowsArgumentException_WhenTenantIsWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
var act = async () => await _resolver.ResolveKeyAsync(SigningMode.Keyless, " ", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.Where(e => e.ParamName == "tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveKeyAsync_ThrowsForUnknownSigningMode()
|
||||
{
|
||||
// Act & Assert
|
||||
var act = async () => await _resolver.ResolveKeyAsync((SigningMode)99, "tenant-123", CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ArgumentOutOfRangeException>()
|
||||
.Where(e => e.ParamName == "mode");
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signer.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Signing;
|
||||
|
||||
public sealed class SignerStatementBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildStatementPayload_CreatesValidStatement()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
|
||||
// Act
|
||||
var payload = SignerStatementBuilder.BuildStatementPayload(request);
|
||||
|
||||
// Assert
|
||||
payload.Should().NotBeNullOrEmpty();
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
root.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v0.1");
|
||||
root.GetProperty("predicateType").GetString().Should().Be("https://slsa.dev/provenance/v0.2");
|
||||
root.GetProperty("subject").GetArrayLength().Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_UsesDeterministicSerialization()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
|
||||
// Act
|
||||
var payload1 = SignerStatementBuilder.BuildStatementPayload(request);
|
||||
var payload2 = SignerStatementBuilder.BuildStatementPayload(request);
|
||||
|
||||
// Assert - Same input should produce identical output
|
||||
payload1.Should().BeEquivalentTo(payload2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_SortsDigestKeys()
|
||||
{
|
||||
// Arrange - Use unsorted digest keys
|
||||
var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}""");
|
||||
var request = new SigningRequest(
|
||||
Subjects:
|
||||
[
|
||||
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["SHA512"] = "xyz789",
|
||||
["sha256"] = "abc123",
|
||||
["MD5"] = "def456"
|
||||
})
|
||||
],
|
||||
PredicateType: PredicateTypes.SlsaProvenanceV02,
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:scanner",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
|
||||
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
|
||||
|
||||
// Act
|
||||
var payload = SignerStatementBuilder.BuildStatementPayload(request);
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
|
||||
// Assert - Digest keys should be lowercase and sorted alphabetically
|
||||
json.Should().Contain("\"md5\"");
|
||||
json.Should().Contain("\"sha256\"");
|
||||
json.Should().Contain("\"sha512\"");
|
||||
|
||||
// Verify order: md5 < sha256 < sha512
|
||||
var md5Index = json.IndexOf("\"md5\"", StringComparison.Ordinal);
|
||||
var sha256Index = json.IndexOf("\"sha256\"", StringComparison.Ordinal);
|
||||
var sha512Index = json.IndexOf("\"sha512\"", StringComparison.Ordinal);
|
||||
|
||||
md5Index.Should().BeLessThan(sha256Index);
|
||||
sha256Index.Should().BeLessThan(sha512Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_WithExplicitStatementType_UsesProvided()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
|
||||
// Act
|
||||
var payload = SignerStatementBuilder.BuildStatementPayload(request, "https://in-toto.io/Statement/v1");
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
|
||||
// Assert
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
doc.RootElement.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatement_ReturnsInTotoStatement()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
|
||||
// Act
|
||||
var statement = SignerStatementBuilder.BuildStatement(request);
|
||||
|
||||
// Assert
|
||||
statement.Should().NotBeNull();
|
||||
statement.Type.Should().Be("https://in-toto.io/Statement/v0.1");
|
||||
statement.PredicateType.Should().Be(PredicateTypes.SlsaProvenanceV02);
|
||||
statement.Subject.Should().HaveCount(1);
|
||||
statement.Subject[0].Name.Should().Be("artifact.tar.gz");
|
||||
statement.Predicate.ValueKind.Should().Be(JsonValueKind.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_ThrowsArgumentNullException_WhenRequestIsNull()
|
||||
{
|
||||
// Act
|
||||
var act = () => SignerStatementBuilder.BuildStatementPayload(null!);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_WithStatementType_ThrowsWhenTypeIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateSigningRequest();
|
||||
|
||||
// Act
|
||||
var act = () => SignerStatementBuilder.BuildStatementPayload(request, "");
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("statementType");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.StellaOpsPromotion, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsSbom, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsVex, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsReplay, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsPolicy, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsEvidence, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsVexDecision, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsGraph, true)]
|
||||
[InlineData(PredicateTypes.SlsaProvenanceV02, true)]
|
||||
[InlineData(PredicateTypes.SlsaProvenanceV1, true)]
|
||||
[InlineData(PredicateTypes.CycloneDxSbom, true)]
|
||||
[InlineData(PredicateTypes.SpdxSbom, true)]
|
||||
[InlineData(PredicateTypes.OpenVex, true)]
|
||||
[InlineData("custom/predicate@v1", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsWellKnownPredicateType_ReturnsExpected(string? predicateType, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = SignerStatementBuilder.IsWellKnownPredicateType(predicateType!);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.SlsaProvenanceV1, "https://in-toto.io/Statement/v1")]
|
||||
[InlineData(PredicateTypes.StellaOpsPromotion, "https://in-toto.io/Statement/v1")]
|
||||
[InlineData(PredicateTypes.StellaOpsSbom, "https://in-toto.io/Statement/v1")]
|
||||
[InlineData(PredicateTypes.SlsaProvenanceV02, "https://in-toto.io/Statement/v0.1")]
|
||||
[InlineData(PredicateTypes.CycloneDxSbom, "https://in-toto.io/Statement/v0.1")]
|
||||
public void GetRecommendedStatementType_ReturnsCorrectVersion(string predicateType, string expectedStatementType)
|
||||
{
|
||||
// Act
|
||||
var result = SignerStatementBuilder.GetRecommendedStatementType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedStatementType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateTypes_IsStellaOpsType_IdentifiesStellaOpsTypes()
|
||||
{
|
||||
// Assert
|
||||
PredicateTypes.IsStellaOpsType("stella.ops/promotion@v1").Should().BeTrue();
|
||||
PredicateTypes.IsStellaOpsType("stella.ops/custom@v2").Should().BeTrue();
|
||||
PredicateTypes.IsStellaOpsType("https://slsa.dev/provenance/v1").Should().BeFalse();
|
||||
PredicateTypes.IsStellaOpsType(null!).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateTypes_IsSlsaProvenance_IdentifiesSlsaTypes()
|
||||
{
|
||||
// Assert
|
||||
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v0.2").Should().BeTrue();
|
||||
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v1").Should().BeTrue();
|
||||
PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v2").Should().BeTrue();
|
||||
PredicateTypes.IsSlsaProvenance("stella.ops/promotion@v1").Should().BeFalse();
|
||||
PredicateTypes.IsSlsaProvenance(null!).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateTypes_IsVexRelatedType_IdentifiesVexTypes()
|
||||
{
|
||||
// Assert
|
||||
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVex).Should().BeTrue();
|
||||
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVexDecision).Should().BeTrue();
|
||||
PredicateTypes.IsVexRelatedType(PredicateTypes.OpenVex).Should().BeTrue();
|
||||
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse();
|
||||
PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsGraph).Should().BeFalse();
|
||||
PredicateTypes.IsVexRelatedType(null!).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateTypes_IsReachabilityRelatedType_IdentifiesReachabilityTypes()
|
||||
{
|
||||
// Assert
|
||||
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsGraph).Should().BeTrue();
|
||||
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsReplay).Should().BeTrue();
|
||||
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsEvidence).Should().BeTrue();
|
||||
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsVex).Should().BeFalse();
|
||||
PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse();
|
||||
PredicateTypes.IsReachabilityRelatedType(null!).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateTypes_GetAllowedPredicateTypes_ReturnsAllKnownTypes()
|
||||
{
|
||||
// Act
|
||||
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes();
|
||||
|
||||
// Assert
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPromotion);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsSbom);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVex);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsReplay);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPolicy);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsEvidence);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDecision);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsGraph);
|
||||
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV02);
|
||||
allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV1);
|
||||
allowedTypes.Should().Contain(PredicateTypes.CycloneDxSbom);
|
||||
allowedTypes.Should().Contain(PredicateTypes.SpdxSbom);
|
||||
allowedTypes.Should().Contain(PredicateTypes.OpenVex);
|
||||
allowedTypes.Should().HaveCount(13);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.StellaOpsVexDecision, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsGraph, true)]
|
||||
[InlineData(PredicateTypes.StellaOpsPromotion, true)]
|
||||
[InlineData("custom/predicate@v1", false)]
|
||||
[InlineData("", false)]
|
||||
public void PredicateTypes_IsAllowedPredicateType_ReturnsExpected(string predicateType, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsAllowedPredicateType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_HandlesMultipleSubjects()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}""");
|
||||
var request = new SigningRequest(
|
||||
Subjects:
|
||||
[
|
||||
new SigningSubject("artifact1.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "hash1"
|
||||
}),
|
||||
new SigningSubject("artifact2.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "hash2"
|
||||
}),
|
||||
new SigningSubject("artifact3.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "hash3"
|
||||
})
|
||||
],
|
||||
PredicateType: PredicateTypes.SlsaProvenanceV02,
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:scanner",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
|
||||
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
|
||||
|
||||
// Act
|
||||
var payload = SignerStatementBuilder.BuildStatementPayload(request);
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
|
||||
// Assert
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStatementPayload_PreservesPredicateContent()
|
||||
{
|
||||
// Arrange
|
||||
var predicateContent = """
|
||||
{
|
||||
"builder": { "id": "https://github.com/actions" },
|
||||
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/test/repo@refs/heads/main"
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var predicate = JsonDocument.Parse(predicateContent);
|
||||
var request = new SigningRequest(
|
||||
Subjects:
|
||||
[
|
||||
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abc123"
|
||||
})
|
||||
],
|
||||
PredicateType: PredicateTypes.SlsaProvenanceV02,
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:scanner",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
|
||||
Options: new SigningOptions(SigningMode.Keyless, null, "bundle"));
|
||||
|
||||
// Act
|
||||
var payload = SignerStatementBuilder.BuildStatementPayload(request);
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
|
||||
// Assert
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var resultPredicate = doc.RootElement.GetProperty("predicate");
|
||||
resultPredicate.GetProperty("builder").GetProperty("id").GetString()
|
||||
.Should().Be("https://github.com/actions");
|
||||
resultPredicate.GetProperty("buildType").GetString()
|
||||
.Should().Be("https://github.com/Attestations/GitHubActionsWorkflow@v1");
|
||||
}
|
||||
|
||||
private static SigningRequest CreateSigningRequest()
|
||||
{
|
||||
var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}""");
|
||||
return new SigningRequest(
|
||||
Subjects:
|
||||
[
|
||||
new SigningSubject("artifact.tar.gz", new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abc123def456"
|
||||
})
|
||||
],
|
||||
PredicateType: PredicateTypes.SlsaProvenanceV02,
|
||||
Predicate: predicate,
|
||||
ScannerImageDigest: "sha256:scanner123",
|
||||
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"),
|
||||
Options: new SigningOptions(SigningMode.Keyless, 3600, "bundle"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Infrastructure.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Signing;
|
||||
|
||||
public sealed class SigningServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddDsseSigning_RegistersRequiredServices()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigning();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
provider.GetService<ISigningKeyResolver>().Should().NotBeNull();
|
||||
provider.GetService<IOptions<DsseSignerOptions>>().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigning_AllowsCustomConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigning(options =>
|
||||
{
|
||||
options.DefaultIssuer = "https://custom.issuer.io";
|
||||
options.KeylessAlgorithm = "ES384";
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
|
||||
|
||||
// Assert
|
||||
options.DefaultIssuer.Should().Be("https://custom.issuer.io");
|
||||
options.KeylessAlgorithm.Should().Be("ES384");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigningWithKms_SetsDefaultKmsKeyId()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigningWithKms("projects/my-project/locations/global/keyRings/ring/cryptoKeys/key");
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
|
||||
|
||||
// Assert
|
||||
options.DefaultKmsKeyId.Should().Be("projects/my-project/locations/global/keyRings/ring/cryptoKeys/key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigningWithKms_AllowsAdditionalConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigningWithKms(
|
||||
"default-key",
|
||||
options => options.PreferredProvider = "kms-provider");
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
|
||||
|
||||
// Assert
|
||||
options.DefaultKmsKeyId.Should().Be("default-key");
|
||||
options.PreferredProvider.Should().Be("kms-provider");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigningKeyless_SetsDefaultIssuer()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigningKeyless("https://keyless.stellaops.io");
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
|
||||
|
||||
// Assert
|
||||
options.DefaultIssuer.Should().Be("https://keyless.stellaops.io");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigningKeyless_UsesDefaultIssuerWhenNotSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigningKeyless();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DsseSignerOptions>>().Value;
|
||||
|
||||
// Assert
|
||||
options.DefaultIssuer.Should().Be("https://stellaops.io");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigning_ThrowsArgumentNullException_WhenServicesIsNull()
|
||||
{
|
||||
// Arrange
|
||||
IServiceCollection? services = null;
|
||||
|
||||
// Act
|
||||
var act = () => services!.AddDsseSigning();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("services");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigningWithKms_ThrowsArgumentException_WhenKeyIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
var act = () => services.AddDsseSigningWithKms("");
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("defaultKmsKeyId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigning_RegistersTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddDsseSigning();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
provider.GetService<TimeProvider>().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDsseSigning_DoesNotOverrideExistingTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
var customTimeProvider = new FakeTimeProvider();
|
||||
services.AddSingleton<TimeProvider>(customTimeProvider);
|
||||
|
||||
// Act
|
||||
services.AddDsseSigning();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var resolvedProvider = provider.GetRequiredService<TimeProvider>();
|
||||
|
||||
// Assert
|
||||
resolvedProvider.Should().BeSameAs(customTimeProvider);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Signer.WebService\StellaOps.Signer.WebService.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user