consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -10,12 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Predicates;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.Serialization;
using StellaOps.Cryptography;
using System.CommandLine;
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -405,6 +408,7 @@ public static class BundleVerifyCommand
var allDsseFiles = rootDsseFiles.Concat(additionalDsseFiles).ToList();
var verified = 0;
var allPassed = true;
foreach (var dsseFile in allDsseFiles)
{
@@ -424,15 +428,55 @@ public static class BundleVerifyCommand
if (envelope?.Signatures == null || envelope.Signatures.Count == 0)
{
result.Checks.Add(new VerificationCheck($"dsse:{dsseFile}", false, "No signatures found"));
allPassed = false;
continue;
}
// If trust root provided, verify signature
if (!string.IsNullOrEmpty(trustRoot))
{
// In production, actually verify the signature
result.Checks.Add(new VerificationCheck($"dsse:{dsseFile}", true,
$"Signature verified ({envelope.Signatures.Count} signature(s))"));
if (!File.Exists(trustRoot))
{
result.Checks.Add(new VerificationCheck($"dsse:{dsseFile}", false,
$"Trust root file not found: {trustRoot}"));
allPassed = false;
continue;
}
if (string.IsNullOrWhiteSpace(envelope.Payload) || string.IsNullOrWhiteSpace(envelope.PayloadType))
{
result.Checks.Add(new VerificationCheck($"dsse:{dsseFile}", false,
"DSSE payload or payloadType missing"));
allPassed = false;
continue;
}
var signatureVerified = false;
string? lastError = null;
foreach (var signature in envelope.Signatures)
{
if (string.IsNullOrWhiteSpace(signature.Sig))
{
lastError = "Signature value missing";
continue;
}
if (TryVerifyDsseSignature(trustRoot, envelope.PayloadType, envelope.Payload, signature.Sig, out var error))
{
signatureVerified = true;
break;
}
lastError = error;
}
result.Checks.Add(new VerificationCheck($"dsse:{dsseFile}", signatureVerified,
signatureVerified
? $"Cryptographic signature verified ({envelope.Signatures.Count} signature(s))"
: $"Signature verification failed: {lastError ?? "invalid_signature"}"));
if (!signatureVerified)
{
allPassed = false;
}
}
else
{
@@ -446,7 +490,97 @@ public static class BundleVerifyCommand
verified++;
}
return verified > 0;
return verified > 0 && allPassed;
}
private static bool TryVerifyDsseSignature(
string trustRootPath,
string payloadType,
string payloadBase64,
string signatureBase64,
out string? error)
{
error = null;
try
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var signatureBytes = Convert.FromBase64String(signatureBase64);
var pae = BuildDssePae(payloadType, payloadBytes);
var publicKeyPem = File.ReadAllText(trustRootPath);
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
if (rsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
return true;
}
}
catch
{
// Try certificate/ECDSA path below.
}
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(trustRootPath);
using var certKey = cert.GetRSAPublicKey();
if (certKey is not null &&
certKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
return true;
}
}
catch
{
// Try ECDSA path.
}
try
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(publicKeyPem);
return ecdsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256);
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static byte[] BuildDssePae(string payloadType, byte[] payload)
{
var header = Encoding.UTF8.GetBytes("DSSEv1");
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
var payloadTypeLengthBytes = Encoding.UTF8.GetBytes(payloadTypeBytes.Length.ToString(CultureInfo.InvariantCulture));
var payloadLengthBytes = Encoding.UTF8.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
var space = new[] { (byte)' ' };
var output = new byte[
header.Length + space.Length + payloadTypeLengthBytes.Length + space.Length +
payloadTypeBytes.Length + space.Length + payloadLengthBytes.Length + space.Length +
payload.Length];
var offset = 0;
Buffer.BlockCopy(header, 0, output, offset, header.Length); offset += header.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadTypeLengthBytes, 0, output, offset, payloadTypeLengthBytes.Length); offset += payloadTypeLengthBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadTypeBytes, 0, output, offset, payloadTypeBytes.Length); offset += payloadTypeBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadLengthBytes, 0, output, offset, payloadLengthBytes.Length); offset += payloadLengthBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payload, 0, output, offset, payload.Length);
return output;
}
private static async Task<bool> VerifyRekorProofsAsync(
@@ -468,45 +602,483 @@ public static class BundleVerifyCommand
}
var proofJson = await File.ReadAllTextAsync(proofPath, ct);
var proof = JsonSerializer.Deserialize<RekorProofDto>(proofJson, JsonOptions);
if (proof == null)
JsonDocument proofDocument;
try
{
result.Checks.Add(new VerificationCheck("rekor:proof", false, "Failed to parse proof"));
proofDocument = JsonDocument.Parse(proofJson);
}
catch (JsonException ex)
{
result.Checks.Add(new VerificationCheck("rekor:proof", false, $"proof-parse-failed: {ex.Message}"));
return false;
}
// Verify Merkle proof
if (!string.IsNullOrEmpty(checkpointPath))
using (proofDocument)
{
var checkpointJson = await File.ReadAllTextAsync(checkpointPath, ct);
var checkpoint = JsonSerializer.Deserialize<CheckpointDto>(checkpointJson, JsonOptions);
if (!TryReadLogIndex(proofDocument.RootElement, out var logIndex))
{
result.Checks.Add(new VerificationCheck("rekor:proof", false, "proof-log-index-missing"));
return false;
}
result.Checks.Add(new VerificationCheck("rekor:proof", true, $"Proof parsed (log index: {logIndex})"));
if (!string.IsNullOrWhiteSpace(checkpointPath))
{
if (!File.Exists(checkpointPath))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
$"checkpoint-not-found: {checkpointPath}"));
return false;
}
var checkpointJson = await File.ReadAllTextAsync(checkpointPath, ct);
if (!TryParseCheckpoint(checkpointJson, out var checkpoint, out var checkpointError))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
$"checkpoint-invalid: {checkpointError ?? "unknown"}"));
return false;
}
if (logIndex < 0 || logIndex >= checkpoint.TreeSize)
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
$"proof-log-index-out-of-range: logIndex={logIndex}, checkpointTreeSize={checkpoint.TreeSize}"));
return false;
}
if (!TryResolveProofRootHash(proofDocument.RootElement, out var proofRootHash, out var rootError))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
$"proof-root-hash-invalid: {rootError ?? "missing"}"));
return false;
}
if (!CryptographicOperations.FixedTimeEquals(proofRootHash, checkpoint.RootHash))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
"proof-root-hash-mismatch-with-checkpoint"));
return false;
}
if (!TryResolveProofHashes(proofDocument.RootElement, out var proofHashes, out var hashError))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
$"proof-hashes-invalid: {hashError ?? "missing"}"));
return false;
}
if (!TryResolveProofTreeSize(proofDocument.RootElement, checkpoint.TreeSize, out var proofTreeSize))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
"proof-tree-size-invalid"));
return false;
}
if (!TryResolveLeafHash(proofDocument.RootElement, out var leafHash, out var leafError))
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
$"proof-leaf-hash-missing: {leafError ?? "cannot-verify-merkle"}"));
return false;
}
var inclusionValid = MerkleProofVerifier.VerifyInclusion(
leafHash,
logIndex,
proofTreeSize,
proofHashes,
checkpoint.RootHash);
if (!inclusionValid)
{
result.Checks.Add(new VerificationCheck(
"rekor:inclusion",
false,
"proof-merkle-verification-failed"));
return false;
}
result.Checks.Add(new VerificationCheck("rekor:inclusion", true, $"Inclusion verified at log index {logIndex}"));
return true;
}
if (!offline)
{
result.Checks.Add(new VerificationCheck("rekor:inclusion", true,
$"Log index {logIndex} present - checkpoint not provided for offline verification")
{
Severity = "warning"
});
return true;
}
// In production, verify inclusion proof against checkpoint
result.Checks.Add(new VerificationCheck("rekor:inclusion", true,
$"Inclusion verified at log index {proof.LogIndex}"));
}
else if (!offline)
{
// Online: fetch checkpoint and verify
result.Checks.Add(new VerificationCheck("rekor:inclusion", true,
$"Log index {proof.LogIndex} present - online verification available")
$"Log index {logIndex} present - no checkpoint for offline verification")
{
Severity = "warning"
});
return true;
}
else
}
private static bool TryParseCheckpoint(
string checkpointJson,
out ParsedCheckpoint checkpoint,
out string? error)
{
checkpoint = default;
error = null;
JsonDocument document;
try
{
result.Checks.Add(new VerificationCheck("rekor:inclusion", true,
$"Log index {proof.LogIndex} present - no checkpoint for offline verification")
document = JsonDocument.Parse(checkpointJson);
}
catch (JsonException ex)
{
error = ex.Message;
return false;
}
using (document)
{
var root = document.RootElement;
var checkpointElement = root.TryGetProperty("checkpoint", out var nestedCheckpoint) &&
nestedCheckpoint.ValueKind == JsonValueKind.Object
? nestedCheckpoint
: root;
if (!TryGetInt64Property(checkpointElement, "treeSize", out var treeSize))
{
Severity = "warning"
});
if (!TryGetInt64Property(checkpointElement, "size", out treeSize))
{
error = "treeSize/size missing";
return false;
}
}
if (!TryGetStringProperty(checkpointElement, "rootHash", out var rootHashString))
{
if (!TryGetStringProperty(checkpointElement, "hash", out rootHashString))
{
error = "rootHash/hash missing";
return false;
}
}
if (!TryDecodeHashValue(rootHashString, out var rootHashBytes))
{
error = "root hash must be lowercase hex, sha256:hex, or base64";
return false;
}
checkpoint = new ParsedCheckpoint(treeSize, rootHashBytes);
return true;
}
}
private static bool TryReadLogIndex(JsonElement root, out long logIndex)
{
if (TryGetInt64Property(root, "logIndex", out logIndex))
{
return true;
}
if (TryGetObjectProperty(root, "inclusion", out var inclusion) &&
TryGetInt64Property(inclusion, "logIndex", out logIndex))
{
return true;
}
if (TryGetObjectProperty(root, "inclusionProof", out var inclusionProof) &&
TryGetInt64Property(inclusionProof, "logIndex", out logIndex))
{
return true;
}
logIndex = -1;
return false;
}
private static bool TryResolveProofTreeSize(JsonElement root, long fallbackTreeSize, out long treeSize)
{
if (TryGetInt64Property(root, "treeSize", out treeSize))
{
return treeSize > 0;
}
if (TryGetObjectProperty(root, "inclusion", out var inclusion) &&
TryGetInt64Property(inclusion, "treeSize", out treeSize))
{
return treeSize > 0;
}
if (TryGetObjectProperty(root, "inclusionProof", out var inclusionProof) &&
TryGetInt64Property(inclusionProof, "treeSize", out treeSize))
{
return treeSize > 0;
}
treeSize = fallbackTreeSize;
return treeSize > 0;
}
private static bool TryResolveProofRootHash(JsonElement root, out byte[] rootHash, out string? error)
{
rootHash = Array.Empty<byte>();
error = null;
string? rootHashString = null;
if (TryGetStringProperty(root, "rootHash", out var directRootHash))
{
rootHashString = directRootHash;
}
else if (TryGetObjectProperty(root, "inclusion", out var inclusion) &&
TryGetStringProperty(inclusion, "rootHash", out var inclusionRootHash))
{
rootHashString = inclusionRootHash;
}
else if (TryGetObjectProperty(root, "inclusionProof", out var inclusionProof) &&
TryGetStringProperty(inclusionProof, "rootHash", out var inclusionProofRootHash))
{
rootHashString = inclusionProofRootHash;
}
else if (TryGetObjectProperty(root, "checkpoint", out var checkpointObject))
{
if (TryGetStringProperty(checkpointObject, "rootHash", out var checkpointRootHash))
{
rootHashString = checkpointRootHash;
}
else if (TryGetStringProperty(checkpointObject, "hash", out var checkpointHash))
{
rootHashString = checkpointHash;
}
}
if (string.IsNullOrWhiteSpace(rootHashString))
{
error = "missing rootHash";
return false;
}
if (!TryDecodeHashValue(rootHashString, out rootHash))
{
error = "invalid rootHash format";
return false;
}
return true;
}
private static bool TryResolveProofHashes(JsonElement root, out List<byte[]> hashes, out string? error)
{
hashes = new List<byte[]>();
error = null;
JsonElement hashesElement;
if (TryGetArrayProperty(root, "hashes", out hashesElement) ||
(TryGetObjectProperty(root, "inclusion", out var inclusion) && TryGetArrayProperty(inclusion, "hashes", out hashesElement)) ||
(TryGetObjectProperty(root, "inclusion", out inclusion) && TryGetArrayProperty(inclusion, "path", out hashesElement)) ||
(TryGetObjectProperty(root, "inclusionProof", out var inclusionProof) && TryGetArrayProperty(inclusionProof, "hashes", out hashesElement)) ||
(TryGetObjectProperty(root, "inclusionProof", out inclusionProof) && TryGetArrayProperty(inclusionProof, "path", out hashesElement)))
{
foreach (var hashElement in hashesElement.EnumerateArray())
{
if (hashElement.ValueKind != JsonValueKind.String)
{
error = "hash entry is not a string";
return false;
}
var hashText = hashElement.GetString();
if (string.IsNullOrWhiteSpace(hashText))
{
error = "hash entry is empty";
return false;
}
if (!TryDecodeHashValue(hashText, out var hashBytes))
{
error = $"invalid hash entry: {hashText}";
return false;
}
hashes.Add(hashBytes);
}
return true;
}
error = "hashes/path array missing";
return false;
}
private static bool TryResolveLeafHash(JsonElement root, out byte[] leafHash, out string? error)
{
leafHash = Array.Empty<byte>();
error = null;
if (TryGetStringProperty(root, "leafHash", out var directLeafHash) &&
TryDecodeHashValue(directLeafHash, out leafHash))
{
return true;
}
if (TryGetObjectProperty(root, "inclusion", out var inclusion) &&
TryGetStringProperty(inclusion, "leafHash", out var inclusionLeafHash) &&
TryDecodeHashValue(inclusionLeafHash, out leafHash))
{
return true;
}
if (TryGetObjectProperty(root, "inclusionProof", out var inclusionProof) &&
TryGetStringProperty(inclusionProof, "leafHash", out var inclusionProofLeafHash) &&
TryDecodeHashValue(inclusionProofLeafHash, out leafHash))
{
return true;
}
error = "leafHash missing";
return false;
}
private static bool TryDecodeHashValue(string value, out byte[] hashBytes)
{
hashBytes = Array.Empty<byte>();
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim();
if (normalized.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized["sha256:".Length..];
}
if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[2..];
}
if (normalized.Length == 64 && normalized.All(IsHexChar))
{
try
{
hashBytes = Convert.FromHexString(normalized);
return hashBytes.Length == 32;
}
catch
{
return false;
}
}
try
{
var base64Bytes = Convert.FromBase64String(normalized);
if (base64Bytes.Length == 32)
{
hashBytes = base64Bytes;
return true;
}
}
catch
{
// Not base64.
}
return false;
}
private static bool IsHexChar(char value)
{
return (value >= '0' && value <= '9') ||
(value >= 'a' && value <= 'f') ||
(value >= 'A' && value <= 'F');
}
private static bool TryGetInt64Property(JsonElement element, string propertyName, out long value)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out var property))
{
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out value))
{
return true;
}
if (property.ValueKind == JsonValueKind.String &&
long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
return true;
}
}
value = 0;
return false;
}
private static bool TryGetStringProperty(JsonElement element, string propertyName, out string value)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String)
{
var text = property.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
value = text;
return true;
}
}
value = string.Empty;
return false;
}
private static bool TryGetArrayProperty(JsonElement element, string propertyName, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out value) &&
value.ValueKind == JsonValueKind.Array)
{
return true;
}
value = default;
return false;
}
private static bool TryGetObjectProperty(JsonElement element, string propertyName, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out value) &&
value.ValueKind == JsonValueKind.Object)
{
return true;
}
value = default;
return false;
}
private static bool VerifyPayloadTypes(
BundleManifestDto? manifest,
VerificationResult result,
@@ -1391,12 +1963,21 @@ public static class BundleVerifyCommand
{
[JsonPropertyName("signatures")]
public List<SignatureDto>? Signatures { get; set; }
[JsonPropertyName("payload")]
public string? Payload { get; set; }
[JsonPropertyName("payloadType")]
public string? PayloadType { get; set; }
}
private sealed class SignatureDto
{
[JsonPropertyName("keyid")]
public string? KeyId { get; set; }
[JsonPropertyName("sig")]
public string? Sig { get; set; }
}
private sealed class RekorProofDto
@@ -1414,5 +1995,7 @@ public static class BundleVerifyCommand
public string? RootHash { get; set; }
}
private readonly record struct ParsedCheckpoint(long TreeSize, byte[] RootHash);
#endregion
}