save progress

This commit is contained in:
StellaOps Bot
2026-01-02 15:52:31 +02:00
parent 2dec7e6a04
commit f46bde5575
174 changed files with 20793 additions and 8307 deletions

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -116,12 +117,12 @@ public static class OfflineVerificationPolicyLoader
return JsonValue.Create(boolean);
}
if (long.TryParse(scalar.Value, out var integer))
if (long.TryParse(scalar.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer))
{
return JsonValue.Create(integer);
}
if (decimal.TryParse(scalar.Value, out var decimalValue))
if (decimal.TryParse(scalar.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue))
{
return JsonValue.Create(decimalValue);
}
@@ -129,4 +130,3 @@ public static class OfflineVerificationPolicyLoader
return JsonValue.Create(scalar.Value);
}
}

View File

@@ -27,7 +27,7 @@ public sealed class EvidenceGraph
/// Generation timestamp in ISO 8601 UTC format.
/// </summary>
[JsonPropertyName("generatedAt")]
public string GeneratedAt { get; init; } = DateTimeOffset.UtcNow.ToString("O");
public string GeneratedAt { get; init; } = DateTimeOffset.UnixEpoch.ToString("O");
/// <summary>
/// Generator tool identifier.
@@ -128,6 +128,9 @@ public sealed record AttestationNodeRef
[JsonPropertyName("path")]
public required string Path { get; init; }
[JsonPropertyName("contentHash")]
public required string ContentHash { get; init; }
[JsonPropertyName("signatureValid")]
public bool SignatureValid { get; init; }
@@ -237,6 +240,7 @@ public sealed class EvidenceGraphSerializer
Generator = graph.Generator,
Nodes = graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Select(ApplyDeterministicOrdering)
.ToList(),
Edges = graph.Edges
.OrderBy(e => e.Source, StringComparer.Ordinal)
@@ -303,4 +307,27 @@ public sealed class EvidenceGraphSerializer
return (graph, hashValid);
}
private static EvidenceNode ApplyDeterministicOrdering(EvidenceNode node)
{
return node with
{
Sboms = node.Sboms?
.OrderBy(s => s.ContentHash, StringComparer.Ordinal)
.ThenBy(s => s.Path, StringComparer.Ordinal)
.ThenBy(s => s.Format, StringComparer.Ordinal)
.ToList(),
Attestations = node.Attestations?
.OrderBy(a => a.ContentHash, StringComparer.Ordinal)
.ThenBy(a => a.Path, StringComparer.Ordinal)
.ThenBy(a => a.PredicateType, StringComparer.Ordinal)
.ToList(),
VexStatements = node.VexStatements?
.OrderBy(v => v.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(v => v.Source, StringComparer.Ordinal)
.ThenBy(v => v.Status, StringComparer.Ordinal)
.ThenBy(v => v.Justification ?? string.Empty, StringComparer.Ordinal)
.ToList()
};
}
}

View File

@@ -182,6 +182,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
{
PredicateType = a.PredicateType,
Path = a.FilePath,
ContentHash = a.ContentHash,
SignatureValid = a.SignatureVerified,
RekorVerified = a.TlogVerified
}).ToList(),

View File

@@ -4,6 +4,7 @@
// Part of Step 2: Evidence Collection (Task T5)
// =============================================================================
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -121,7 +122,7 @@ public sealed class CycloneDxParser : ISbomParser
{
if (metadataProp.TryGetProperty("timestamp", out var timestampProp))
{
if (DateTimeOffset.TryParse(timestampProp.GetString(), out var parsed))
if (TryParseTimestamp(timestampProp.GetString(), out var parsed))
{
createdAt = parsed;
}
@@ -214,21 +215,7 @@ public sealed class CycloneDxParser : ISbomParser
}
// Determine primary digest (prefer SHA-256)
string? digest = null;
if (hashes.TryGetValue("SHA-256", out var sha256))
{
digest = NormalizeDigest("sha256:" + sha256);
}
else if (hashes.TryGetValue("SHA256", out sha256))
{
digest = NormalizeDigest("sha256:" + sha256);
}
else if (hashes.Count > 0)
{
// Use first available hash
var first = hashes.First();
digest = NormalizeDigest($"{first.Key.ToLowerInvariant().Replace("-", "")}:{first.Value}");
}
var digest = TrySelectSha256Digest(hashes);
// If no digest, this component can't be indexed by digest
if (string.IsNullOrEmpty(digest))
@@ -333,4 +320,24 @@ public sealed class CycloneDxParser : ISbomParser
{
return ArtifactIndex.NormalizeDigest(digest);
}
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
{
timestamp = default;
return !string.IsNullOrWhiteSpace(value) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
}
private static string? TrySelectSha256Digest(IReadOnlyDictionary<string, string> hashes)
{
foreach (var key in new[] { "SHA-256", "SHA256", "sha256" })
{
if (hashes.TryGetValue(key, out var sha256))
{
return NormalizeDigest("sha256:" + sha256);
}
}
return null;
}
}

