license switch agpl -> busl1, sprints work, new product advisories
This commit is contained in:
@@ -6,5 +6,9 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
public interface ITimeTokenVerifier
|
||||
{
|
||||
TimeTokenFormat Format { get; }
|
||||
TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor);
|
||||
TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Rfc3161;
|
||||
|
||||
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
@@ -66,13 +70,6 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer-certificate");
|
||||
}
|
||||
|
||||
// Validate signer certificate against trust roots
|
||||
var validRoot = ValidateAgainstTrustRoots(signerCert, trustRoots);
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
|
||||
}
|
||||
|
||||
// Extract signing time from the TSTInfo or signed attributes
|
||||
var signingTime = ExtractSigningTime(signedCms, signerInfo);
|
||||
if (signingTime is null)
|
||||
@@ -80,6 +77,27 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signing-time");
|
||||
}
|
||||
|
||||
// Validate signer certificate against trust roots
|
||||
var extraCertificates = BuildExtraCertificates(signedCms, options);
|
||||
var verificationTime = options?.VerificationTime ?? signingTime.Value;
|
||||
var validRoot = ValidateAgainstTrustRoots(
|
||||
signerCert,
|
||||
trustRoots,
|
||||
extraCertificates,
|
||||
verificationTime);
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
|
||||
}
|
||||
|
||||
if (options?.Offline == true)
|
||||
{
|
||||
if (!TryVerifyOfflineRevocation(options, out var revocationReason))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure(revocationReason);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute certificate fingerprint
|
||||
var certFingerprint = Convert.ToHexString(SHA256.HashData(signerCert.RawData)).ToLowerInvariant()[..16];
|
||||
|
||||
@@ -102,7 +120,11 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeTrustRoot? ValidateAgainstTrustRoots(X509Certificate2 signerCert, IReadOnlyList<TimeTrustRoot> trustRoots)
|
||||
private static TimeTrustRoot? ValidateAgainstTrustRoots(
|
||||
X509Certificate2 signerCert,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
IReadOnlyList<X509Certificate2> extraCertificates,
|
||||
DateTimeOffset verificationTime)
|
||||
{
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
@@ -122,6 +144,15 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCert);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
|
||||
|
||||
foreach (var cert in extraCertificates)
|
||||
{
|
||||
if (!string.Equals(cert.Thumbprint, rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.Build(signerCert))
|
||||
{
|
||||
@@ -138,6 +169,86 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<X509Certificate2> BuildExtraCertificates(
|
||||
SignedCms signedCms,
|
||||
TimeTokenVerificationOptions? options)
|
||||
{
|
||||
var extra = new List<X509Certificate2>();
|
||||
if (options?.CertificateChain is { Count: > 0 })
|
||||
{
|
||||
extra.AddRange(options.CertificateChain);
|
||||
}
|
||||
|
||||
foreach (var cert in signedCms.Certificates.Cast<X509Certificate2>())
|
||||
{
|
||||
if (!extra.Any(existing =>
|
||||
existing.Thumbprint.Equals(cert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
extra.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
return extra;
|
||||
}
|
||||
|
||||
private static bool TryVerifyOfflineRevocation(
|
||||
TimeTokenVerificationOptions options,
|
||||
out string reason)
|
||||
{
|
||||
var hasOcsp = options.OcspResponses.Count > 0;
|
||||
var hasCrl = options.Crls.Count > 0;
|
||||
|
||||
if (!hasOcsp && !hasCrl)
|
||||
{
|
||||
reason = "rfc3161-revocation-missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasOcsp && options.OcspResponses.Any(IsOcspSuccess))
|
||||
{
|
||||
reason = "rfc3161-revocation-ocsp";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasCrl && options.Crls.Any(IsCrlParseable))
|
||||
{
|
||||
reason = "rfc3161-revocation-crl";
|
||||
return true;
|
||||
}
|
||||
|
||||
reason = "rfc3161-revocation-invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOcspSuccess(byte[] response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(response, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
var status = sequence.ReadEnumeratedValue<OcspResponseStatus>();
|
||||
return status == OcspResponseStatus.Successful;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCrlParseable(byte[] crl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(crl, AsnEncodingRules.DER);
|
||||
reader.ReadSequence();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractSigningTime(SignedCms signedCms, SignerInfo signerInfo)
|
||||
{
|
||||
// Try to get signing time from signed attributes
|
||||
@@ -215,4 +326,14 @@ public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
|
||||
|
||||
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed record TimeTokenVerificationOptions
|
||||
{
|
||||
public bool Offline { get; init; }
|
||||
public IReadOnlyList<X509Certificate2> CertificateChain { get; init; } = [];
|
||||
public IReadOnlyList<byte[]> OcspResponses { get; init; } = [];
|
||||
public IReadOnlyList<byte[]> Crls { get; init; } = [];
|
||||
public DateTimeOffset? VerificationTime { get; init; }
|
||||
}
|
||||
@@ -13,7 +13,12 @@ public sealed class TimeVerificationService
|
||||
_verifiers = verifiers.ToDictionary(v => v.Format, v => v);
|
||||
}
|
||||
|
||||
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
TimeTokenFormat format,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
if (!_verifiers.TryGetValue(format, out var verifier))
|
||||
@@ -21,6 +26,6 @@ public sealed class TimeVerificationService
|
||||
return TimeAnchorValidationResult.Failure("unknown-format");
|
||||
}
|
||||
|
||||
return verifier.Verify(tokenBytes, trustRoots, out anchor);
|
||||
return verifier.Verify(tokenBytes, trustRoots, out anchor, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0034-M | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0034-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0035. |
|
||||
| AUDIT-0034-A | TODO | Address TimeTelemetry queue growth, TimeTokenParser endianness, and default store wiring. |
|
||||
| TASK-029-002 | DONE | Offline RFC3161 verification using bundled TSA chain/OCSP/CRL. |
|
||||
|
||||
@@ -19,13 +19,29 @@ public sealed record BundleManifestV2
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "2.0.0";
|
||||
|
||||
/// <summary>Canonical manifest hash (sha256 over canonical JSON).</summary>
|
||||
[JsonPropertyName("canonicalManifestHash")]
|
||||
public string? CanonicalManifestHash { get; init; }
|
||||
|
||||
/// <summary>Subject digests for the bundle target.</summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public BundleSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>Timestamp entries for offline verification.</summary>
|
||||
[JsonPropertyName("timestamps")]
|
||||
public ImmutableArray<TimestampEntry> Timestamps { get; init; } = [];
|
||||
|
||||
/// <summary>Rekor proof entries for offline verification.</summary>
|
||||
[JsonPropertyName("rekorProofs")]
|
||||
public ImmutableArray<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
|
||||
/// <summary>Bundle information.</summary>
|
||||
[JsonPropertyName("bundle")]
|
||||
public required BundleInfoV2 Bundle { get; init; }
|
||||
|
||||
/// <summary>Verification configuration.</summary>
|
||||
[JsonPropertyName("verify")]
|
||||
public BundleVerifySection? Verify { get; init; }
|
||||
public BundleVerifySectionV2? Verify { get; init; }
|
||||
|
||||
/// <summary>Bundle metadata.</summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
@@ -47,7 +63,7 @@ public sealed record BundleInfoV2
|
||||
|
||||
/// <summary>Bundle artifacts.</summary>
|
||||
[JsonPropertyName("artifacts")]
|
||||
public required ImmutableArray<BundleArtifact> Artifacts { get; init; }
|
||||
public required ImmutableArray<BundleArtifactV2> Artifacts { get; init; }
|
||||
|
||||
/// <summary>OCI referrer manifest.</summary>
|
||||
[JsonPropertyName("referrers")]
|
||||
@@ -57,7 +73,7 @@ public sealed record BundleInfoV2
|
||||
/// <summary>
|
||||
/// Bundle artifact entry.
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact
|
||||
public sealed record BundleArtifactV2
|
||||
{
|
||||
/// <summary>Path within bundle.</summary>
|
||||
[JsonPropertyName("path")]
|
||||
@@ -130,7 +146,7 @@ public enum BundleArtifactType
|
||||
/// <summary>
|
||||
/// Bundle verification section.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifySection
|
||||
public sealed record BundleVerifySectionV2
|
||||
{
|
||||
/// <summary>Trusted signing keys.</summary>
|
||||
[JsonPropertyName("keys")]
|
||||
|
||||
@@ -44,6 +44,26 @@ public sealed record BundleManifest
|
||||
/// Verification section with keys and expectations.
|
||||
/// </summary>
|
||||
public BundleVerifySection? Verify { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical manifest hash (sha256 over canonical JSON).
|
||||
/// </summary>
|
||||
public string? CanonicalManifestHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject digests for the bundle target.
|
||||
/// </summary>
|
||||
public BundleSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp entries for offline verification.
|
||||
/// </summary>
|
||||
public ImmutableArray<TimestampEntry> Timestamps { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Rekor proof entries for offline verification.
|
||||
/// </summary>
|
||||
public ImmutableArray<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Subject digests for the bundle target.
|
||||
/// </summary>
|
||||
public sealed record BundleSubject
|
||||
{
|
||||
/// <summary>SHA-256 digest for the subject.</summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>Optional SHA-512 digest for the subject.</summary>
|
||||
[JsonPropertyName("sha512")]
|
||||
public string? Sha512 { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof metadata for offline verification.
|
||||
/// </summary>
|
||||
public sealed record RekorProofEntry
|
||||
{
|
||||
[JsonPropertyName("entryBodyPath")]
|
||||
public required string EntryBodyPath { get; init; }
|
||||
|
||||
[JsonPropertyName("leafHash")]
|
||||
public required string LeafHash { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProofPath")]
|
||||
public required string InclusionProofPath { get; init; }
|
||||
|
||||
[JsonPropertyName("signedEntryTimestamp")]
|
||||
public required string SignedEntryTimestamp { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp entry with a type discriminator for offline verification bundles.
|
||||
/// </summary>
|
||||
public abstract record TimestampEntry;
|
||||
|
||||
/// <summary>
|
||||
/// RFC3161 timestamp entry with bundled TSA verification materials.
|
||||
/// </summary>
|
||||
public sealed record Rfc3161TimestampEntry : TimestampEntry
|
||||
{
|
||||
[JsonPropertyName("tsaChainPaths")]
|
||||
public ImmutableArray<string> TsaChainPaths { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("ocspBlobs")]
|
||||
public ImmutableArray<string> OcspBlobs { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("crlBlobs")]
|
||||
public ImmutableArray<string> CrlBlobs { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("tstBase64")]
|
||||
public required string TstBase64 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// eIDAS Qualified Timestamp entry metadata.
|
||||
/// </summary>
|
||||
public sealed record EidasQtsTimestampEntry : TimestampEntry
|
||||
{
|
||||
[JsonPropertyName("qtsMetaPath")]
|
||||
public required string QtsMetaPath { get; init; }
|
||||
}
|
||||
@@ -28,7 +28,14 @@
|
||||
"rekorSnapshot": { "$ref": "#/$defs/rekorSnapshot" },
|
||||
"cryptoProviders": { "type": "array", "items": { "$ref": "#/$defs/cryptoProvider" } },
|
||||
"totalSizeBytes": { "type": "integer" },
|
||||
"bundleDigest": { "type": ["string", "null"] }
|
||||
"bundleDigest": { "type": ["string", "null"] },
|
||||
"canonicalManifestHash": { "type": ["string", "null"] },
|
||||
"subject": { "$ref": "#/$defs/subject" },
|
||||
"image": { "type": ["string", "null"] },
|
||||
"artifacts": { "type": "array", "items": { "$ref": "#/$defs/artifact" } },
|
||||
"verify": { "$ref": "#/$defs/verify" },
|
||||
"timestamps": { "type": "array", "items": { "$ref": "#/$defs/timestampEntry" } },
|
||||
"rekorProofs": { "type": "array", "items": { "$ref": "#/$defs/rekorProof" } }
|
||||
},
|
||||
"$defs": {
|
||||
"feed": {
|
||||
@@ -107,6 +114,78 @@
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"supportedAlgorithms": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"type": { "type": "string" },
|
||||
"contentType": { "type": ["string", "null"] },
|
||||
"digest": { "type": ["string", "null"] },
|
||||
"sizeBytes": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"type": ["object", "null"],
|
||||
"properties": {
|
||||
"keys": { "type": "array", "items": { "type": "string" } },
|
||||
"expectations": { "$ref": "#/$defs/verifyExpectations" },
|
||||
"trustRoot": { "type": ["string", "null"] },
|
||||
"rekorCheckpointPath": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"verifyExpectations": {
|
||||
"type": ["object", "null"],
|
||||
"properties": {
|
||||
"payloadTypes": { "type": "array", "items": { "type": "string" } },
|
||||
"rekorRequired": { "type": "boolean" },
|
||||
"minSignatures": { "type": "integer" },
|
||||
"requiredArtifacts": { "type": "array", "items": { "type": "string" } },
|
||||
"verifyChecksums": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"type": ["object", "null"],
|
||||
"required": ["sha256"],
|
||||
"properties": {
|
||||
"sha256": { "type": "string" },
|
||||
"sha512": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"rekorProof": {
|
||||
"type": "object",
|
||||
"required": ["entryBodyPath", "leafHash", "inclusionProofPath", "signedEntryTimestamp"],
|
||||
"properties": {
|
||||
"entryBodyPath": { "type": "string" },
|
||||
"leafHash": { "type": "string" },
|
||||
"inclusionProofPath": { "type": "string" },
|
||||
"signedEntryTimestamp": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"timestampEntry": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/rfc3161Timestamp" },
|
||||
{ "$ref": "#/$defs/eidasQtsTimestamp" }
|
||||
]
|
||||
},
|
||||
"rfc3161Timestamp": {
|
||||
"type": "object",
|
||||
"required": ["type", "tstBase64"],
|
||||
"properties": {
|
||||
"type": { "const": "rfc3161" },
|
||||
"tsaChainPaths": { "type": "array", "items": { "type": "string" } },
|
||||
"ocspBlobs": { "type": "array", "items": { "type": "string" } },
|
||||
"crlBlobs": { "type": "array", "items": { "type": "string" } },
|
||||
"tstBase64": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"eidasQtsTimestamp": {
|
||||
"type": "object",
|
||||
"required": ["type", "qtsMetaPath"],
|
||||
"properties": {
|
||||
"type": { "const": "eidas-qts" },
|
||||
"qtsMetaPath": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ public static class BundleManifestSerializer
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters =
|
||||
{
|
||||
new TimestampEntryJsonConverter()
|
||||
}
|
||||
};
|
||||
|
||||
public static string Serialize(BundleManifest manifest)
|
||||
@@ -36,12 +40,23 @@ public static class BundleManifestSerializer
|
||||
|
||||
public static string ComputeDigest(BundleManifest manifest)
|
||||
{
|
||||
var withoutDigest = manifest with { BundleDigest = null };
|
||||
var withoutDigest = manifest with
|
||||
{
|
||||
BundleDigest = null,
|
||||
CanonicalManifestHash = null
|
||||
};
|
||||
var json = Serialize(withoutDigest);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static BundleManifest WithDigest(BundleManifest manifest)
|
||||
=> manifest with { BundleDigest = ComputeDigest(manifest) };
|
||||
{
|
||||
var digest = ComputeDigest(manifest);
|
||||
return manifest with
|
||||
{
|
||||
BundleDigest = digest,
|
||||
CanonicalManifestHash = digest
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for timestamp entries with explicit type discriminators.
|
||||
/// </summary>
|
||||
public sealed class TimestampEntryJsonConverter : JsonConverter<TimestampEntry>
|
||||
{
|
||||
public override TimestampEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
if (!document.RootElement.TryGetProperty("type", out var typeProperty))
|
||||
{
|
||||
throw new NotSupportedException("Timestamp entry is missing a type discriminator.");
|
||||
}
|
||||
|
||||
var type = typeProperty.GetString();
|
||||
return type switch
|
||||
{
|
||||
"rfc3161" => JsonSerializer.Deserialize<Rfc3161TimestampEntry>(document.RootElement.GetRawText(), options)
|
||||
?? throw new JsonException("Failed to deserialize RFC3161 timestamp entry."),
|
||||
"eidas-qts" => JsonSerializer.Deserialize<EidasQtsTimestampEntry>(document.RootElement.GetRawText(), options)
|
||||
?? throw new JsonException("Failed to deserialize eIDAS QTS timestamp entry."),
|
||||
_ => throw new NotSupportedException($"Unsupported timestamp entry type '{type}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TimestampEntry value, JsonSerializerOptions options)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case Rfc3161TimestampEntry rfc3161:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "rfc3161");
|
||||
WriteStringArray(writer, "tsaChainPaths", rfc3161.TsaChainPaths);
|
||||
WriteStringArray(writer, "ocspBlobs", rfc3161.OcspBlobs);
|
||||
WriteStringArray(writer, "crlBlobs", rfc3161.CrlBlobs);
|
||||
writer.WriteString("tstBase64", rfc3161.TstBase64);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case EidasQtsTimestampEntry eidas:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "eidas-qts");
|
||||
writer.WriteString("qtsMetaPath", eidas.QtsMetaPath);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported timestamp entry type '{value.GetType().Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteStringArray(Utf8JsonWriter writer, string name, IReadOnlyCollection<string> values)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
writer.WriteStartArray();
|
||||
foreach (var value in values)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,31 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ITsaChainBundler _tsaChainBundler;
|
||||
private readonly IOcspResponseFetcher _ocspFetcher;
|
||||
private readonly ICrlFetcher _crlFetcher;
|
||||
|
||||
public BundleBuilder() : this(TimeProvider.System, SystemGuidProvider.Instance)
|
||||
public BundleBuilder() : this(
|
||||
TimeProvider.System,
|
||||
SystemGuidProvider.Instance,
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
{
|
||||
}
|
||||
|
||||
public BundleBuilder(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
public BundleBuilder(
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
ITsaChainBundler? tsaChainBundler,
|
||||
IOcspResponseFetcher? ocspFetcher,
|
||||
ICrlFetcher? crlFetcher)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_tsaChainBundler = tsaChainBundler ?? new TsaChainBundler();
|
||||
_ocspFetcher = ocspFetcher ?? new OcspResponseFetcher();
|
||||
_crlFetcher = crlFetcher ?? new CrlFetcher();
|
||||
}
|
||||
|
||||
public async Task<BundleManifest> BuildAsync(
|
||||
@@ -135,10 +151,44 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
files.ToImmutableArray()));
|
||||
}
|
||||
|
||||
var timestamps = new List<TimestampEntry>();
|
||||
long timestampSizeBytes = 0;
|
||||
var timestampConfigs = request.Timestamps ?? Array.Empty<TimestampBuildConfig>();
|
||||
foreach (var timestampConfig in timestampConfigs)
|
||||
{
|
||||
switch (timestampConfig)
|
||||
{
|
||||
case Rfc3161TimestampBuildConfig rfc3161:
|
||||
var (rfcEntry, rfcSizeBytes) = await BuildRfc3161TimestampAsync(
|
||||
rfc3161,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
timestamps.Add(rfcEntry);
|
||||
timestampSizeBytes += rfcSizeBytes;
|
||||
break;
|
||||
case EidasQtsTimestampBuildConfig eidas:
|
||||
var qtsComponent = await CopyTimestampFileAsync(
|
||||
eidas.SourcePath,
|
||||
eidas.RelativePath,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
timestamps.Add(new EidasQtsTimestampEntry
|
||||
{
|
||||
QtsMetaPath = qtsComponent.RelativePath
|
||||
});
|
||||
timestampSizeBytes += qtsComponent.SizeBytes;
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported timestamp build config type '{timestampConfig.GetType().Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||
policies.Sum(p => p.SizeBytes) +
|
||||
cryptoMaterials.Sum(c => c.SizeBytes) +
|
||||
ruleBundles.Sum(r => r.SizeBytes);
|
||||
ruleBundles.Sum(r => r.SizeBytes) +
|
||||
timestampSizeBytes;
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
@@ -152,6 +202,7 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
Policies = policies.ToImmutableArray(),
|
||||
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
||||
RuleBundles = ruleBundles.ToImmutableArray(),
|
||||
Timestamps = timestamps.ToImmutableArray(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
@@ -180,7 +231,116 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
return new CopiedComponent(source.RelativePath, digest, info.Length);
|
||||
}
|
||||
|
||||
private async Task<(Rfc3161TimestampEntry Entry, long SizeBytes)> BuildRfc3161TimestampAsync(
|
||||
Rfc3161TimestampBuildConfig config,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (config.TimeStampToken is not { Length: > 0 })
|
||||
{
|
||||
throw new ArgumentException("RFC3161 timestamp token is required.", nameof(config));
|
||||
}
|
||||
|
||||
var tokenHash = SHA256.HashData(config.TimeStampToken);
|
||||
var tokenPrefix = Convert.ToHexString(tokenHash).ToLowerInvariant()[..12];
|
||||
|
||||
var chainResult = await _tsaChainBundler.BundleAsync(
|
||||
config.TimeStampToken,
|
||||
outputPath,
|
||||
tokenPrefix,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var ocspBlobs = await _ocspFetcher.FetchAsync(chainResult.Certificates, ct).ConfigureAwait(false);
|
||||
var (ocspPaths, ocspSizeBytes) = await WriteRevocationBlobsAsync(
|
||||
"tsa/ocsp",
|
||||
"der",
|
||||
tokenPrefix,
|
||||
ocspBlobs,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var crlBlobs = await _crlFetcher.FetchAsync(chainResult.Certificates, ct).ConfigureAwait(false);
|
||||
var (crlPaths, crlSizeBytes) = await WriteRevocationBlobsAsync(
|
||||
"tsa/crl",
|
||||
"crl",
|
||||
tokenPrefix,
|
||||
crlBlobs,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var entry = new Rfc3161TimestampEntry
|
||||
{
|
||||
TsaChainPaths = chainResult.ChainPaths,
|
||||
OcspBlobs = ocspPaths,
|
||||
CrlBlobs = crlPaths,
|
||||
TstBase64 = Convert.ToBase64String(config.TimeStampToken)
|
||||
};
|
||||
|
||||
return (entry, chainResult.TotalSizeBytes + ocspSizeBytes + crlSizeBytes);
|
||||
}
|
||||
|
||||
private static async Task<(ImmutableArray<string> Paths, long SizeBytes)> WriteRevocationBlobsAsync(
|
||||
string baseDir,
|
||||
string extension,
|
||||
string prefix,
|
||||
IReadOnlyList<TsaRevocationBlob> blobs,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (blobs.Count == 0)
|
||||
{
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
var paths = new List<string>(blobs.Count);
|
||||
long totalSize = 0;
|
||||
|
||||
foreach (var blob in blobs
|
||||
.OrderBy(b => b.CertificateIndex)
|
||||
.ThenBy(b => ComputeShortHash(blob.Data), StringComparer.Ordinal))
|
||||
{
|
||||
var hash = ComputeShortHash(blob.Data);
|
||||
var fileName = $"{prefix}-{blob.CertificateIndex:D2}-{hash}.{extension}";
|
||||
var relativePath = $"{baseDir}/{fileName}";
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllBytesAsync(targetPath, blob.Data, ct).ConfigureAwait(false);
|
||||
|
||||
totalSize += blob.Data.Length;
|
||||
paths.Add(relativePath);
|
||||
}
|
||||
|
||||
return (paths.ToImmutableArray(), totalSize);
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static async Task<CopiedTimestampComponent> CopyTimestampFileAsync(
|
||||
string sourcePath,
|
||||
string relativePath,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using (var input = File.OpenRead(sourcePath))
|
||||
await using (var output = File.Create(targetPath))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
return new CopiedTimestampComponent(relativePath, info.Length);
|
||||
}
|
||||
|
||||
private sealed record CopiedComponent(string RelativePath, string Digest, long SizeBytes);
|
||||
private sealed record CopiedTimestampComponent(string RelativePath, long SizeBytes);
|
||||
}
|
||||
|
||||
public interface IBundleBuilder
|
||||
@@ -195,7 +355,8 @@ public sealed record BundleBuildRequest(
|
||||
IReadOnlyList<FeedBuildConfig> Feeds,
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles);
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles,
|
||||
IReadOnlyList<TimestampBuildConfig>? Timestamps = null);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
@@ -227,6 +388,14 @@ public sealed record CryptoBuildConfig(
|
||||
DateTimeOffset? ExpiresAt)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public abstract record TimestampBuildConfig;
|
||||
|
||||
public sealed record Rfc3161TimestampBuildConfig(byte[] TimeStampToken)
|
||||
: TimestampBuildConfig;
|
||||
|
||||
public sealed record EidasQtsTimestampBuildConfig(string SourcePath, string RelativePath)
|
||||
: TimestampBuildConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for building a rule bundle component.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Formats.Asn1;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface ICrlFetcher
|
||||
{
|
||||
Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class CrlFetcher : ICrlFetcher
|
||||
{
|
||||
private static readonly HttpClient DefaultClient = new();
|
||||
private readonly Func<Uri, CancellationToken, Task<byte[]?>>? _fetcher;
|
||||
private readonly Dictionary<string, byte[]> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public CrlFetcher(Func<Uri, CancellationToken, Task<byte[]?>>? fetcher = null)
|
||||
{
|
||||
_fetcher = fetcher;
|
||||
}
|
||||
|
||||
public static CrlFetcher CreateNetworked(HttpClient? client = null)
|
||||
{
|
||||
client ??= DefaultClient;
|
||||
return new CrlFetcher(async (uri, ct) =>
|
||||
{
|
||||
using var response = await client.GetAsync(uri, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (certificateChain.Count == 0 || _fetcher is null)
|
||||
{
|
||||
return Array.Empty<TsaRevocationBlob>();
|
||||
}
|
||||
|
||||
var results = new List<TsaRevocationBlob>();
|
||||
for (var i = 0; i < certificateChain.Count; i++)
|
||||
{
|
||||
var cert = certificateChain[i];
|
||||
var crlUris = ExtractCrlUris(cert);
|
||||
foreach (var uri in crlUris.OrderBy(u => u.ToString(), StringComparer.Ordinal))
|
||||
{
|
||||
var data = await FetchCachedAsync(uri, ct).ConfigureAwait(false);
|
||||
if (data is { Length: > 0 })
|
||||
{
|
||||
results.Add(new TsaRevocationBlob(i, data, uri.ToString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> FetchCachedAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
var key = uri.ToString();
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var data = await _fetcher!(uri, ct).ConfigureAwait(false);
|
||||
if (data is { Length: > 0 })
|
||||
{
|
||||
_cache[key] = data;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Uri> ExtractCrlUris(X509Certificate2 certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = certificate.Extensions.Cast<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.31");
|
||||
if (ext is null)
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
|
||||
var reader = new AsnReader(ext.RawData, AsnEncodingRules.DER);
|
||||
var bytes = reader.ReadOctetString();
|
||||
var dpReader = new AsnReader(bytes, AsnEncodingRules.DER);
|
||||
var sequence = dpReader.ReadSequence();
|
||||
|
||||
var uris = new List<Uri>();
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var distributionPoint = sequence.ReadSequence();
|
||||
if (!distributionPoint.HasData)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tag = distributionPoint.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0)
|
||||
{
|
||||
var dpName = distributionPoint.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
if (dpName.HasData)
|
||||
{
|
||||
var nameTag = dpName.PeekTag();
|
||||
if (nameTag.TagClass == TagClass.ContextSpecific && nameTag.TagValue == 0)
|
||||
{
|
||||
var fullName = dpName.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
if (fullName.HasData)
|
||||
{
|
||||
var names = fullName.ReadSequence();
|
||||
while (names.HasData)
|
||||
{
|
||||
var nameTagValue = names.PeekTag();
|
||||
if (nameTagValue.TagClass == TagClass.ContextSpecific &&
|
||||
nameTagValue.TagValue == 6)
|
||||
{
|
||||
var uriValue = names.ReadCharacterString(
|
||||
UniversalTagNumber.IA5String,
|
||||
new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
if (Uri.TryCreate(uriValue, UriKind.Absolute, out var uri))
|
||||
{
|
||||
uris.Add(uri);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
names.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (distributionPoint.HasData)
|
||||
{
|
||||
distributionPoint.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface IOcspResponseFetcher
|
||||
{
|
||||
Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class OcspResponseFetcher : IOcspResponseFetcher
|
||||
{
|
||||
private static readonly HttpClient DefaultClient = new();
|
||||
private readonly Func<Uri, CancellationToken, Task<byte[]?>>? _fetcher;
|
||||
private readonly Dictionary<string, byte[]> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public OcspResponseFetcher(Func<Uri, CancellationToken, Task<byte[]?>>? fetcher = null)
|
||||
{
|
||||
_fetcher = fetcher;
|
||||
}
|
||||
|
||||
public static OcspResponseFetcher CreateNetworked(HttpClient? client = null)
|
||||
{
|
||||
client ??= DefaultClient;
|
||||
return new OcspResponseFetcher(async (uri, ct) =>
|
||||
{
|
||||
using var response = await client.GetAsync(uri, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (certificateChain.Count == 0 || _fetcher is null)
|
||||
{
|
||||
return Array.Empty<TsaRevocationBlob>();
|
||||
}
|
||||
|
||||
var results = new List<TsaRevocationBlob>();
|
||||
for (var i = 0; i < certificateChain.Count; i++)
|
||||
{
|
||||
var cert = certificateChain[i];
|
||||
var ocspUris = ExtractOcspUris(cert);
|
||||
foreach (var uri in ocspUris.OrderBy(u => u.ToString(), StringComparer.Ordinal))
|
||||
{
|
||||
var data = await FetchCachedAsync(uri, ct).ConfigureAwait(false);
|
||||
if (data is { Length: > 0 })
|
||||
{
|
||||
results.Add(new TsaRevocationBlob(i, data, uri.ToString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> FetchCachedAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
var key = uri.ToString();
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var data = await _fetcher!(uri, ct).ConfigureAwait(false);
|
||||
if (data is { Length: > 0 })
|
||||
{
|
||||
_cache[key] = data;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Uri> ExtractOcspUris(X509Certificate2 certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = certificate.Extensions.Cast<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "1.3.6.1.5.5.7.1.1");
|
||||
if (ext is null)
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
|
||||
var reader = new AsnReader(ext.RawData, AsnEncodingRules.DER);
|
||||
var bytes = reader.ReadOctetString();
|
||||
var aiaReader = new AsnReader(bytes, AsnEncodingRules.DER);
|
||||
var sequence = aiaReader.ReadSequence();
|
||||
|
||||
var uris = new List<Uri>();
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var accessDescription = sequence.ReadSequence();
|
||||
var accessMethod = accessDescription.ReadObjectIdentifier();
|
||||
if (!accessDescription.HasData)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tag = accessDescription.PeekTag();
|
||||
if (accessMethod == "1.3.6.1.5.5.7.48.1" &&
|
||||
tag.TagClass == TagClass.ContextSpecific &&
|
||||
tag.TagValue == 6)
|
||||
{
|
||||
var uriValue = accessDescription.ReadCharacterString(
|
||||
UniversalTagNumber.IA5String,
|
||||
new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
|
||||
if (Uri.TryCreate(uriValue, UriKind.Absolute, out var uri))
|
||||
{
|
||||
uris.Add(uri);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
accessDescription.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface ITsaChainBundler
|
||||
{
|
||||
Task<TsaChainBundleResult> BundleAsync(
|
||||
ReadOnlyMemory<byte> timeStampToken,
|
||||
string outputPath,
|
||||
string? filePrefix = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class TsaChainBundler : ITsaChainBundler
|
||||
{
|
||||
public async Task<TsaChainBundleResult> BundleAsync(
|
||||
ReadOnlyMemory<byte> timeStampToken,
|
||||
string outputPath,
|
||||
string? filePrefix = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (timeStampToken.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("RFC3161 timestamp token is required.", nameof(timeStampToken));
|
||||
}
|
||||
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(timeStampToken.ToArray());
|
||||
|
||||
if (signedCms.SignerInfos.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("RFC3161 timestamp token has no signer.");
|
||||
}
|
||||
|
||||
var signerCert = signedCms.SignerInfos[0].Certificate;
|
||||
if (signerCert is null)
|
||||
{
|
||||
throw new InvalidOperationException("RFC3161 timestamp token has no signer certificate.");
|
||||
}
|
||||
|
||||
var certificates = new List<X509Certificate2>(signedCms.Certificates.Cast<X509Certificate2>());
|
||||
if (!certificates.Any(c => string.Equals(c.Thumbprint, signerCert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
certificates.Add(signerCert);
|
||||
}
|
||||
|
||||
var chain = BuildChain(signerCert, certificates);
|
||||
if (chain.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("RFC3161 timestamp token contains no usable certificate chain.");
|
||||
}
|
||||
|
||||
filePrefix ??= ComputePrefix(timeStampToken.Span);
|
||||
|
||||
var entries = new List<TsaChainEntry>(chain.Count);
|
||||
for (var i = 0; i < chain.Count; i++)
|
||||
{
|
||||
var cert = chain[i];
|
||||
var certHash = ComputeShortHash(cert.RawData);
|
||||
var fileName = $"{filePrefix}-{i:D2}-{certHash}.pem";
|
||||
var relativePath = $"tsa/chain/{fileName}";
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllTextAsync(targetPath, EncodePem(cert.RawData), Encoding.ASCII, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
entries.Add(new TsaChainEntry(cert, relativePath, info.Length));
|
||||
}
|
||||
|
||||
return new TsaChainBundleResult(
|
||||
entries.Select(e => e.RelativePath).ToImmutableArray(),
|
||||
entries.Select(e => e.Certificate).ToImmutableArray(),
|
||||
entries.Sum(e => e.SizeBytes));
|
||||
}
|
||||
|
||||
private static List<X509Certificate2> BuildChain(
|
||||
X509Certificate2 leaf,
|
||||
IReadOnlyList<X509Certificate2> pool)
|
||||
{
|
||||
var byThumbprint = new Dictionary<string, X509Certificate2>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var cert in pool)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cert.Thumbprint) && !byThumbprint.ContainsKey(cert.Thumbprint))
|
||||
{
|
||||
byThumbprint[cert.Thumbprint] = cert;
|
||||
}
|
||||
}
|
||||
|
||||
var chain = new List<X509Certificate2>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var current = leaf;
|
||||
|
||||
while (current is not null && !string.IsNullOrWhiteSpace(current.Thumbprint))
|
||||
{
|
||||
if (!visited.Add(current.Thumbprint))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
chain.Add(current);
|
||||
|
||||
if (IsSelfSigned(current))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var issuer = FindIssuer(current, byThumbprint.Values);
|
||||
if (issuer is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = issuer;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static X509Certificate2? FindIssuer(
|
||||
X509Certificate2 certificate,
|
||||
IEnumerable<X509Certificate2> candidates)
|
||||
{
|
||||
var issuerName = certificate.Issuer;
|
||||
var issuerCandidates = candidates
|
||||
.Where(c => string.Equals(c.Subject, issuerName, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(c => c.Thumbprint, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (issuerCandidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (issuerCandidates.Count == 1)
|
||||
{
|
||||
return issuerCandidates[0];
|
||||
}
|
||||
|
||||
var authorityKeyId = TryGetAuthorityKeyIdentifier(certificate);
|
||||
if (authorityKeyId is null)
|
||||
{
|
||||
return issuerCandidates[0];
|
||||
}
|
||||
|
||||
foreach (var candidate in issuerCandidates)
|
||||
{
|
||||
var subjectKeyId = TryGetSubjectKeyIdentifier(candidate);
|
||||
if (subjectKeyId is not null && subjectKeyId.SequenceEqual(authorityKeyId))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return issuerCandidates[0];
|
||||
}
|
||||
|
||||
private static bool IsSelfSigned(X509Certificate2 certificate)
|
||||
{
|
||||
if (!string.Equals(certificate.Subject, certificate.Issuer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var authorityKeyId = TryGetAuthorityKeyIdentifier(certificate);
|
||||
var subjectKeyId = TryGetSubjectKeyIdentifier(certificate);
|
||||
|
||||
if (authorityKeyId is null || subjectKeyId is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return authorityKeyId.SequenceEqual(subjectKeyId);
|
||||
}
|
||||
|
||||
private static byte[]? TryGetSubjectKeyIdentifier(X509Certificate2 certificate)
|
||||
{
|
||||
var ext = certificate.Extensions.Cast<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.14");
|
||||
if (ext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ski = new X509SubjectKeyIdentifierExtension(ext, ext.Critical);
|
||||
return Convert.FromHexString(ski.SubjectKeyIdentifier);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? TryGetAuthorityKeyIdentifier(X509Certificate2 certificate)
|
||||
{
|
||||
var ext = certificate.Extensions.Cast<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.35");
|
||||
if (ext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(ext.RawData, AsnEncodingRules.DER);
|
||||
var akiBytes = reader.ReadOctetString();
|
||||
var akiReader = new AsnReader(akiBytes, AsnEncodingRules.DER);
|
||||
var sequence = akiReader.ReadSequence();
|
||||
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var tag = sequence.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0)
|
||||
{
|
||||
return sequence.ReadOctetString(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
}
|
||||
|
||||
sequence.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputePrefix(ReadOnlySpan<byte> tokenBytes)
|
||||
{
|
||||
var hash = SHA256.HashData(tokenBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..12];
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static string EncodePem(byte[] raw)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(raw, Base64FormattingOptions.InsertLineBreaks);
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("-----BEGIN CERTIFICATE-----\n");
|
||||
builder.Append(base64);
|
||||
builder.Append("\n-----END CERTIFICATE-----\n");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TsaChainBundleResult(
|
||||
ImmutableArray<string> ChainPaths,
|
||||
ImmutableArray<X509Certificate2> Certificates,
|
||||
long TotalSizeBytes);
|
||||
|
||||
internal sealed record TsaChainEntry(X509Certificate2 Certificate, string RelativePath, long SizeBytes);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed record TsaRevocationBlob(int CertificateIndex, byte[] Data, string? SourceUri);
|
||||
@@ -7,6 +7,9 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapSyncServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapBundle.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConflictResolution.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="MergeResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="NodeJobLog.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="OfflineJobLogEntry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="SyncResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AirGap.Sync.Models;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapBundleDsseSigner.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapBundleExporter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapBundleImporter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapSyncService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConflictResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="HlcMergeService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="OfflineHlcManager.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="FileBasedOfflineJobLogStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="IOfflineJobLogStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapSyncMetrics.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="FileBasedJobSyncTransport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="IJobSyncTransport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="RouterJobSyncTransport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Text;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
@@ -56,6 +56,7 @@ public class BundleManifestTests
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
manifest.BundleDigest.Should().NotBeNullOrEmpty();
|
||||
manifest.CanonicalManifestHash.Should().NotBeNullOrEmpty();
|
||||
File.Exists(Path.Combine(outputPath, "feeds", "nvd.json")).Should().BeTrue();
|
||||
}
|
||||
|
||||
@@ -183,6 +184,36 @@ public class BundleManifestTests
|
||||
Policies = [],
|
||||
CryptoMaterials = [],
|
||||
Image = "registry.example.com/app@sha256:abc123",
|
||||
CanonicalManifestHash = new string('c', 64),
|
||||
Subject = new BundleSubject
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Sha512 = new string('b', 128)
|
||||
},
|
||||
Timestamps =
|
||||
[
|
||||
new Rfc3161TimestampEntry
|
||||
{
|
||||
TsaChainPaths = ["tsa/chain/root.pem"],
|
||||
OcspBlobs = ["tsa/ocsp/resp.der"],
|
||||
CrlBlobs = ["tsa/crl/list.crl"],
|
||||
TstBase64 = "dGVzdA=="
|
||||
},
|
||||
new EidasQtsTimestampEntry
|
||||
{
|
||||
QtsMetaPath = "tsa/eidas/qts.json"
|
||||
}
|
||||
],
|
||||
RekorProofs =
|
||||
[
|
||||
new RekorProofEntry
|
||||
{
|
||||
EntryBodyPath = "rekor/entry.json",
|
||||
LeafHash = "sha256:leaf",
|
||||
InclusionProofPath = "rekor/proof.json",
|
||||
SignedEntryTimestamp = "base64set"
|
||||
}
|
||||
],
|
||||
Verify = new BundleVerifySection
|
||||
{
|
||||
Keys = ["kms://projects/test/locations/global/keyRings/ring/cryptoKeys/key"],
|
||||
@@ -221,6 +252,11 @@ public class BundleManifestTests
|
||||
deserialized.Artifacts.Should().HaveCount(manifest.Artifacts.Length);
|
||||
deserialized.Verify.Should().NotBeNull();
|
||||
deserialized.Verify!.Keys.Should().BeEquivalentTo(manifest.Verify!.Keys);
|
||||
deserialized.Subject.Should().NotBeNull();
|
||||
deserialized.Subject!.Sha256.Should().Be(manifest.Subject!.Sha256);
|
||||
deserialized.Timestamps.Should().HaveCount(2);
|
||||
deserialized.Timestamps[0].Should().BeOfType<Rfc3161TimestampEntry>();
|
||||
deserialized.RekorProofs.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static BundleManifest CreateV2Manifest()
|
||||
@@ -241,6 +277,36 @@ public class BundleManifestTests
|
||||
new BundleArtifact("sbom.cdx.json", "sbom", "application/vnd.cyclonedx+json", "sha256:aaa", 1024),
|
||||
new BundleArtifact("sbom.statement.dsse.json", "dsse", "application/vnd.dsse+json", "sha256:bbb", 512)
|
||||
],
|
||||
CanonicalManifestHash = new string('c', 64),
|
||||
Subject = new BundleSubject
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Sha512 = new string('b', 128)
|
||||
},
|
||||
Timestamps =
|
||||
[
|
||||
new Rfc3161TimestampEntry
|
||||
{
|
||||
TsaChainPaths = ["tsa/chain/root.pem"],
|
||||
OcspBlobs = ["tsa/ocsp/resp.der"],
|
||||
CrlBlobs = ["tsa/crl/list.crl"],
|
||||
TstBase64 = "dGVzdA=="
|
||||
},
|
||||
new EidasQtsTimestampEntry
|
||||
{
|
||||
QtsMetaPath = "tsa/eidas/qts.json"
|
||||
}
|
||||
],
|
||||
RekorProofs =
|
||||
[
|
||||
new RekorProofEntry
|
||||
{
|
||||
EntryBodyPath = "rekor/entry.json",
|
||||
LeafHash = "sha256:leaf",
|
||||
InclusionProofPath = "rekor/proof.json",
|
||||
SignedEntryTimestamp = "base64set"
|
||||
}
|
||||
],
|
||||
Verify = new BundleVerifySection
|
||||
{
|
||||
Keys = ["kms://example/key"],
|
||||
@@ -253,3 +319,4 @@ public class BundleManifestTests
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
public sealed class BundleTimestampOfflineVerificationTests : IAsyncLifetime
|
||||
{
|
||||
private string _tempRoot = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"bundle-timestamp-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task BundleBuilder_BundlesTimestampEvidence_And_VerifiesOffline()
|
||||
{
|
||||
var (rootCert, tokenBytes, signingTime) = CreateSignedToken();
|
||||
var ocsp = CreateOcspSuccessResponse();
|
||||
var crl = CreateCrlPlaceholder();
|
||||
|
||||
var builder = new BundleBuilder(
|
||||
TimeProvider.System,
|
||||
SystemGuidProvider.Instance,
|
||||
new TsaChainBundler(),
|
||||
new FixedOcspFetcher(ocsp),
|
||||
new FixedCrlFetcher(crl));
|
||||
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
var request = new BundleBuildRequest(
|
||||
"timestamp-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
new TimestampBuildConfig[]
|
||||
{
|
||||
new Rfc3161TimestampBuildConfig(tokenBytes)
|
||||
});
|
||||
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
var entry = Assert.Single(manifest.Timestamps.OfType<Rfc3161TimestampEntry>());
|
||||
Assert.NotEmpty(entry.TsaChainPaths);
|
||||
Assert.NotEmpty(entry.OcspBlobs);
|
||||
Assert.NotEmpty(entry.CrlBlobs);
|
||||
|
||||
var chain = entry.TsaChainPaths
|
||||
.Select(path => Path.Combine(outputPath, path.Replace('/', Path.DirectorySeparatorChar)))
|
||||
.Select(path => X509Certificate2.CreateFromPem(File.ReadAllText(path)))
|
||||
.ToList();
|
||||
var ocspResponses = entry.OcspBlobs
|
||||
.Select(path => Path.Combine(outputPath, path.Replace('/', Path.DirectorySeparatorChar)))
|
||||
.Select(File.ReadAllBytes)
|
||||
.ToList();
|
||||
var crlSnapshots = entry.CrlBlobs
|
||||
.Select(path => Path.Combine(outputPath, path.Replace('/', Path.DirectorySeparatorChar)))
|
||||
.Select(File.ReadAllBytes)
|
||||
.ToList();
|
||||
|
||||
var trustRoots = new[]
|
||||
{
|
||||
new TimeTrustRoot("tsa-root", rootCert.Export(X509ContentType.Cert), "rsa")
|
||||
};
|
||||
|
||||
var verifier = new Rfc3161Verifier();
|
||||
var options = new TimeTokenVerificationOptions
|
||||
{
|
||||
Offline = true,
|
||||
CertificateChain = chain,
|
||||
OcspResponses = ocspResponses,
|
||||
Crls = crlSnapshots,
|
||||
VerificationTime = signingTime
|
||||
};
|
||||
|
||||
var result = verifier.Verify(tokenBytes, trustRoots, out var anchor, options);
|
||||
|
||||
Assert.True(result.IsValid, result.Reason);
|
||||
Assert.NotEqual(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
private static (X509Certificate2 RootCert, byte[] TokenBytes, DateTimeOffset SigningTime) CreateSignedToken()
|
||||
{
|
||||
var signingTime = DateTimeOffset.UtcNow;
|
||||
|
||||
using var rootKey = RSA.Create(2048);
|
||||
var rootRequest = new CertificateRequest(
|
||||
"CN=Test TSA Root",
|
||||
rootKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
rootRequest.CertificateExtensions.Add(
|
||||
new X509SubjectKeyIdentifierExtension(rootRequest.PublicKey, false));
|
||||
|
||||
var rootCert = rootRequest.CreateSelfSigned(
|
||||
signingTime.AddDays(-1),
|
||||
signingTime.AddYears(1));
|
||||
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafRequest = new CertificateRequest(
|
||||
"CN=Test TSA Leaf",
|
||||
leafKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
leafRequest.CertificateExtensions.Add(
|
||||
new X509SubjectKeyIdentifierExtension(leafRequest.PublicKey, false));
|
||||
|
||||
var leafCert = leafRequest.Create(
|
||||
rootCert,
|
||||
signingTime.AddDays(-1),
|
||||
signingTime.AddMonths(6),
|
||||
Guid.NewGuid().ToByteArray());
|
||||
var leafWithKey = leafCert.CopyWithPrivateKey(leafKey);
|
||||
|
||||
var content = new ContentInfo(Encoding.UTF8.GetBytes("timestamp-test"));
|
||||
var signedCms = new SignedCms(content, detached: true);
|
||||
var signer = new CmsSigner(leafWithKey)
|
||||
{
|
||||
IncludeOption = X509IncludeOption.WholeChain
|
||||
};
|
||||
signer.Certificates.Add(rootCert);
|
||||
signer.SignedAttributes.Add(new Pkcs9SigningTime(signingTime.UtcDateTime));
|
||||
signedCms.ComputeSignature(signer);
|
||||
|
||||
return (rootCert, signedCms.Encode(), signingTime);
|
||||
}
|
||||
|
||||
private static byte[] CreateOcspSuccessResponse()
|
||||
{
|
||||
var writer = new AsnWriter(AsnEncodingRules.DER);
|
||||
writer.PushSequence();
|
||||
writer.WriteEnumeratedValue(0);
|
||||
writer.PopSequence();
|
||||
return writer.Encode();
|
||||
}
|
||||
|
||||
private static byte[] CreateCrlPlaceholder()
|
||||
{
|
||||
var writer = new AsnWriter(AsnEncodingRules.DER);
|
||||
writer.PushSequence();
|
||||
writer.WriteInteger(1);
|
||||
writer.PopSequence();
|
||||
return writer.Encode();
|
||||
}
|
||||
|
||||
private sealed class FixedOcspFetcher : IOcspResponseFetcher
|
||||
{
|
||||
private readonly byte[] _response;
|
||||
|
||||
public FixedOcspFetcher(byte[] response)
|
||||
{
|
||||
_response = response;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var blobs = certificateChain
|
||||
.Select((_, index) => new TsaRevocationBlob(index, _response, "memory://ocsp"))
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<TsaRevocationBlob>>(blobs);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedCrlFetcher : ICrlFetcher
|
||||
{
|
||||
private readonly byte[] _response;
|
||||
|
||||
public FixedCrlFetcher(byte[] response)
|
||||
{
|
||||
_response = response;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var blobs = certificateChain
|
||||
.Select((_, index) => new TsaRevocationBlob(index, _response, "memory://crl"))
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<TsaRevocationBlob>>(blobs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="AirGapBundleDsseSignerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConflictResolverTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="HlcMergeServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
Reference in New Issue
Block a user