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;
}
}