View File

@@ -174,10 +174,14 @@ public sealed record InTotoSubject
/// </summary>
public string? GetSha256Digest()
{
if (Digest.TryGetValue("sha256", out var hash))
foreach (var (key, value) in Digest)
{
return "sha256:" + hash.ToLowerInvariant();
if (string.Equals(key, "sha256", StringComparison.OrdinalIgnoreCase))
{
return "sha256:" + value.ToLowerInvariant();
}
}
return null;
}
}

View File

@@ -4,6 +4,7 @@
// Part of Step 2: Evidence Collection (Task T5)
// =============================================================================
using System.Globalization;
using System.Text.Json;
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
@@ -114,7 +115,7 @@ public sealed class SpdxParser : ISbomParser
if (root.TryGetProperty("creationInfo", out var creationInfoProp) &&
creationInfoProp.TryGetProperty("created", out var createdProp))
{
if (DateTimeOffset.TryParse(createdProp.GetString(), out var parsed))
if (TryParseTimestamp(createdProp.GetString(), out var parsed))
{
createdAt = parsed;
}
@@ -224,17 +225,7 @@ public sealed class SpdxParser : ISbomParser
}
// Determine primary digest (prefer SHA256)
string? digest = null;
if (hashes.TryGetValue("SHA256", out var sha256))
{
digest = NormalizeDigest("sha256:" + sha256);
}
else if (hashes.Count > 0)
{
// Use first available hash
var first = hashes.First();
digest = NormalizeDigest($"{first.Key.ToLowerInvariant()}:{first.Value}");
}
var digest = TrySelectSha256Digest(hashes);
// If no digest, this package can't be indexed by digest
if (string.IsNullOrEmpty(digest))
@@ -302,4 +293,24 @@ public sealed class SpdxParser : ISbomParser
{
return ArtifactIndex.NormalizeDigest(digest);
}
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
{
timestamp = default;
return !string.IsNullOrWhiteSpace(value) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
}
private static string? TrySelectSha256Digest(IReadOnlyDictionary<string, string> hashes)
{
foreach (var key in new[] { "SHA256", "SHA-256", "sha256" })
{
if (hashes.TryGetValue(key, out var sha256))
{
return NormalizeDigest("sha256:" + sha256);
}
}
return null;
}
}

View File

@@ -5,8 +5,11 @@ using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Asn1.X9;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
using StellaOps.AirGap.Importer.Validation;
using AttestorDsseEnvelope = StellaOps.Attestor.Envelope.DsseEnvelope;
using AttestorDsseSignature = StellaOps.Attestor.Envelope.DsseSignature;
using StellaOps.Attestor.Envelope;
namespace StellaOps.AirGap.Importer.Reconciliation.Signing;
@@ -45,10 +48,10 @@ internal sealed class EvidenceGraphDsseSigner
var envelopeKey = LoadEcdsaEnvelopeKey(signingPrivateKeyPemPath, signingKeyId);
var signature = SignDeterministicEcdsa(pae, signingPrivateKeyPemPath, envelopeKey.AlgorithmId);
var envelope = new DsseEnvelope(
var envelope = new AttestorDsseEnvelope(
EvidenceGraphPayloadType,
payloadBytes,
signatures: [DsseSignature.FromBytes(signature, envelopeKey.KeyId)],
signatures: [AttestorDsseSignature.FromBytes(signature, envelopeKey.KeyId)],
payloadContentType: "application/json");
var serialized = DsseEnvelopeSerializer.Serialize(
@@ -177,25 +180,3 @@ internal sealed class EvidenceGraphDsseSigner
/// </summary>
private sealed record EnvelopeKey(string AlgorithmId, string KeyId);
}
internal static class DssePreAuthenticationEncoding
{
private const string Prefix = "DSSEv1";
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
return buffer;
}
}

