license switch agpl -> busl1, sprints work, new product advisories

This commit is contained in:
master
2026-01-20 15:32:20 +02:00
parent 4903395618
commit c32fff8f86
1835 changed files with 38630 additions and 4359 deletions

View File

@@ -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>

View File

@@ -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>();
}
}
}

View File

@@ -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>();
}
}
}

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace StellaOps.AirGap.Bundle.Services;
public sealed record TsaRevocationBlob(int CertificateIndex, byte[] Data, string? SourceUri);