save progress
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user