View File

@@ -107,10 +107,12 @@ public sealed class SourcePrecedenceLattice
nameof(statements));
}
// Sort by precedence (descending), then by timestamp (descending)
// Sort by precedence (descending), timestamp (descending), then status priority (configurable).
var winner = statementList
.OrderByDescending(s => (int)s.Source)
.ThenByDescending(s => s.Timestamp ?? DateTimeOffset.MinValue)
.ThenByDescending(s => GetStatusPriority(s.Status))
.ThenBy(s => s.DocumentRef ?? string.Empty, StringComparer.Ordinal)
.First();
return winner;
@@ -184,18 +186,8 @@ public sealed class SourcePrecedenceLattice
}
// Same precedence and timestamp - true conflict
// Use status priority: NotAffected > Fixed > UnderInvestigation > Affected > Unknown
var statusPriority = new Dictionary<VexStatus, int>
{
[VexStatus.NotAffected] = 5,
[VexStatus.Fixed] = 4,
[VexStatus.UnderInvestigation] = 3,
[VexStatus.Affected] = 2,
[VexStatus.Unknown] = 1
};
var aPriority = statusPriority.GetValueOrDefault(a.Status, 0);
var bPriority = statusPriority.GetValueOrDefault(b.Status, 0);
var aPriority = GetStatusPriority(a.Status);
var bPriority = GetStatusPriority(b.Status);
if (aPriority != bPriority)
{
@@ -218,6 +210,29 @@ public sealed class SourcePrecedenceLattice
Winner: fallbackWinner,
Reason: "Deterministic fallback (document ref ordering)");
}
private int GetStatusPriority(VexStatus status)
{
return _config.PreferRestrictive
? status switch
{
VexStatus.Affected => 5,
VexStatus.UnderInvestigation => 4,
VexStatus.Fixed => 3,
VexStatus.NotAffected => 2,
VexStatus.Unknown => 1,
_ => 0
}
: status switch
{
VexStatus.NotAffected => 5,
VexStatus.Fixed => 4,
VexStatus.UnderInvestigation => 3,
VexStatus.Affected => 2,
VexStatus.Unknown => 1,
_ => 0
};
}
}
/// <summary>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0026-M | DONE | Maintainability audit for StellaOps.AirGap.Importer. |
| AUDIT-0026-T | DONE | Test coverage audit for StellaOps.AirGap.Importer. |
| AUDIT-0026-A | TODO | Pending approval for changes. |
| AUDIT-0026-A | DOING | Pending approval for changes. |

View File

@@ -0,0 +1,25 @@
using System.Text;
namespace StellaOps.AirGap.Importer.Validation;
internal static class DssePreAuthenticationEncoding
{
private const string Prefix = "DSSEv1";
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
return buffer;
}
}

View File

