875 lines
30 KiB
C#
875 lines
30 KiB
C#
// -----------------------------------------------------------------------------
|
|
// DeltaSigCommandGroup.cs
|
|
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
|
|
// Task: DSP-007 - Add CLI commands for delta-sig operations
|
|
// Description: CLI commands for delta-sig diff, attest, verify, and gate operations
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StellaOps.Attestor.Core.Rekor;
|
|
using StellaOps.Attestor.Core.Submission;
|
|
using StellaOps.BinaryIndex.DeltaSig;
|
|
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
|
using StellaOps.BinaryIndex.DeltaSig.Policy;
|
|
using StellaOps.Cli.Extensions;
|
|
using System.CommandLine;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Cli.Commands.Binary;
|
|
|
|
/// <summary>
|
|
/// CLI command group for delta-sig binary diff operations.
|
|
/// </summary>
|
|
internal static class DeltaSigCommandGroup
|
|
{
|
|
/// <summary>
|
|
/// Builds the delta-sig command group.
|
|
/// </summary>
|
|
internal static Command BuildDeltaSigCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var deltaSig = new Command("delta-sig", "Binary delta signature operations for patch verification.");
|
|
|
|
deltaSig.Add(BuildDiffCommand(services, verboseOption, cancellationToken));
|
|
deltaSig.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
|
|
deltaSig.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
|
|
deltaSig.Add(BuildGateCommand(services, verboseOption, cancellationToken));
|
|
|
|
return deltaSig;
|
|
}
|
|
|
|
/// <summary>
|
|
/// stella binary delta-sig diff - Generate delta-sig predicate from two binaries.
|
|
/// </summary>
|
|
private static Command BuildDiffCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var oldFileArg = new Argument<string>("old-file")
|
|
{
|
|
Description = "Path to the original (vulnerable) binary."
|
|
};
|
|
|
|
var newFileArg = new Argument<string>("new-file")
|
|
{
|
|
Description = "Path to the patched binary."
|
|
};
|
|
|
|
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
|
{
|
|
Description = "Output file path (default: stdout)."
|
|
};
|
|
|
|
var archOption = new Option<string?>("--arch", new[] { "-a" })
|
|
{
|
|
Description = "Architecture hint (e.g., linux-amd64, linux-arm64)."
|
|
};
|
|
|
|
var cveOption = new Option<string[]>("--cve")
|
|
{
|
|
Description = "CVE IDs associated with the patch."
|
|
}.SetDefaultValue(Array.Empty<string>());
|
|
|
|
var packageOption = new Option<string?>("--package", new[] { "-p" })
|
|
{
|
|
Description = "Package name."
|
|
};
|
|
|
|
var oldVersionOption = new Option<string?>("--old-version")
|
|
{
|
|
Description = "Version of the old binary."
|
|
};
|
|
|
|
var newVersionOption = new Option<string?>("--new-version")
|
|
{
|
|
Description = "Version of the new binary."
|
|
};
|
|
|
|
var lifterOption = new Option<string>("--lifter")
|
|
{
|
|
Description = "Preferred binary lifter (b2r2, ghidra)."
|
|
}.SetDefaultValue("b2r2").FromAmong("b2r2", "ghidra");
|
|
|
|
var semanticOption = new Option<bool>("--semantic")
|
|
{
|
|
Description = "Compute semantic similarity using BSim."
|
|
};
|
|
|
|
var formatOption = new Option<string>("--format", new[] { "-f" })
|
|
{
|
|
Description = "Output format: json (default), yaml."
|
|
}.SetDefaultValue("json").FromAmong("json", "yaml");
|
|
|
|
var command = new Command("diff", "Generate a delta-sig predicate from two binaries.")
|
|
{
|
|
oldFileArg,
|
|
newFileArg,
|
|
outputOption,
|
|
archOption,
|
|
cveOption,
|
|
packageOption,
|
|
oldVersionOption,
|
|
newVersionOption,
|
|
lifterOption,
|
|
semanticOption,
|
|
formatOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async parseResult =>
|
|
{
|
|
var oldFile = parseResult.GetValue(oldFileArg)!;
|
|
var newFile = parseResult.GetValue(newFileArg)!;
|
|
var output = parseResult.GetValue(outputOption);
|
|
var arch = parseResult.GetValue(archOption);
|
|
var cves = parseResult.GetValue(cveOption) ?? [];
|
|
var package = parseResult.GetValue(packageOption);
|
|
var oldVersion = parseResult.GetValue(oldVersionOption);
|
|
var newVersion = parseResult.GetValue(newVersionOption);
|
|
var lifter = parseResult.GetValue(lifterOption)!;
|
|
var semantic = parseResult.GetValue(semanticOption);
|
|
var format = parseResult.GetValue(formatOption)!;
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await HandleDiffAsync(
|
|
services,
|
|
oldFile,
|
|
newFile,
|
|
output,
|
|
arch,
|
|
cves.ToList(),
|
|
package,
|
|
oldVersion,
|
|
newVersion,
|
|
lifter,
|
|
semantic,
|
|
format,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
/// <summary>
|
|
/// stella binary delta-sig attest - Sign and submit delta-sig to Rekor.
|
|
/// </summary>
|
|
private static Command BuildAttestCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var predicateFileArg = new Argument<string>("predicate-file")
|
|
{
|
|
Description = "Path to delta-sig predicate JSON file."
|
|
};
|
|
|
|
var keyOption = new Option<string?>("--key", new[] { "-k" })
|
|
{
|
|
Description = "Signing key identifier (uses default if not specified)."
|
|
};
|
|
|
|
var rekorOption = new Option<string?>("--rekor-url")
|
|
{
|
|
Description = "Rekor server URL (default: https://rekor.sigstore.dev)."
|
|
};
|
|
|
|
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
|
{
|
|
Description = "Output file for DSSE envelope."
|
|
};
|
|
|
|
var dryRunOption = new Option<bool>("--dry-run")
|
|
{
|
|
Description = "Create envelope without submitting to Rekor."
|
|
};
|
|
|
|
// Sprint 040-05: Receipt output option
|
|
var receiptOption = new Option<string?>("--receipt")
|
|
{
|
|
Description = "Output path for Rekor receipt (JSON with logIndex, uuid, inclusionProof)."
|
|
};
|
|
|
|
var command = new Command("attest", "Sign and submit a delta-sig predicate to Rekor.")
|
|
{
|
|
predicateFileArg,
|
|
keyOption,
|
|
rekorOption,
|
|
outputOption,
|
|
dryRunOption,
|
|
receiptOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async parseResult =>
|
|
{
|
|
var predicateFile = parseResult.GetValue(predicateFileArg)!;
|
|
var key = parseResult.GetValue(keyOption);
|
|
var rekorUrl = parseResult.GetValue(rekorOption);
|
|
var output = parseResult.GetValue(outputOption);
|
|
var dryRun = parseResult.GetValue(dryRunOption);
|
|
var receipt = parseResult.GetValue(receiptOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await HandleAttestAsync(
|
|
services,
|
|
predicateFile,
|
|
key,
|
|
rekorUrl,
|
|
output,
|
|
receipt,
|
|
dryRun,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
/// <summary>
|
|
/// stella binary delta-sig verify - Verify a binary against a delta-sig predicate.
|
|
/// </summary>
|
|
private static Command BuildVerifyCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var predicateArg = new Argument<string>("predicate")
|
|
{
|
|
Description = "Path to delta-sig predicate or Rekor entry UUID."
|
|
};
|
|
|
|
var binaryArg = new Argument<string>("binary")
|
|
{
|
|
Description = "Path to binary file to verify."
|
|
};
|
|
|
|
var rekorOption = new Option<string?>("--rekor-url")
|
|
{
|
|
Description = "Rekor server URL for fetching remote predicates."
|
|
};
|
|
|
|
var formatOption = new Option<string>("--format", new[] { "-f" })
|
|
{
|
|
Description = "Output format: text (default), json."
|
|
}.SetDefaultValue("text").FromAmong("text", "json");
|
|
|
|
var command = new Command("verify", "Verify a binary against a delta-sig predicate.")
|
|
{
|
|
predicateArg,
|
|
binaryArg,
|
|
rekorOption,
|
|
formatOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async parseResult =>
|
|
{
|
|
var predicate = parseResult.GetValue(predicateArg)!;
|
|
var binary = parseResult.GetValue(binaryArg)!;
|
|
var rekorUrl = parseResult.GetValue(rekorOption);
|
|
var format = parseResult.GetValue(formatOption)!;
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await HandleVerifyAsync(
|
|
services,
|
|
predicate,
|
|
binary,
|
|
rekorUrl,
|
|
format,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
/// <summary>
|
|
/// stella binary delta-sig gate - Evaluate delta-sig against policy constraints.
|
|
/// </summary>
|
|
private static Command BuildGateCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var predicateArg = new Argument<string>("predicate")
|
|
{
|
|
Description = "Path to delta-sig predicate JSON file."
|
|
};
|
|
|
|
var maxModifiedOption = new Option<int?>("--max-modified")
|
|
{
|
|
Description = "Maximum modified functions allowed."
|
|
};
|
|
|
|
var maxAddedOption = new Option<int?>("--max-added")
|
|
{
|
|
Description = "Maximum added functions allowed."
|
|
};
|
|
|
|
var maxRemovedOption = new Option<int?>("--max-removed")
|
|
{
|
|
Description = "Maximum removed functions allowed."
|
|
};
|
|
|
|
var maxBytesOption = new Option<long?>("--max-bytes")
|
|
{
|
|
Description = "Maximum bytes changed allowed."
|
|
};
|
|
|
|
var minSimilarityOption = new Option<double?>("--min-similarity")
|
|
{
|
|
Description = "Minimum semantic similarity (0.0-1.0)."
|
|
};
|
|
|
|
var formatOption = new Option<string>("--format", new[] { "-f" })
|
|
{
|
|
Description = "Output format: text (default), json."
|
|
}.SetDefaultValue("text").FromAmong("text", "json");
|
|
|
|
var command = new Command("gate", "Evaluate a delta-sig against policy constraints.")
|
|
{
|
|
predicateArg,
|
|
maxModifiedOption,
|
|
maxAddedOption,
|
|
maxRemovedOption,
|
|
maxBytesOption,
|
|
minSimilarityOption,
|
|
formatOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async parseResult =>
|
|
{
|
|
var predicate = parseResult.GetValue(predicateArg)!;
|
|
var maxModified = parseResult.GetValue(maxModifiedOption);
|
|
var maxAdded = parseResult.GetValue(maxAddedOption);
|
|
var maxRemoved = parseResult.GetValue(maxRemovedOption);
|
|
var maxBytes = parseResult.GetValue(maxBytesOption);
|
|
var minSimilarity = parseResult.GetValue(minSimilarityOption);
|
|
var format = parseResult.GetValue(formatOption)!;
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await HandleGateAsync(
|
|
services,
|
|
predicate,
|
|
maxModified,
|
|
maxAdded,
|
|
maxRemoved,
|
|
maxBytes,
|
|
minSimilarity,
|
|
format,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
// Handler implementations
|
|
|
|
private static async Task HandleDiffAsync(
|
|
IServiceProvider services,
|
|
string oldFile,
|
|
string newFile,
|
|
string? output,
|
|
string? arch,
|
|
IReadOnlyList<string> cves,
|
|
string? package,
|
|
string? oldVersion,
|
|
string? newVersion,
|
|
string lifter,
|
|
bool semantic,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var deltaSigService = services.GetRequiredService<IDeltaSigService>();
|
|
var console = Console.Out;
|
|
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Generating delta-sig: {oldFile} -> {newFile}");
|
|
}
|
|
|
|
// Open binary streams
|
|
await using var oldStream = File.OpenRead(oldFile);
|
|
await using var newStream = File.OpenRead(newFile);
|
|
|
|
var oldFileInfo = new FileInfo(oldFile);
|
|
var newFileInfo = new FileInfo(newFile);
|
|
|
|
// Compute digests
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var oldDigest = Convert.ToHexString(await sha256.ComputeHashAsync(oldStream, ct)).ToLowerInvariant();
|
|
oldStream.Position = 0;
|
|
var newDigest = Convert.ToHexString(await sha256.ComputeHashAsync(newStream, ct)).ToLowerInvariant();
|
|
newStream.Position = 0;
|
|
|
|
var request = new DeltaSigRequest
|
|
{
|
|
OldBinary = new BinaryReference
|
|
{
|
|
Uri = $"file://{oldFile}",
|
|
Digest = new Dictionary<string, string> { ["sha256"] = oldDigest },
|
|
Content = oldStream,
|
|
Filename = oldFileInfo.Name,
|
|
Size = oldFileInfo.Length
|
|
},
|
|
NewBinary = new BinaryReference
|
|
{
|
|
Uri = $"file://{newFile}",
|
|
Digest = new Dictionary<string, string> { ["sha256"] = newDigest },
|
|
Content = newStream,
|
|
Filename = newFileInfo.Name,
|
|
Size = newFileInfo.Length
|
|
},
|
|
Architecture = arch ?? "unknown",
|
|
CveIds = cves,
|
|
PackageName = package,
|
|
OldVersion = oldVersion,
|
|
NewVersion = newVersion,
|
|
PreferredLifter = lifter,
|
|
ComputeSemanticSimilarity = semantic
|
|
};
|
|
|
|
var predicate = await deltaSigService.GenerateAsync(request, ct);
|
|
|
|
// Serialize output
|
|
var json = System.Text.Json.JsonSerializer.Serialize(predicate, new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
});
|
|
|
|
if (!string.IsNullOrEmpty(output))
|
|
{
|
|
await File.WriteAllTextAsync(output, json, ct);
|
|
await console.WriteLineAsync($"Delta-sig written to: {output}");
|
|
}
|
|
else
|
|
{
|
|
await console.WriteLineAsync(json);
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Summary: {predicate.Summary.FunctionsModified} modified, " +
|
|
$"{predicate.Summary.FunctionsAdded} added, " +
|
|
$"{predicate.Summary.FunctionsRemoved} removed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sprint 040-05: Sign predicate and submit to Rekor.
|
|
/// </summary>
|
|
private static async Task HandleAttestAsync(
|
|
IServiceProvider services,
|
|
string predicateFile,
|
|
string? key,
|
|
string? rekorUrl,
|
|
string? output,
|
|
string? receiptPath,
|
|
bool dryRun,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var console = Console.Out;
|
|
|
|
// Read predicate
|
|
var json = await File.ReadAllTextAsync(predicateFile, ct);
|
|
DeltaSigPredicate? predicate;
|
|
try
|
|
{
|
|
predicate = JsonSerializer.Deserialize<DeltaSigPredicate>(json);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
Console.Error.WriteLine($"Failed to parse predicate file: {ex.Message}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (predicate is null)
|
|
{
|
|
Console.Error.WriteLine("Failed to parse predicate file.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Loaded predicate with {predicate.Delta.Count} function deltas");
|
|
}
|
|
|
|
// Build envelope
|
|
var builder = new DeltaSigEnvelopeBuilder();
|
|
var (payloadType, payload, pae) = builder.PrepareForSigning(predicate);
|
|
|
|
if (dryRun)
|
|
{
|
|
await console.WriteLineAsync("Dry run - envelope prepared but not submitted.");
|
|
await console.WriteLineAsync($"Payload type: {payloadType}");
|
|
await console.WriteLineAsync($"Payload size: {payload.Length} bytes");
|
|
return;
|
|
}
|
|
|
|
// Sign the PAE using the configured key
|
|
byte[] signature;
|
|
string keyId;
|
|
|
|
if (!string.IsNullOrEmpty(key) && File.Exists(key))
|
|
{
|
|
var keyPem = await File.ReadAllTextAsync(key, ct);
|
|
(signature, keyId) = SignWithEcdsaKey(pae, keyPem, key);
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Signed with key: {keyId}");
|
|
}
|
|
}
|
|
else if (!string.IsNullOrEmpty(key))
|
|
{
|
|
// Key reference (KMS URI or other identifier) - use as key ID with HMAC placeholder
|
|
keyId = key;
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
|
|
signature = hmac.ComputeHash(pae);
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Signed with key reference: {keyId}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Console.Error.WriteLine("Error: --key is required for signing. Provide a PEM file path or key reference.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Create DSSE envelope JSON
|
|
var payloadBase64 = Convert.ToBase64String(payload);
|
|
var sigBase64 = Convert.ToBase64String(signature);
|
|
var envelope = new
|
|
{
|
|
payloadType,
|
|
payload = payloadBase64,
|
|
signatures = new[]
|
|
{
|
|
new { keyid = keyId, sig = sigBase64 }
|
|
}
|
|
};
|
|
|
|
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
|
|
|
|
// Write DSSE envelope
|
|
if (!string.IsNullOrEmpty(output))
|
|
{
|
|
await File.WriteAllTextAsync(output, envelopeJson, ct);
|
|
await console.WriteLineAsync($"DSSE envelope written to: {output}");
|
|
}
|
|
else
|
|
{
|
|
await console.WriteLineAsync(envelopeJson);
|
|
}
|
|
|
|
// Submit to Rekor if URL specified
|
|
if (!string.IsNullOrEmpty(rekorUrl))
|
|
{
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Submitting to Rekor: {rekorUrl}");
|
|
}
|
|
|
|
var rekorClient = services.GetService<IRekorClient>();
|
|
if (rekorClient is null)
|
|
{
|
|
Console.Error.WriteLine("Warning: IRekorClient not configured. Rekor submission skipped.");
|
|
Console.Error.WriteLine("Register IRekorClient in DI to enable Rekor transparency log submission.");
|
|
return;
|
|
}
|
|
|
|
var payloadDigest = SHA256.HashData(payload);
|
|
var submissionRequest = new AttestorSubmissionRequest
|
|
{
|
|
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
|
{
|
|
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
|
{
|
|
PayloadType = payloadType,
|
|
PayloadBase64 = payloadBase64,
|
|
Signatures = new List<AttestorSubmissionRequest.DsseSignature>
|
|
{
|
|
new() { KeyId = keyId, Signature = sigBase64 }
|
|
}
|
|
},
|
|
Mode = "keyed"
|
|
},
|
|
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
|
{
|
|
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
|
{
|
|
Sha256 = Convert.ToHexStringLower(payloadDigest),
|
|
Kind = "deltasig"
|
|
},
|
|
BundleSha256 = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson)))
|
|
}
|
|
};
|
|
|
|
var backend = new RekorBackend
|
|
{
|
|
Name = "cli-submit",
|
|
Url = new Uri(rekorUrl)
|
|
};
|
|
|
|
try
|
|
{
|
|
var response = await rekorClient.SubmitAsync(submissionRequest, backend, ct);
|
|
|
|
await console.WriteLineAsync();
|
|
await console.WriteLineAsync($"Rekor entry created:");
|
|
await console.WriteLineAsync($" Log index: {response.Index}");
|
|
await console.WriteLineAsync($" UUID: {response.Uuid}");
|
|
if (!string.IsNullOrEmpty(response.LogUrl))
|
|
{
|
|
await console.WriteLineAsync($" URL: {response.LogUrl}");
|
|
}
|
|
|
|
// Save receipt if path specified
|
|
if (!string.IsNullOrEmpty(receiptPath))
|
|
{
|
|
var receiptJson = JsonSerializer.Serialize(new
|
|
{
|
|
response.Uuid,
|
|
response.Index,
|
|
response.LogUrl,
|
|
response.Status,
|
|
response.IntegratedTime,
|
|
Proof = response.Proof
|
|
}, new JsonSerializerOptions { WriteIndented = true });
|
|
|
|
await File.WriteAllTextAsync(receiptPath, receiptJson, ct);
|
|
await console.WriteLineAsync($" Receipt: {receiptPath}");
|
|
}
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Rekor submission failed: {ex.Message}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
Console.Error.WriteLine("Rekor submission timed out.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Signs PAE data using an EC key loaded from PEM file.
|
|
/// Falls back to HMAC if the key format is not recognized.
|
|
/// </summary>
|
|
private static (byte[] Signature, string KeyId) SignWithEcdsaKey(byte[] pae, string pemContent, string keyPath)
|
|
{
|
|
var keyId = Path.GetFileNameWithoutExtension(keyPath);
|
|
|
|
try
|
|
{
|
|
using var ecdsa = ECDsa.Create();
|
|
ecdsa.ImportFromPem(pemContent);
|
|
var signature = ecdsa.SignData(pae, HashAlgorithmName.SHA256);
|
|
return (signature, keyId);
|
|
}
|
|
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
|
{
|
|
// Not an EC key - try RSA
|
|
}
|
|
|
|
try
|
|
{
|
|
using var rsa = RSA.Create();
|
|
rsa.ImportFromPem(pemContent);
|
|
var signature = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
return (signature, keyId);
|
|
}
|
|
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
|
{
|
|
// Not an RSA key either - fall back to HMAC
|
|
}
|
|
|
|
// Fallback: HMAC with key file content as key material
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(pemContent));
|
|
return (hmac.ComputeHash(pae), keyId);
|
|
}
|
|
|
|
private static async Task HandleVerifyAsync(
|
|
IServiceProvider services,
|
|
string predicateArg,
|
|
string binary,
|
|
string? rekorUrl,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var deltaSigService = services.GetRequiredService<IDeltaSigService>();
|
|
var console = Console.Out;
|
|
|
|
// Load predicate
|
|
DeltaSigPredicate predicate;
|
|
if (File.Exists(predicateArg))
|
|
{
|
|
var json = await File.ReadAllTextAsync(predicateArg, ct);
|
|
predicate = System.Text.Json.JsonSerializer.Deserialize<DeltaSigPredicate>(json)!;
|
|
}
|
|
else
|
|
{
|
|
// Assume it's a Rekor entry ID - fetch from Rekor
|
|
Console.Error.WriteLine("Fetching from Rekor not yet implemented.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
await console.WriteLineAsync($"Verifying {binary} against predicate");
|
|
}
|
|
|
|
await using var binaryStream = File.OpenRead(binary);
|
|
var result = await deltaSigService.VerifyAsync(predicate, binaryStream, ct);
|
|
|
|
if (format == "json")
|
|
{
|
|
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
await console.WriteLineAsync(json);
|
|
}
|
|
else
|
|
{
|
|
if (result.IsValid)
|
|
{
|
|
await console.WriteLineAsync("✓ Verification PASSED");
|
|
}
|
|
else
|
|
{
|
|
await console.WriteLineAsync($"✗ Verification FAILED: {result.Message ?? "Unknown failure"}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task HandleGateAsync(
|
|
IServiceProvider services,
|
|
string predicateFile,
|
|
int? maxModified,
|
|
int? maxAdded,
|
|
int? maxRemoved,
|
|
long? maxBytes,
|
|
double? minSimilarity,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var gate = services.GetService<IDeltaScopePolicyGate>();
|
|
var console = Console.Out;
|
|
|
|
// Read predicate
|
|
var json = await File.ReadAllTextAsync(predicateFile, ct);
|
|
var predicate = System.Text.Json.JsonSerializer.Deserialize<DeltaSigPredicate>(json);
|
|
|
|
if (predicate is null)
|
|
{
|
|
Console.Error.WriteLine("Failed to parse predicate file.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Build options
|
|
var options = new DeltaScopeGateOptions
|
|
{
|
|
MaxModifiedFunctions = maxModified ?? 10,
|
|
MaxAddedFunctions = maxAdded ?? 5,
|
|
MaxRemovedFunctions = maxRemoved ?? 2,
|
|
MaxBytesChanged = maxBytes ?? 10_000,
|
|
MinSemanticSimilarity = minSimilarity ?? 0.8
|
|
};
|
|
|
|
if (gate is null)
|
|
{
|
|
// Use inline evaluation
|
|
var violations = new List<string>();
|
|
|
|
if (predicate.Summary.FunctionsModified > options.MaxModifiedFunctions)
|
|
{
|
|
violations.Add($"Modified {predicate.Summary.FunctionsModified} functions; max {options.MaxModifiedFunctions}");
|
|
}
|
|
if (predicate.Summary.FunctionsAdded > options.MaxAddedFunctions)
|
|
{
|
|
violations.Add($"Added {predicate.Summary.FunctionsAdded} functions; max {options.MaxAddedFunctions}");
|
|
}
|
|
if (predicate.Summary.FunctionsRemoved > options.MaxRemovedFunctions)
|
|
{
|
|
violations.Add($"Removed {predicate.Summary.FunctionsRemoved} functions; max {options.MaxRemovedFunctions}");
|
|
}
|
|
if (predicate.Summary.TotalBytesChanged > options.MaxBytesChanged)
|
|
{
|
|
violations.Add($"Changed {predicate.Summary.TotalBytesChanged} bytes; max {options.MaxBytesChanged}");
|
|
}
|
|
if (predicate.Summary.MinSemanticSimilarity < options.MinSemanticSimilarity)
|
|
{
|
|
violations.Add($"Min similarity {predicate.Summary.MinSemanticSimilarity:P0}; required {options.MinSemanticSimilarity:P0}");
|
|
}
|
|
|
|
if (format == "json")
|
|
{
|
|
var result = new { passed = violations.Count == 0, violations };
|
|
var resultJson = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
|
await console.WriteLineAsync(resultJson);
|
|
}
|
|
else
|
|
{
|
|
if (violations.Count == 0)
|
|
{
|
|
await console.WriteLineAsync("✓ Gate PASSED");
|
|
}
|
|
else
|
|
{
|
|
await console.WriteLineAsync("✗ Gate FAILED");
|
|
foreach (var v in violations)
|
|
{
|
|
await console.WriteLineAsync($" - {v}");
|
|
}
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var result = await gate.EvaluateAsync(predicate, options, ct);
|
|
|
|
if (format == "json")
|
|
{
|
|
var resultJson = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
|
await console.WriteLineAsync(resultJson);
|
|
}
|
|
else
|
|
{
|
|
if (result.Passed)
|
|
{
|
|
await console.WriteLineAsync("✓ Gate PASSED");
|
|
}
|
|
else
|
|
{
|
|
await console.WriteLineAsync($"✗ Gate FAILED: {result.Reason}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|