|
|
|
|
@@ -0,0 +1,288 @@
|
|
|
|
|
using System;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using Spectre.Console;
|
|
|
|
|
using Spectre.Console.Testing;
|
|
|
|
|
using StellaOps.Cli.Commands;
|
|
|
|
|
using StellaOps.Cli.Telemetry;
|
|
|
|
|
using StellaOps.Cli.Tests.Testing;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Cli.Tests.Commands;
|
|
|
|
|
|
|
|
|
|
public sealed class VerifyOfflineCommandHandlersTests
|
|
|
|
|
{
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task HandleVerifyOfflineAsync_WhenEvidenceAndPolicyValid_PassesAndWritesGraph()
|
|
|
|
|
{
|
|
|
|
|
using var temp = new TempDirectory();
|
|
|
|
|
var evidenceDir = Path.Combine(temp.Path, "evidence");
|
|
|
|
|
Directory.CreateDirectory(evidenceDir);
|
|
|
|
|
|
|
|
|
|
var policyDir = Path.Combine(evidenceDir, "policy");
|
|
|
|
|
var keysDir = Path.Combine(evidenceDir, "keys", "identities");
|
|
|
|
|
var tlogKeysDir = Path.Combine(evidenceDir, "keys", "tlog-root");
|
|
|
|
|
var attestationsDir = Path.Combine(evidenceDir, "attestations");
|
|
|
|
|
var tlogDir = Path.Combine(evidenceDir, "tlog");
|
|
|
|
|
Directory.CreateDirectory(policyDir);
|
|
|
|
|
Directory.CreateDirectory(keysDir);
|
|
|
|
|
Directory.CreateDirectory(tlogKeysDir);
|
|
|
|
|
Directory.CreateDirectory(attestationsDir);
|
|
|
|
|
Directory.CreateDirectory(tlogDir);
|
|
|
|
|
|
|
|
|
|
// Artifact under test.
|
|
|
|
|
var artifactBytes = Encoding.UTF8.GetBytes("artifact-content");
|
|
|
|
|
var artifactDigest = ComputeSha256Hex(artifactBytes);
|
|
|
|
|
var artifact = $"sha256:{artifactDigest}";
|
|
|
|
|
|
|
|
|
|
// DSSE trust-root key (RSA-PSS) used by DsseVerifier.
|
|
|
|
|
using var rsa = RSA.Create(2048);
|
|
|
|
|
var rsaPublicKeyDer = rsa.ExportSubjectPublicKeyInfo();
|
|
|
|
|
var fingerprint = ComputeSha256Hex(rsaPublicKeyDer);
|
|
|
|
|
var vendorKeyPath = Path.Combine(keysDir, "vendor_A.pub");
|
|
|
|
|
await File.WriteAllTextAsync(vendorKeyPath, WrapPem("PUBLIC KEY", rsaPublicKeyDer), CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
var attestationPath = Path.Combine(attestationsDir, "provenance.intoto.json");
|
|
|
|
|
await WriteDsseProvenanceAttestationAsync(attestationPath, rsa, fingerprint, artifactDigest, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
// Rekor offline proof material.
|
|
|
|
|
using var rekorEcdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
|
|
|
var dsseFileBytes = await File.ReadAllBytesAsync(attestationPath, CancellationToken.None);
|
|
|
|
|
var dsseSha256 = SHA256.HashData(dsseFileBytes);
|
|
|
|
|
var otherLeaf = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope"));
|
|
|
|
|
|
|
|
|
|
var leaf0 = HashLeaf(dsseSha256);
|
|
|
|
|
var leaf1 = HashLeaf(otherLeaf);
|
|
|
|
|
var root = HashInterior(leaf0, leaf1);
|
|
|
|
|
|
|
|
|
|
var checkpointPath = Path.Combine(tlogDir, "checkpoint.sig");
|
|
|
|
|
await WriteCheckpointAsync(checkpointPath, rekorEcdsa, root, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
var rekorPubKeyPath = Path.Combine(tlogKeysDir, "rekor-pub.pem");
|
|
|
|
|
await File.WriteAllTextAsync(rekorPubKeyPath, WrapPem("PUBLIC KEY", rekorEcdsa.ExportSubjectPublicKeyInfo()), CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
var receiptPath = Path.Combine(attestationsDir, "provenance.intoto.rekor.json");
|
|
|
|
|
var receiptJson = JsonSerializer.Serialize(new
|
|
|
|
|
{
|
|
|
|
|
uuid = "uuid-1",
|
|
|
|
|
logIndex = 0,
|
|
|
|
|
rootHash = Convert.ToHexString(root).ToLowerInvariant(),
|
|
|
|
|
hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
|
|
|
|
|
checkpoint = "../tlog/checkpoint.sig"
|
|
|
|
|
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
|
|
|
|
await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false), CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
// Policy (YAML), resolved under evidence-dir/policy by the handler.
|
|
|
|
|
var policyPath = Path.Combine(policyDir, "verify-policy.yaml");
|
|
|
|
|
var policyYaml = """
|
|
|
|
|
keys:
|
|
|
|
|
- ./evidence/keys/identities/vendor_A.pub
|
|
|
|
|
tlog:
|
|
|
|
|
mode: "offline"
|
|
|
|
|
checkpoint: "./evidence/tlog/checkpoint.sig"
|
|
|
|
|
entry_pack: "./evidence/tlog/entries"
|
|
|
|
|
attestations:
|
|
|
|
|
required:
|
|
|
|
|
- type: slsa-provenance
|
|
|
|
|
optional: []
|
|
|
|
|
constraints:
|
|
|
|
|
subjects:
|
|
|
|
|
alg: "sha256"
|
|
|
|
|
certs:
|
|
|
|
|
allowed_issuers:
|
|
|
|
|
- "https://fulcio.offline"
|
|
|
|
|
allow_expired_if_timepinned: true
|
|
|
|
|
""";
|
|
|
|
|
await File.WriteAllTextAsync(policyPath, policyYaml, new UTF8Encoding(false), CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
using var services = BuildServices();
|
|
|
|
|
var outputRoot = Path.Combine(temp.Path, "out");
|
|
|
|
|
|
|
|
|
|
var originalExitCode = Environment.ExitCode;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleVerifyOfflineAsync(
|
|
|
|
|
services,
|
|
|
|
|
evidenceDirectory: evidenceDir,
|
|
|
|
|
artifactDigest: artifact,
|
|
|
|
|
policyPath: "verify-policy.yaml",
|
|
|
|
|
outputDirectory: outputRoot,
|
|
|
|
|
outputFormat: "json",
|
|
|
|
|
verbose: false,
|
|
|
|
|
cancellationToken: CancellationToken.None));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(OfflineExitCodes.Success, Environment.ExitCode);
|
|
|
|
|
|
|
|
|
|
using var document = JsonDocument.Parse(output.Console.Trim());
|
|
|
|
|
Assert.Equal("passed", document.RootElement.GetProperty("status").GetString());
|
|
|
|
|
Assert.Equal(OfflineExitCodes.Success, document.RootElement.GetProperty("exitCode").GetInt32());
|
|
|
|
|
Assert.Equal(artifact, document.RootElement.GetProperty("artifact").GetString());
|
|
|
|
|
|
|
|
|
|
var outputDir = document.RootElement.GetProperty("outputDir").GetString();
|
|
|
|
|
Assert.False(string.IsNullOrWhiteSpace(outputDir));
|
|
|
|
|
Assert.True(File.Exists(Path.Combine(outputDir!, "evidence-graph.json")));
|
|
|
|
|
Assert.True(File.Exists(Path.Combine(outputDir!, "evidence-graph.sha256")));
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Environment.ExitCode = originalExitCode;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static ServiceProvider BuildServices()
|
|
|
|
|
{
|
|
|
|
|
var services = new ServiceCollection();
|
|
|
|
|
|
|
|
|
|
services.AddSingleton(new VerbosityState());
|
|
|
|
|
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None)));
|
|
|
|
|
|
|
|
|
|
return services.BuildServiceProvider();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task<CapturedConsoleOutput> CaptureTestConsoleAsync(Func<TestConsole, Task> action)
|
|
|
|
|
{
|
|
|
|
|
var testConsole = new TestConsole();
|
|
|
|
|
testConsole.Width(4000);
|
|
|
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
|
var originalOut = Console.Out;
|
|
|
|
|
using var writer = new StringWriter();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
AnsiConsole.Console = testConsole;
|
|
|
|
|
Console.SetOut(writer);
|
|
|
|
|
await action(testConsole).ConfigureAwait(false);
|
|
|
|
|
return new CapturedConsoleOutput(testConsole.Output.ToString(), writer.ToString());
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Console.SetOut(originalOut);
|
|
|
|
|
AnsiConsole.Console = originalConsole;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task WriteDsseProvenanceAttestationAsync(
|
|
|
|
|
string path,
|
|
|
|
|
RSA signingKey,
|
|
|
|
|
string keyId,
|
|
|
|
|
string artifactSha256Hex,
|
|
|
|
|
CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var statementJson = JsonSerializer.Serialize(new
|
|
|
|
|
{
|
|
|
|
|
_type = "https://in-toto.io/Statement/v1",
|
|
|
|
|
predicateType = "https://slsa.dev/provenance/v1",
|
|
|
|
|
subject = new[]
|
|
|
|
|
{
|
|
|
|
|
new
|
|
|
|
|
{
|
|
|
|
|
name = "artifact",
|
|
|
|
|
digest = new
|
|
|
|
|
{
|
|
|
|
|
sha256 = artifactSha256Hex
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
predicate = new { }
|
|
|
|
|
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
|
|
|
|
|
|
|
|
|
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
|
|
|
|
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBase64);
|
|
|
|
|
var signature = Convert.ToBase64String(signingKey.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
|
|
|
|
|
|
|
|
|
|
var envelopeJson = JsonSerializer.Serialize(new
|
|
|
|
|
{
|
|
|
|
|
payloadType = "application/vnd.in-toto+json",
|
|
|
|
|
payload = payloadBase64,
|
|
|
|
|
signatures = new[]
|
|
|
|
|
{
|
|
|
|
|
new { keyid = keyId, sig = signature }
|
|
|
|
|
}
|
|
|
|
|
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
|
|
|
|
|
|
|
|
|
await File.WriteAllTextAsync(path, envelopeJson, new UTF8Encoding(false), ct);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static byte[] BuildDssePae(string payloadType, string payloadBase64)
|
|
|
|
|
{
|
|
|
|
|
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
|
|
|
|
var payloadText = Encoding.UTF8.GetString(payloadBytes);
|
|
|
|
|
var parts = new[]
|
|
|
|
|
{
|
|
|
|
|
"DSSEv1",
|
|
|
|
|
payloadType,
|
|
|
|
|
payloadText
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var builder = new StringBuilder();
|
|
|
|
|
builder.Append("PAE:");
|
|
|
|
|
builder.Append(parts.Length);
|
|
|
|
|
foreach (var part in parts)
|
|
|
|
|
{
|
|
|
|
|
builder.Append(' ');
|
|
|
|
|
builder.Append(part.Length);
|
|
|
|
|
builder.Append(' ');
|
|
|
|
|
builder.Append(part);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Encoding.UTF8.GetBytes(builder.ToString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task WriteCheckpointAsync(string path, ECDsa signingKey, byte[] rootHash, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var origin = "rekor.sigstore.dev - 2605736670972794746";
|
|
|
|
|
var treeSize = 2L;
|
|
|
|
|
var rootBase64 = Convert.ToBase64String(rootHash);
|
|
|
|
|
var timestamp = "1700000000";
|
|
|
|
|
var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n";
|
|
|
|
|
|
|
|
|
|
var signature = signingKey.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256);
|
|
|
|
|
var signatureBase64 = Convert.ToBase64String(signature);
|
|
|
|
|
|
|
|
|
|
await File.WriteAllTextAsync(path, canonicalBody + $"sig {signatureBase64}\n", new UTF8Encoding(false), ct);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ComputeSha256Hex(byte[] bytes)
|
|
|
|
|
{
|
|
|
|
|
var hash = SHA256.HashData(bytes);
|
|
|
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string WrapPem(string label, byte[] derBytes)
|
|
|
|
|
{
|
|
|
|
|
var base64 = Convert.ToBase64String(derBytes);
|
|
|
|
|
var builder = new StringBuilder();
|
|
|
|
|
builder.Append("-----BEGIN ").Append(label).AppendLine("-----");
|
|
|
|
|
for (var offset = 0; offset < base64.Length; offset += 64)
|
|
|
|
|
{
|
|
|
|
|
builder.AppendLine(base64.Substring(offset, Math.Min(64, base64.Length - offset)));
|
|
|
|
|
}
|
|
|
|
|
builder.Append("-----END ").Append(label).AppendLine("-----");
|
|
|
|
|
return builder.ToString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed record CapturedConsoleOutput(string Console, string Plain);
|
|
|
|
|
}
|
|
|
|
|
|