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
386 lines
14 KiB
C#
386 lines
14 KiB
C#
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;
|
|
}
|
|
}
|