// -----------------------------------------------------------------------------
// 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;
///
/// CLI command group for delta-sig binary diff operations.
///
internal static class DeltaSigCommandGroup
{
///
/// Builds the delta-sig command group.
///
internal static Command BuildDeltaSigCommand(
IServiceProvider services,
Option 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;
}
///
/// stella binary delta-sig diff - Generate delta-sig predicate from two binaries.
///
private static Command BuildDiffCommand(
IServiceProvider services,
Option verboseOption,
CancellationToken cancellationToken)
{
var oldFileArg = new Argument("old-file")
{
Description = "Path to the original (vulnerable) binary."
};
var newFileArg = new Argument("new-file")
{
Description = "Path to the patched binary."
};
var outputOption = new Option("--output", new[] { "-o" })
{
Description = "Output file path (default: stdout)."
};
var archOption = new Option("--arch", new[] { "-a" })
{
Description = "Architecture hint (e.g., linux-amd64, linux-arm64)."
};
var cveOption = new Option("--cve")
{
Description = "CVE IDs associated with the patch."
}.SetDefaultValue(Array.Empty());
var packageOption = new Option("--package", new[] { "-p" })
{
Description = "Package name."
};
var oldVersionOption = new Option("--old-version")
{
Description = "Version of the old binary."
};
var newVersionOption = new Option("--new-version")
{
Description = "Version of the new binary."
};
var lifterOption = new Option("--lifter")
{
Description = "Preferred binary lifter (b2r2, ghidra)."
}.SetDefaultValue("b2r2").FromAmong("b2r2", "ghidra");
var semanticOption = new Option("--semantic")
{
Description = "Compute semantic similarity using BSim."
};
var formatOption = new Option("--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;
}
///
/// stella binary delta-sig attest - Sign and submit delta-sig to Rekor.
///
private static Command BuildAttestCommand(
IServiceProvider services,
Option verboseOption,
CancellationToken cancellationToken)
{
var predicateFileArg = new Argument("predicate-file")
{
Description = "Path to delta-sig predicate JSON file."
};
var keyOption = new Option("--key", new[] { "-k" })
{
Description = "Signing key identifier (uses default if not specified)."
};
var rekorOption = new Option("--rekor-url")
{
Description = "Rekor server URL (default: https://rekor.sigstore.dev)."
};
var outputOption = new Option("--output", new[] { "-o" })
{
Description = "Output file for DSSE envelope."
};
var dryRunOption = new Option("--dry-run")
{
Description = "Create envelope without submitting to Rekor."
};
// Sprint 040-05: Receipt output option
var receiptOption = new Option("--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;
}
///
/// stella binary delta-sig verify - Verify a binary against a delta-sig predicate.
///
private static Command BuildVerifyCommand(
IServiceProvider services,
Option verboseOption,
CancellationToken cancellationToken)
{
var predicateArg = new Argument("predicate")
{
Description = "Path to delta-sig predicate or Rekor entry UUID."
};
var binaryArg = new Argument("binary")
{
Description = "Path to binary file to verify."
};
var rekorOption = new Option("--rekor-url")
{
Description = "Rekor server URL for fetching remote predicates."
};
var formatOption = new Option("--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;
}
///
/// stella binary delta-sig gate - Evaluate delta-sig against policy constraints.
///
private static Command BuildGateCommand(
IServiceProvider services,
Option verboseOption,
CancellationToken cancellationToken)
{
var predicateArg = new Argument("predicate")
{
Description = "Path to delta-sig predicate JSON file."
};
var maxModifiedOption = new Option("--max-modified")
{
Description = "Maximum modified functions allowed."
};
var maxAddedOption = new Option("--max-added")
{
Description = "Maximum added functions allowed."
};
var maxRemovedOption = new Option("--max-removed")
{
Description = "Maximum removed functions allowed."
};
var maxBytesOption = new Option("--max-bytes")
{
Description = "Maximum bytes changed allowed."
};
var minSimilarityOption = new Option("--min-similarity")
{
Description = "Minimum semantic similarity (0.0-1.0)."
};
var formatOption = new Option("--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 cves,
string? package,
string? oldVersion,
string? newVersion,
string lifter,
bool semantic,
string format,
bool verbose,
CancellationToken ct)
{
var deltaSigService = services.GetRequiredService();
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 { ["sha256"] = oldDigest },
Content = oldStream,
Filename = oldFileInfo.Name,
Size = oldFileInfo.Length
},
NewBinary = new BinaryReference
{
Uri = $"file://{newFile}",
Digest = new Dictionary { ["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");
}
}
///
/// Sprint 040-05: Sign predicate and submit to Rekor.
///
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(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();
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
{
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;
}
}
}
///
/// Signs PAE data using an EC key loaded from PEM file.
/// Falls back to HMAC if the key format is not recognized.
///
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();
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(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();
var console = Console.Out;
// Read predicate
var json = await File.ReadAllTextAsync(predicateFile, ct);
var predicate = System.Text.Json.JsonSerializer.Deserialize(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();
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;
}
}
}
}
}