@@ -1,4 +1,3 @@
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.Cryptography;
@@ -12,10 +11,10 @@ namespace StellaOps.AirGap.Importer.Validation;
/// </summary>
public sealed class DsseVerifier
{
private const string PaePrefix = "DSSEv1";
private readonly ICryptoProviderRegistry _cryptoRegistry;
private readonly TimeProvider _timeProvider;
public DsseVerifier(ICryptoProviderRegistry? cryptoRegistry = null)
public DsseVerifier(ICryptoProviderRegistry? cryptoRegistry = null, TimeProvider? timeProvider = null)
{
if (cryptoRegistry is null)
{
@@ -27,6 +26,8 @@ public sealed class DsseVerifier
{
_cryptoRegistry = cryptoRegistry;
}
_timeProvider = timeProvider ?? TimeProvider.System;
}
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots, ILogger? logger = null)
@@ -41,35 +42,86 @@ public sealed class DsseVerifier
return BundleValidationResult.Failure("trust-roots-required");
}
if (!IsAlgorithmAllowed(trustRoots.AllowedSignatureAlgorithms))
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code} allowed_algorithms={allowed_algorithms}",
"ALGORITHM_NOT_ALLOWED",
trustRoots.AllowedSignatureAlgorithms.Count);
return BundleValidationResult.Failure("signature-algorithm-disallowed");
}
if (!IsWithinTrustWindow(trustRoots, _timeProvider.GetUtcNow(), out var windowReason))
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code} trust_window={trust_window}",
"TRUST_WINDOW_INVALID",
windowReason);
return BundleValidationResult.Failure("trust-window-invalid");
}
logger?.LogDebug(
"offlinekit.dsse.verify start payload_type={payload_type} signatures={signatures} public_keys={public_keys}",
envelope.PayloadType,
envelope.Signatures.Count,
trustRoots.PublicKeys.Count);
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code}",
"PAYLOAD_BASE64_INVALID");
return BundleValidationResult.Failure("payload-base64-invalid");
}
var fingerprints = new HashSet<string>(trustRoots.TrustedKeyFingerprints, StringComparer.OrdinalIgnoreCase);
var signatureKeyIds = envelope.Signatures
.Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId))
.Select(sig => sig.KeyId!)
.ToList();
if (signatureKeyIds.Count == 0)
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code}",
"SIGNATURE_KEYID_MISSING");
return BundleValidationResult.Failure("signature-keyid-missing");
}
var pae = DssePreAuthenticationEncoding.Encode(envelope.PayloadType, payloadBytes);
foreach (var signature in envelope.Signatures)
{
if (string.IsNullOrWhiteSpace(signature.KeyId))
{
continue;
}
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
{
continue;
}
var fingerprint = ComputeFingerprint(keyBytes);
if (!trustRoots.TrustedKeyFingerprints.Contains(fingerprint))
if (!fingerprints.Contains(fingerprint))
{
continue;
}
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
if (!TryDecodeBase64(signature.Signature, out var sigBytes))
{
continue;
}
if (TryVerifyRsaPss(keyBytes, pae, sigBytes))
{
logger?.LogInformation(
"offlinekit.dsse.verify succeeded key_id={key_id} fingerprint={fingerprint} payload_type={payload_type}",
signature.KeyId,
fingerprint,
envelope.PayloadType);
return BundleValidationResult.Success("dsse-signature-verified");
}
return BundleValidationResult.Success("dsse-signature-verified");
}
}
logger?.LogWarning(
@@ -80,31 +132,7 @@ public sealed class DsseVerifier
return BundleValidationResult.Failure("dsse-signature-untrusted-or-invalid");
}
private static byte[] BuildPreAuthEncoding(string payloadType, string payloadBase64)
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var parts = new[]
{
PaePrefix,
payloadType,
Encoding.UTF8.GetString(payloadBytes)
};
var paeBuilder = new StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, string signatureBase64)
private bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, byte[] signatureBytes)
{
try
{
@@ -112,8 +140,7 @@ public sealed class DsseVerifier
var verifier = _cryptoRegistry.ResolveOrThrow(CryptoCapability.Verification, "PS256")
.CreateEphemeralVerifier("PS256", publicKey);
var sig = Convert.FromBase64String(signatureBase64);
var result = verifier.VerifyAsync(pae, sig).GetAwaiter().GetResult();
var result = verifier.VerifyAsync(pae, signatureBytes).GetAwaiter().GetResult();
return result;
}
catch
@@ -128,5 +155,57 @@ public sealed class DsseVerifier
var hash = hasherResolution.Hasher.ComputeHash(publicKey);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
private static bool TryDecodeBase64(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch
{
bytes = Array.Empty<byte>();
return false;
}
}
private static bool IsAlgorithmAllowed(IReadOnlyCollection<string> allowedAlgorithms)
{
if (allowedAlgorithms.Count == 0)
{
return true;
}
foreach (var algorithm in allowedAlgorithms)
{
if (string.Equals(algorithm, "PS256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(algorithm, "RSASSA-PSS-SHA256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(algorithm, "RSA-PSS-SHA256", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool IsWithinTrustWindow(TrustRootConfig trustRoots, DateTimeOffset nowUtc, out string reason)
{
reason = string.Empty;
if (trustRoots.NotBeforeUtc is { } notBefore && nowUtc < notBefore)
{
reason = "not-before";
return false;
}
if (trustRoots.NotAfterUtc is { } notAfter && nowUtc > notAfter)
{
reason = "not-after";
return false;
}
return true;
}
}

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
@@ -99,6 +100,38 @@ public sealed class ImportValidator
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
var expectedMerkleRoot = TryExtractManifestMerkleRoot(request.ManifestJson, out var manifestError);
if (string.IsNullOrWhiteSpace(expectedMerkleRoot))
{
var failed = BundleValidationResult.Failure(manifestError ?? "merkle-root-missing");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"MERKLE_ROOT_MISSING",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
if (!string.Equals(expectedMerkleRoot, merkleRoot, StringComparison.OrdinalIgnoreCase))
{
var failed = BundleValidationResult.Failure($"merkle-root-mismatch:expected={expectedMerkleRoot}:actual={merkleRoot}");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"MERKLE_ROOT_MISMATCH",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"merkle:{merkleRoot}");
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
@@ -279,6 +312,48 @@ public sealed class ImportValidator
request.BundlePath);
}
}
private static string? TryExtractManifestMerkleRoot(string? manifestJson, out string? errorReason)
{
errorReason = null;
if (string.IsNullOrWhiteSpace(manifestJson))
{
errorReason = "manifest-missing";
return null;
}
try
{
using var doc = JsonDocument.Parse(manifestJson);
if (TryGetString(doc.RootElement, "merkleRoot", out var merkleRoot) ||
TryGetString(doc.RootElement, "merkle_root", out merkleRoot))
{
return merkleRoot;
}
}
catch (JsonException ex)
{
errorReason = $"manifest-json-invalid:{ex.GetType().Name.ToLowerInvariant()}";
return null;
}
errorReason = "merkle-root-missing";
return null;
}
private static bool TryGetString(JsonElement element, string propertyName, out string? value)
{
if (element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String)
{
value = property.GetString();
return !string.IsNullOrWhiteSpace(value);
}
value = null;
return false;
}
}
public sealed record ImportValidationRequest(

View File

@@ -32,15 +32,31 @@ public sealed class MerkleRootCalculator
private static byte[] HashLeaf(NamedStream entry)
{
using var sha256 = SHA256.Create();
using var buffer = new MemoryStream();
entry.Stream.Seek(0, SeekOrigin.Begin);
entry.Stream.CopyTo(buffer);
var contentHash = sha256.ComputeHash(buffer.ToArray());
var contentHash = ComputeContentHash(sha256, entry.Stream);
var leafBytes = Encoding.UTF8.GetBytes(entry.Path.ToLowerInvariant() + ":" + Convert.ToHexString(contentHash).ToLowerInvariant());
return SHA256.HashData(leafBytes);
}
private static byte[] ComputeContentHash(HashAlgorithm hasher, Stream stream)
{
var canSeek = stream.CanSeek;
var originalPosition = canSeek ? stream.Position : 0;
if (canSeek)
{
stream.Seek(0, SeekOrigin.Begin);
}
var hash = hasher.ComputeHash(stream);
if (canSeek)
{
stream.Seek(originalPosition, SeekOrigin.Begin);
}
return hash;
}
private static IEnumerable<byte[]> Pairwise(IReadOnlyList<byte[]> nodes)
{
for (var i = 0; i < nodes.Count; i += 2)

View File

@@ -399,7 +399,7 @@ public static class RekorOfflineReceiptVerifier
.Select(static line => line.TrimEnd())
.ToList();
// Extract signatures first (note format: "— origin base64sig", or "sig <base64>").
// Extract signatures first (note format: em-dash prefix or "sig <base64>").
var signatures = new List<byte[]>();
foreach (var line in lines)
{
@@ -409,7 +409,7 @@ public static class RekorOfflineReceiptVerifier
continue;
}
if (trimmed.StartsWith("—", StringComparison.Ordinal) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
if (LooksLikeDashSignature(trimmed) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
{
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
@@ -486,7 +486,7 @@ public static class RekorOfflineReceiptVerifier
private static bool LooksLikeSignatureLine(string trimmedLine)
{
if (trimmedLine.StartsWith("—", StringComparison.Ordinal))
if (LooksLikeDashSignature(trimmedLine))
{
return true;
}
@@ -505,6 +505,11 @@ public static class RekorOfflineReceiptVerifier
return false;
}
private static bool LooksLikeDashSignature(string trimmedLine)
{
return trimmedLine.Length > 0 && trimmedLine[0] == '\u2014';
}
private static bool TryDecodeBase64(string token, out byte[] bytes)
{
try