using System.Security.Cryptography; using System.Text; using System.Text.Json; using FluentAssertions; using StellaOps.AirGap.Importer.Validation; namespace StellaOps.AirGap.Importer.Tests.Validation; public sealed class RekorOfflineReceiptVerifierTests { [Fact] public async Task VerifyAsync_ValidReceiptAndCheckpoint_Succeeds() { var temp = Path.Combine(Path.GetTempPath(), "stellaops-rekor-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); try { // Leaf 0 is the DSSE digest we verify for inclusion. var dsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("dsse-envelope")); var otherDsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope")); var leaf0 = HashLeaf(dsseSha256); var leaf1 = HashLeaf(otherDsseSha256); var root = HashInterior(leaf0, leaf1); var rootBase64 = Convert.ToBase64String(root); var treeSize = 2L; var origin = "rekor.sigstore.dev - 2605736670972794746"; var timestamp = "1700000000"; var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n"; using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256); var signatureBase64 = Convert.ToBase64String(signature); var checkpointPath = Path.Combine(temp, "checkpoint.sig"); await File.WriteAllTextAsync( checkpointPath, canonicalBody + $"sig {signatureBase64}\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); var publicKeyPath = Path.Combine(temp, "rekor-pub.pem"); await File.WriteAllTextAsync( publicKeyPath, WrapPem("PUBLIC KEY", ecdsa.ExportSubjectPublicKeyInfo()), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); var receiptPath = Path.Combine(temp, "rekor-receipt.json"); var receiptJson = JsonSerializer.Serialize(new { uuid = "uuid-1", logIndex = 0, rootHash = Convert.ToHexString(root).ToLowerInvariant(), hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() }, checkpoint = "checkpoint.sig" }, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false)); var result = await RekorOfflineReceiptVerifier.VerifyAsync(receiptPath, dsseSha256, publicKeyPath, CancellationToken.None); result.Verified.Should().BeTrue(); result.CheckpointSignatureVerified.Should().BeTrue(); result.RekorUuid.Should().Be("uuid-1"); result.LogIndex.Should().Be(0); result.TreeSize.Should().Be(2); result.ExpectedRootHash.Should().Be(Convert.ToHexString(root).ToLowerInvariant()); result.ComputedRootHash.Should().Be(Convert.ToHexString(root).ToLowerInvariant()); } finally { Directory.Delete(temp, recursive: true); } } [Fact] public async Task VerifyAsync_TamperedCheckpointSignature_Fails() { var temp = Path.Combine(Path.GetTempPath(), "stellaops-rekor-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); try { var dsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("dsse-envelope")); var otherDsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope")); var leaf0 = HashLeaf(dsseSha256); var leaf1 = HashLeaf(otherDsseSha256); var root = HashInterior(leaf0, leaf1); var rootBase64 = Convert.ToBase64String(root); var treeSize = 2L; var origin = "rekor.sigstore.dev - 2605736670972794746"; var timestamp = "1700000000"; var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n"; using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256); signature[0] ^= 0xFF; // tamper var checkpointPath = Path.Combine(temp, "checkpoint.sig"); await File.WriteAllTextAsync( checkpointPath, canonicalBody + $"sig {Convert.ToBase64String(signature)}\n", new UTF8Encoding(false)); var publicKeyPath = Path.Combine(temp, "rekor-pub.pem"); await File.WriteAllTextAsync( publicKeyPath, WrapPem("PUBLIC KEY", ecdsa.ExportSubjectPublicKeyInfo()), new UTF8Encoding(false)); var receiptPath = Path.Combine(temp, "rekor-receipt.json"); var receiptJson = JsonSerializer.Serialize(new { uuid = "uuid-1", logIndex = 0, rootHash = Convert.ToHexString(root).ToLowerInvariant(), hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() }, checkpoint = "checkpoint.sig" }, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false)); var result = await RekorOfflineReceiptVerifier.VerifyAsync(receiptPath, dsseSha256, publicKeyPath, CancellationToken.None); result.Verified.Should().BeFalse(); result.FailureReason.Should().Contain("checkpoint signature", because: result.FailureReason); } finally { Directory.Delete(temp, recursive: true); } } private static byte[] HashLeaf(byte[] leafData) { var buffer = new byte[1 + leafData.Length]; buffer[0] = 0x00; leafData.CopyTo(buffer, 1); return SHA256.HashData(buffer); } private static byte[] HashInterior(byte[] left, byte[] right) { var buffer = new byte[1 + left.Length + right.Length]; buffer[0] = 0x01; left.CopyTo(buffer, 1); right.CopyTo(buffer, 1 + left.Length); return SHA256.HashData(buffer); } private static string WrapPem(string label, byte[] derBytes) { var base64 = Convert.ToBase64String(derBytes); var sb = new StringBuilder(); sb.AppendLine($"-----BEGIN {label}-----"); for (var i = 0; i < base64.Length; i += 64) { sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i))); } sb.AppendLine($"-----END {label}-----"); return sb.ToString(); } }