up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files (DSSE envelopes with in-toto statements).
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
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<AttestationReader> _logger;
|
||||
private readonly IForensicVerifier _verifier;
|
||||
|
||||
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
}
|
||||
|
||||
public async Task<AttestationShowResult> 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<AttestationEnvelope>(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<InTotoStatement>(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<AttestationSignatureInfo>();
|
||||
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<string, string>();
|
||||
var materials = new List<AttestationMaterial>();
|
||||
|
||||
// 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<string, string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user