sprints work.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IPredicateTimestampMetadata.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Task: ATT-004 - Predicate Writer Extensions
|
||||
// Description: RFC-3161 timestamp metadata for embedding in predicates.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
/// <summary>
|
||||
/// RFC-3161 timestamp metadata for embedding in predicates.
|
||||
/// </summary>
|
||||
public sealed record Rfc3161TimestampMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the TSA URL that issued the timestamp.
|
||||
/// </summary>
|
||||
public required string TsaUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest of the timestamp token (base64 or hex).
|
||||
/// </summary>
|
||||
public required string TokenDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest algorithm used for the token digest.
|
||||
/// </summary>
|
||||
public string DigestAlgorithm { get; init; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generation time from the TST.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GenerationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA policy OID.
|
||||
/// </summary>
|
||||
public string? PolicyOid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TST serial number.
|
||||
/// </summary>
|
||||
public string? SerialNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA name from the TSTInfo.
|
||||
/// </summary>
|
||||
public string? TsaName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the timestamp has stapled revocation data.
|
||||
/// </summary>
|
||||
public bool HasStapledRevocation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a qualified timestamp (eIDAS).
|
||||
/// </summary>
|
||||
public bool IsQualified { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxTimestampExtension.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Task: ATT-004 - Predicate Writer Extensions
|
||||
// Description: CycloneDX signature.timestamp extension for RFC-3161 timestamps.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Writers;
|
||||
|
||||
/// <summary>
|
||||
/// Extension for adding RFC-3161 timestamp metadata to CycloneDX documents.
|
||||
/// Adds signature.timestamp field per CycloneDX 1.5+ specification.
|
||||
/// </summary>
|
||||
public static class CycloneDxTimestampExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds RFC-3161 timestamp metadata to a CycloneDX JSON document.
|
||||
/// </summary>
|
||||
/// <param name="cycloneDxJson">The CycloneDX JSON bytes.</param>
|
||||
/// <param name="timestampMetadata">The timestamp metadata to add.</param>
|
||||
/// <returns>The modified JSON bytes with timestamp metadata.</returns>
|
||||
public static byte[] AddTimestampMetadata(
|
||||
byte[] cycloneDxJson,
|
||||
Rfc3161TimestampMetadata timestampMetadata)
|
||||
{
|
||||
var jsonNode = JsonNode.Parse(cycloneDxJson)
|
||||
?? throw new InvalidOperationException("Failed to parse CycloneDX JSON");
|
||||
|
||||
// Create the signature.timestamp structure
|
||||
var timestampNode = new JsonObject
|
||||
{
|
||||
["rfc3161"] = new JsonObject
|
||||
{
|
||||
["tsaUrl"] = timestampMetadata.TsaUrl,
|
||||
["tokenDigest"] = $"{timestampMetadata.DigestAlgorithm.ToLowerInvariant()}:{timestampMetadata.TokenDigest}",
|
||||
["generationTime"] = timestampMetadata.GenerationTime.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)
|
||||
}
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
var rfc3161Node = timestampNode["rfc3161"]!.AsObject();
|
||||
if (timestampMetadata.PolicyOid is not null)
|
||||
{
|
||||
rfc3161Node["policyOid"] = timestampMetadata.PolicyOid;
|
||||
}
|
||||
if (timestampMetadata.SerialNumber is not null)
|
||||
{
|
||||
rfc3161Node["serialNumber"] = timestampMetadata.SerialNumber;
|
||||
}
|
||||
if (timestampMetadata.TsaName is not null)
|
||||
{
|
||||
rfc3161Node["tsaName"] = timestampMetadata.TsaName;
|
||||
}
|
||||
if (timestampMetadata.HasStapledRevocation)
|
||||
{
|
||||
rfc3161Node["stapledRevocation"] = true;
|
||||
}
|
||||
if (timestampMetadata.IsQualified)
|
||||
{
|
||||
rfc3161Node["qualified"] = true;
|
||||
}
|
||||
|
||||
// Add or extend signature object
|
||||
if (jsonNode["signature"] is JsonObject signatureNode)
|
||||
{
|
||||
signatureNode["timestamp"] = timestampNode;
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonNode["signature"] = new JsonObject
|
||||
{
|
||||
["timestamp"] = timestampNode
|
||||
};
|
||||
}
|
||||
|
||||
// Serialize with deterministic ordering
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(jsonNode, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts RFC-3161 timestamp metadata from a CycloneDX JSON document.
|
||||
/// </summary>
|
||||
/// <param name="cycloneDxJson">The CycloneDX JSON bytes.</param>
|
||||
/// <returns>The timestamp metadata if present, null otherwise.</returns>
|
||||
public static Rfc3161TimestampMetadata? ExtractTimestampMetadata(byte[] cycloneDxJson)
|
||||
{
|
||||
var jsonNode = JsonNode.Parse(cycloneDxJson);
|
||||
var timestampNode = jsonNode?["signature"]?["timestamp"]?["rfc3161"];
|
||||
|
||||
if (timestampNode is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenDigest = timestampNode["tokenDigest"]?.GetValue<string>() ?? "";
|
||||
var digestAlgorithm = "SHA256";
|
||||
var digestValue = tokenDigest;
|
||||
|
||||
// Parse "sha256:abc123" format
|
||||
if (tokenDigest.Contains(':'))
|
||||
{
|
||||
var parts = tokenDigest.Split(':', 2);
|
||||
digestAlgorithm = parts[0].ToUpperInvariant();
|
||||
digestValue = parts[1];
|
||||
}
|
||||
|
||||
return new Rfc3161TimestampMetadata
|
||||
{
|
||||
TsaUrl = timestampNode["tsaUrl"]?.GetValue<string>() ?? "",
|
||||
TokenDigest = digestValue,
|
||||
DigestAlgorithm = digestAlgorithm,
|
||||
GenerationTime = DateTimeOffset.Parse(
|
||||
timestampNode["generationTime"]?.GetValue<string>() ?? DateTimeOffset.MinValue.ToString("O"),
|
||||
CultureInfo.InvariantCulture),
|
||||
PolicyOid = timestampNode["policyOid"]?.GetValue<string>(),
|
||||
SerialNumber = timestampNode["serialNumber"]?.GetValue<string>(),
|
||||
TsaName = timestampNode["tsaName"]?.GetValue<string>(),
|
||||
HasStapledRevocation = timestampNode["stapledRevocation"]?.GetValue<bool>() ?? false,
|
||||
IsQualified = timestampNode["qualified"]?.GetValue<bool>() ?? false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -50,27 +50,28 @@ public sealed class CycloneDxWriter : ISbomWriter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] Write(SbomDocument document)
|
||||
public SbomWriteResult Write(SbomDocument document)
|
||||
{
|
||||
var cdx = ConvertToCycloneDx(document);
|
||||
return _canonicalizer.Canonicalize(cdx);
|
||||
var canonicalBytes = _canonicalizer.Canonicalize(cdx);
|
||||
var goldenHash = _canonicalizer.ComputeGoldenHash(canonicalBytes);
|
||||
|
||||
return new SbomWriteResult
|
||||
{
|
||||
Format = SbomFormat.CycloneDx,
|
||||
CanonicalBytes = canonicalBytes,
|
||||
GoldenHash = goldenHash,
|
||||
DocumentId = cdx.SerialNumber
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> WriteAsync(SbomDocument document, CancellationToken ct = default)
|
||||
public Task<SbomWriteResult> WriteAsync(SbomDocument document, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(Write(document));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeContentHash(SbomDocument document)
|
||||
{
|
||||
var bytes = Write(document);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private CycloneDxBom ConvertToCycloneDx(SbomDocument document)
|
||||
{
|
||||
// Sort components by bom-ref
|
||||
|
||||
@@ -7,6 +7,32 @@
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Writers;
|
||||
|
||||
/// <summary>
|
||||
/// Result of SBOM write operation.
|
||||
/// </summary>
|
||||
public sealed record SbomWriteResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The format of the generated SBOM.
|
||||
/// </summary>
|
||||
public required Canonicalization.SbomFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The canonical bytes of the SBOM.
|
||||
/// </summary>
|
||||
public required byte[] CanonicalBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The golden hash of the canonical bytes.
|
||||
/// </summary>
|
||||
public required string GoldenHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Document ID.
|
||||
/// </summary>
|
||||
public string? DocumentId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes SBOM documents in deterministic, canonical format.
|
||||
/// </summary>
|
||||
@@ -18,26 +44,19 @@ public interface ISbomWriter
|
||||
Canonicalization.SbomFormat Format { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes an SBOM to canonical bytes.
|
||||
/// Writes an SBOM to canonical format.
|
||||
/// </summary>
|
||||
/// <param name="document">The SBOM document model.</param>
|
||||
/// <returns>Canonical JSON bytes.</returns>
|
||||
byte[] Write(SbomDocument document);
|
||||
/// <returns>Write result containing canonical bytes and hash.</returns>
|
||||
SbomWriteResult Write(SbomDocument document);
|
||||
|
||||
/// <summary>
|
||||
/// Writes an SBOM to canonical bytes asynchronously.
|
||||
/// Writes an SBOM asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="document">The SBOM document model.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Canonical JSON bytes.</returns>
|
||||
Task<byte[]> WriteAsync(SbomDocument document, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the content hash of the canonical SBOM.
|
||||
/// </summary>
|
||||
/// <param name="document">The SBOM document.</param>
|
||||
/// <returns>SHA-256 hash in hex format.</returns>
|
||||
string ComputeContentHash(SbomDocument document);
|
||||
/// <returns>Write result containing canonical bytes and hash.</returns>
|
||||
Task<SbomWriteResult> WriteAsync(SbomDocument document, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SpdxTimestampExtension.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Task: ATT-004 - Predicate Writer Extensions
|
||||
// Description: SPDX 3.0+ annotation extension for RFC-3161 timestamps.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Writers;
|
||||
|
||||
/// <summary>
|
||||
/// Extension for adding RFC-3161 timestamp metadata to SPDX documents.
|
||||
/// Uses SPDX 3.0 annotations for timestamp references.
|
||||
/// </summary>
|
||||
public static class SpdxTimestampExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// The annotation type for RFC-3161 timestamps.
|
||||
/// </summary>
|
||||
public const string TimestampAnnotationType = "OTHER";
|
||||
|
||||
/// <summary>
|
||||
/// The annotator prefix for Stella timestamp annotations.
|
||||
/// </summary>
|
||||
public const string TimestampAnnotator = "Tool: stella-attestor";
|
||||
|
||||
/// <summary>
|
||||
/// Adds RFC-3161 timestamp annotation to an SPDX JSON document.
|
||||
/// </summary>
|
||||
/// <param name="spdxJson">The SPDX JSON bytes.</param>
|
||||
/// <param name="timestampMetadata">The timestamp metadata to add.</param>
|
||||
/// <returns>The modified JSON bytes with timestamp annotation.</returns>
|
||||
public static byte[] AddTimestampAnnotation(
|
||||
byte[] spdxJson,
|
||||
Rfc3161TimestampMetadata timestampMetadata)
|
||||
{
|
||||
var jsonNode = JsonNode.Parse(spdxJson)
|
||||
?? throw new InvalidOperationException("Failed to parse SPDX JSON");
|
||||
|
||||
// Build the comment field with RFC3161 reference
|
||||
var commentParts = new List<string>
|
||||
{
|
||||
$"RFC3161-TST:{timestampMetadata.DigestAlgorithm.ToLowerInvariant()}:{timestampMetadata.TokenDigest}",
|
||||
$"TSA:{timestampMetadata.TsaUrl}"
|
||||
};
|
||||
|
||||
if (timestampMetadata.TsaName is not null)
|
||||
{
|
||||
commentParts.Add($"TSAName:{timestampMetadata.TsaName}");
|
||||
}
|
||||
|
||||
if (timestampMetadata.PolicyOid is not null)
|
||||
{
|
||||
commentParts.Add($"Policy:{timestampMetadata.PolicyOid}");
|
||||
}
|
||||
|
||||
if (timestampMetadata.HasStapledRevocation)
|
||||
{
|
||||
commentParts.Add("Stapled:true");
|
||||
}
|
||||
|
||||
if (timestampMetadata.IsQualified)
|
||||
{
|
||||
commentParts.Add("Qualified:true");
|
||||
}
|
||||
|
||||
var comment = string.Join("; ", commentParts);
|
||||
|
||||
// Create the annotation
|
||||
var annotation = new JsonObject
|
||||
{
|
||||
["annotationType"] = TimestampAnnotationType,
|
||||
["annotator"] = TimestampAnnotator,
|
||||
["annotationDate"] = timestampMetadata.GenerationTime.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture),
|
||||
["comment"] = comment
|
||||
};
|
||||
|
||||
// Add to annotations array
|
||||
if (jsonNode["annotations"] is JsonArray annotationsArray)
|
||||
{
|
||||
annotationsArray.Add(annotation);
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonNode["annotations"] = new JsonArray { annotation };
|
||||
}
|
||||
|
||||
// Serialize with deterministic ordering
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(jsonNode, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts RFC-3161 timestamp metadata from an SPDX JSON document.
|
||||
/// </summary>
|
||||
/// <param name="spdxJson">The SPDX JSON bytes.</param>
|
||||
/// <returns>The timestamp metadata if present, null otherwise.</returns>
|
||||
public static Rfc3161TimestampMetadata? ExtractTimestampMetadata(byte[] spdxJson)
|
||||
{
|
||||
var jsonNode = JsonNode.Parse(spdxJson);
|
||||
var annotationsNode = jsonNode?["annotations"]?.AsArray();
|
||||
|
||||
if (annotationsNode is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the timestamp annotation
|
||||
foreach (var annotation in annotationsNode)
|
||||
{
|
||||
var annotator = annotation?["annotator"]?.GetValue<string>();
|
||||
var comment = annotation?["comment"]?.GetValue<string>();
|
||||
|
||||
if (annotator == TimestampAnnotator && comment?.StartsWith("RFC3161-TST:") == true)
|
||||
{
|
||||
return ParseTimestampComment(
|
||||
comment,
|
||||
annotation?["annotationDate"]?.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Rfc3161TimestampMetadata? ParseTimestampComment(string comment, string? annotationDate)
|
||||
{
|
||||
var parts = comment.Split("; ");
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? digestAlgorithm = null;
|
||||
string? tokenDigest = null;
|
||||
string? tsaUrl = null;
|
||||
string? tsaName = null;
|
||||
string? policyOid = null;
|
||||
bool hasStapledRevocation = false;
|
||||
bool isQualified = false;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.StartsWith("RFC3161-TST:"))
|
||||
{
|
||||
var digestPart = part.Substring("RFC3161-TST:".Length);
|
||||
var colonIdx = digestPart.IndexOf(':');
|
||||
if (colonIdx > 0)
|
||||
{
|
||||
digestAlgorithm = digestPart.Substring(0, colonIdx).ToUpperInvariant();
|
||||
tokenDigest = digestPart.Substring(colonIdx + 1);
|
||||
}
|
||||
}
|
||||
else if (part.StartsWith("TSA:"))
|
||||
{
|
||||
tsaUrl = part.Substring("TSA:".Length);
|
||||
}
|
||||
else if (part.StartsWith("TSAName:"))
|
||||
{
|
||||
tsaName = part.Substring("TSAName:".Length);
|
||||
}
|
||||
else if (part.StartsWith("Policy:"))
|
||||
{
|
||||
policyOid = part.Substring("Policy:".Length);
|
||||
}
|
||||
else if (part == "Stapled:true")
|
||||
{
|
||||
hasStapledRevocation = true;
|
||||
}
|
||||
else if (part == "Qualified:true")
|
||||
{
|
||||
isQualified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenDigest is null || tsaUrl is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTimeOffset generationTime = DateTimeOffset.MinValue;
|
||||
if (annotationDate is not null)
|
||||
{
|
||||
DateTimeOffset.TryParse(annotationDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out generationTime);
|
||||
}
|
||||
|
||||
return new Rfc3161TimestampMetadata
|
||||
{
|
||||
TsaUrl = tsaUrl,
|
||||
TokenDigest = tokenDigest,
|
||||
DigestAlgorithm = digestAlgorithm ?? "SHA256",
|
||||
GenerationTime = generationTime,
|
||||
PolicyOid = policyOid,
|
||||
TsaName = tsaName,
|
||||
HasStapledRevocation = hasStapledRevocation,
|
||||
IsQualified = isQualified
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user