using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Cli.Output; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Services; /// /// Reader for attestation files (DSSE envelopes with in-toto statements). /// Per CLI-FORENSICS-54-002. /// internal sealed class AttestationReader : IAttestationReader { private const string PaePrefix = "DSSEv1"; private const string InTotoStatementType = "https://in-toto.io/Statement/v0.1"; private const string InTotoStatementV1Type = "https://in-toto.io/Statement/v1"; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; private readonly ILogger _logger; private readonly IForensicVerifier _verifier; public AttestationReader(ILogger logger, IForensicVerifier verifier) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _verifier = verifier ?? throw new ArgumentNullException(nameof(verifier)); } public async Task ReadAttestationAsync( string filePath, AttestationShowOptions options, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(filePath); ArgumentNullException.ThrowIfNull(options); _logger.LogDebug("Reading attestation from {FilePath}", filePath); if (!File.Exists(filePath)) { throw new FileNotFoundException($"Attestation file not found: {filePath}", filePath); } var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); AttestationEnvelope envelope; try { envelope = JsonSerializer.Deserialize(json, SerializerOptions) ?? throw new InvalidDataException("Invalid attestation JSON"); } catch (JsonException ex) { _logger.LogError(ex, "Failed to parse attestation envelope from {FilePath}", filePath); throw new InvalidDataException($"Failed to parse attestation envelope: {ex.Message}", ex); } // Decode payload byte[] payloadBytes; try { payloadBytes = Convert.FromBase64String(envelope.Payload); } catch (FormatException ex) { throw new InvalidDataException($"Invalid base64 payload: {ex.Message}", ex); } var payloadJson = Encoding.UTF8.GetString(payloadBytes); InTotoStatement statement; try { statement = JsonSerializer.Deserialize(payloadJson, SerializerOptions) ?? throw new InvalidDataException("Invalid in-toto statement JSON"); } catch (JsonException ex) { _logger.LogError(ex, "Failed to parse in-toto statement from payload"); throw new InvalidDataException($"Failed to parse in-toto statement: {ex.Message}", ex); } // Extract subjects var subjects = statement.Subject .Select(s => new AttestationSubjectInfo { Name = s.Name, DigestAlgorithm = s.Digest.Keys.FirstOrDefault() ?? "unknown", DigestValue = s.Digest.Values.FirstOrDefault() ?? string.Empty }) .ToList(); // Extract signatures var signatures = new List(); var trustRoots = options.TrustRoots.ToList(); if (!string.IsNullOrWhiteSpace(options.TrustRootPath)) { var loadedRoots = await _verifier.LoadTrustRootsAsync(options.TrustRootPath, cancellationToken) .ConfigureAwait(false); trustRoots.AddRange(loadedRoots); } foreach (var sig in envelope.Signatures) { var sigInfo = new AttestationSignatureInfo { KeyId = sig.KeyId ?? "(no key id)", Algorithm = "unknown" // Would need certificate parsing for actual algorithm }; if (options.VerifySignatures && trustRoots.Count > 0) { var matchingRoot = trustRoots.FirstOrDefault(tr => string.Equals(tr.KeyId, sig.KeyId, StringComparison.OrdinalIgnoreCase)); if (matchingRoot is not null) { var isValid = VerifySignature(envelope, sig, matchingRoot); var now = DateTimeOffset.UtcNow; var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) && (!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value); sigInfo = sigInfo with { Algorithm = matchingRoot.Algorithm, IsValid = isValid, IsTrusted = isValid && timeValid, SignerInfo = new AttestationSignerInfo { Fingerprint = matchingRoot.Fingerprint, NotBefore = matchingRoot.NotBefore, NotAfter = matchingRoot.NotAfter }, Reason = !isValid ? "Signature verification failed" : !timeValid ? "Key outside validity period" : null }; } else { sigInfo = sigInfo with { IsValid = null, IsTrusted = false, Reason = "No matching trust root found" }; } } signatures.Add(sigInfo); } // Extract predicate summary var predicateSummary = ExtractPredicateSummary(statement); // Build verification result AttestationVerificationResult? verificationResult = null; if (options.VerifySignatures) { var validCount = signatures.Count(s => s.IsValid == true); var trustedCount = signatures.Count(s => s.IsTrusted == true); var errors = signatures .Where(s => !string.IsNullOrWhiteSpace(s.Reason)) .Select(s => $"{s.KeyId}: {s.Reason}") .ToList(); verificationResult = new AttestationVerificationResult { IsValid = validCount > 0, SignatureCount = signatures.Count, ValidSignatures = validCount, TrustedSignatures = trustedCount, Errors = errors }; } return new AttestationShowResult { FilePath = filePath, PayloadType = envelope.PayloadType, StatementType = statement.Type, PredicateType = statement.PredicateType, Subjects = subjects, Signatures = signatures, PredicateSummary = predicateSummary, VerificationResult = verificationResult }; } private static bool VerifySignature( AttestationEnvelope envelope, AttestationSignature sig, ForensicTrustRoot trustRoot) { try { var payloadBytes = Convert.FromBase64String(envelope.Payload); var pae = BuildPreAuthEncoding(envelope.PayloadType, payloadBytes); var signatureBytes = Convert.FromBase64String(sig.Signature); var publicKeyBytes = Convert.FromBase64String(trustRoot.PublicKey); using var rsa = RSA.Create(); rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _); return rsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss); } catch (Exception) { return false; } } private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payload) { // DSSE PAE format: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType); using var ms = new MemoryStream(); using var writer = new BinaryWriter(ms); // Write "DSSEv1" prefix length and value writer.Write((long)PaePrefix.Length); writer.Write(Encoding.UTF8.GetBytes(PaePrefix)); // Write payload type length and value writer.Write((long)payloadTypeBytes.Length); writer.Write(payloadTypeBytes); // Write payload length and value writer.Write((long)payload.Length); writer.Write(payload); return ms.ToArray(); } private static AttestationPredicateSummary? ExtractPredicateSummary(InTotoStatement statement) { if (statement.Predicate is null) { return null; } var summary = new AttestationPredicateSummary { Type = statement.PredicateType }; // Try to extract common fields from predicate if (statement.Predicate is JsonElement element) { var metadata = new Dictionary(); var materials = new List(); // Extract buildType (SLSA) if (element.TryGetProperty("buildType", out var buildTypeProp) && buildTypeProp.ValueKind == JsonValueKind.String) { summary = summary with { BuildType = buildTypeProp.GetString() }; } // Extract builder (SLSA) if (element.TryGetProperty("builder", out var builderProp)) { if (builderProp.TryGetProperty("id", out var builderIdProp) && builderIdProp.ValueKind == JsonValueKind.String) { summary = summary with { Builder = builderIdProp.GetString() }; } } // Extract invocation ID (SLSA) if (element.TryGetProperty("invocation", out var invocationProp)) { if (invocationProp.TryGetProperty("configSource", out var configSourceProp) && configSourceProp.TryGetProperty("digest", out var digestProp)) { foreach (var d in digestProp.EnumerateObject()) { metadata[$"invocation.digest.{d.Name}"] = d.Value.GetString() ?? string.Empty; } } } // Extract materials if (element.TryGetProperty("materials", out var materialsProp) && materialsProp.ValueKind == JsonValueKind.Array) { foreach (var material in materialsProp.EnumerateArray()) { var uri = string.Empty; var digest = new Dictionary(); if (material.TryGetProperty("uri", out var uriProp) && uriProp.ValueKind == JsonValueKind.String) { uri = uriProp.GetString() ?? string.Empty; } if (material.TryGetProperty("digest", out var matDigestProp)) { foreach (var d in matDigestProp.EnumerateObject()) { digest[d.Name] = d.Value.GetString() ?? string.Empty; } } materials.Add(new AttestationMaterial { Uri = uri, Digest = digest }); } summary = summary with { Materials = materials }; } // Extract timestamp if (element.TryGetProperty("metadata", out var metaProp)) { if (metaProp.TryGetProperty("buildStartedOn", out var startedProp) && startedProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(startedProp.GetString(), out var started)) { summary = summary with { Timestamp = started }; } else if (metaProp.TryGetProperty("buildFinishedOn", out var finishedProp) && finishedProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(finishedProp.GetString(), out var finished)) { summary = summary with { Timestamp = finished }; } if (metaProp.TryGetProperty("invocationId", out var invIdProp) && invIdProp.ValueKind == JsonValueKind.String) { summary = summary with { InvocationId = invIdProp.GetString() }; } } // Extract VEX-specific fields if (statement.PredicateType.Contains("vex", StringComparison.OrdinalIgnoreCase)) { if (element.TryGetProperty("author", out var authorProp) && authorProp.ValueKind == JsonValueKind.String) { metadata["author"] = authorProp.GetString() ?? string.Empty; } if (element.TryGetProperty("timestamp", out var tsProp) && tsProp.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(tsProp.GetString(), out var ts)) { summary = summary with { Timestamp = ts }; } } if (element.TryGetProperty("version", out var versionProp)) { if (versionProp.ValueKind == JsonValueKind.String) { metadata["version"] = versionProp.GetString() ?? string.Empty; } else if (versionProp.ValueKind == JsonValueKind.Number) { metadata["version"] = versionProp.GetInt32().ToString(); } } } if (metadata.Count > 0) { summary = summary with { Metadata = metadata }; } } return summary; } }