feat: Add MongoIdempotencyStoreOptions for MongoDB configuration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat: Implement BsonJsonConverter for converting BsonDocument and BsonArray to JSON fix: Update project file to include MongoDB.Bson package test: Add GraphOverlayExporterTests to validate NDJSON export functionality refactor: Refactor Program.cs in Attestation Tool for improved argument parsing and error handling docs: Update README for stella-forensic-verify with usage instructions and exit codes feat: Enhance HmacVerifier with clock skew and not-after checks feat: Add MerkleRootVerifier and ChainOfCustodyVerifier for additional verification methods fix: Update DenoRuntimeShim to correctly handle file paths feat: Introduce ComposerAutoloadData and related parsing in ComposerLockReader test: Add tests for Deno runtime execution and verification test: Enhance PHP package tests to include autoload data verification test: Add unit tests for HmacVerifier and verification logic
This commit is contained in:
@@ -1,60 +1,136 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
|
||||
static int PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: stella-forensic-verify --payload <file> --signature-hex <hex> --key-hex <hex> [--key-id <id>] [--content-type <ct>]");
|
||||
return 1;
|
||||
}
|
||||
return await ToolEntrypoint.RunAsync(args, Console.Out, Console.Error, TimeProvider.System);
|
||||
|
||||
string? GetArg(string name)
|
||||
internal static class ToolEntrypoint
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
private const int ExitInvalid = 1;
|
||||
private const int ExitUnverified = 2;
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter stdout, TextWriter stderr, TimeProvider timeProvider)
|
||||
{
|
||||
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
var options = Parse(args);
|
||||
if (!options.Valid)
|
||||
{
|
||||
return Usage(stderr);
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = options.PayloadPath == "-"
|
||||
? await ReadAllAsync(Console.OpenStandardInput())
|
||||
: await File.ReadAllBytesAsync(options.PayloadPath!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await stderr.WriteLineAsync($"read error: {ex.Message}");
|
||||
return ExitInvalid;
|
||||
}
|
||||
|
||||
byte[] signature;
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
signature = Hex.FromHex(options.SignatureHex!);
|
||||
key = Hex.FromHex(options.KeyHex!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await stderr.WriteLineAsync($"hex parse error: {ex.Message}");
|
||||
return ExitInvalid;
|
||||
}
|
||||
|
||||
var signRequest = new SignRequest(payload, options.ContentType!, RequiredClaims: new[] { "predicateType" });
|
||||
var signResult = new SignResult(signature, options.KeyId!, options.SignedAt ?? DateTimeOffset.MinValue, null);
|
||||
|
||||
var verifier = new HmacVerifier(new InMemoryKeyProvider(options.KeyId!, key, options.NotAfter), timeProvider, options.MaxSkew);
|
||||
var verifyResult = await verifier.VerifyAsync(signRequest, signResult);
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
valid = verifyResult.IsValid,
|
||||
reason = verifyResult.Reason,
|
||||
verifiedAt = verifyResult.VerifiedAt.ToUniversalTime().ToString("O"),
|
||||
keyId = options.KeyId,
|
||||
contentType = options.ContentType
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false });
|
||||
|
||||
await stdout.WriteLineAsync(json);
|
||||
return verifyResult.IsValid ? 0 : ExitUnverified;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadAllAsync(Stream stream)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static int Usage(TextWriter stderr)
|
||||
{
|
||||
stderr.WriteLine("Usage: stella-forensic-verify --payload <file|-> --signature-hex <hex> --key-hex <hex> [--key-id <id>] [--content-type <ct>] [--signed-at <ISO>] [--not-after <ISO>] [--max-skew-minutes <int>]");
|
||||
stderr.WriteLine("Exit codes: 0 valid, 2 invalid signature/time, 1 bad args");
|
||||
return ExitInvalid;
|
||||
}
|
||||
|
||||
private static ParsedOptions Parse(string[] args)
|
||||
{
|
||||
string? GetArg(string name)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = GetArg("--payload");
|
||||
var sig = GetArg("--signature-hex");
|
||||
var key = GetArg("--key-hex");
|
||||
if (payload is null || sig is null || key is null)
|
||||
{
|
||||
return ParsedOptions.Invalid;
|
||||
}
|
||||
|
||||
DateTimeOffset? ParseDate(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return DateTimeOffset.Parse(value!, null, System.Globalization.DateTimeStyles.RoundtripKind);
|
||||
}
|
||||
|
||||
TimeSpan ParseSkew(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return TimeSpan.FromMinutes(5);
|
||||
return TimeSpan.FromMinutes(double.Parse(value!, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return new ParsedOptions(
|
||||
Valid: true,
|
||||
PayloadPath: payload,
|
||||
SignatureHex: sig,
|
||||
KeyHex: key,
|
||||
KeyId: GetArg("--key-id") ?? "hmac",
|
||||
ContentType: GetArg("--content-type") ?? "application/octet-stream",
|
||||
SignedAt: ParseDate(GetArg("--signed-at")),
|
||||
NotAfter: ParseDate(GetArg("--not-after")),
|
||||
MaxSkew: ParseSkew(GetArg("--max-skew-minutes"))
|
||||
);
|
||||
}
|
||||
|
||||
private sealed record ParsedOptions(
|
||||
bool Valid,
|
||||
string? PayloadPath = null,
|
||||
string? SignatureHex = null,
|
||||
string? KeyHex = null,
|
||||
string? KeyId = null,
|
||||
string? ContentType = null,
|
||||
DateTimeOffset? SignedAt = null,
|
||||
DateTimeOffset? NotAfter = null,
|
||||
TimeSpan MaxSkew = default)
|
||||
{
|
||||
public static readonly ParsedOptions Invalid = new(false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
string? payloadPath = GetArg("--payload");
|
||||
string? signatureHex = GetArg("--signature-hex");
|
||||
string? keyHex = GetArg("--key-hex");
|
||||
string keyId = GetArg("--key-id") ?? "hmac";
|
||||
string contentType = GetArg("--content-type") ?? "application/octet-stream";
|
||||
|
||||
if (payloadPath is null || signatureHex is null || keyHex is null)
|
||||
{
|
||||
return PrintUsage();
|
||||
}
|
||||
|
||||
byte[] payload = await System.IO.File.ReadAllBytesAsync(payloadPath);
|
||||
byte[] signature;
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
signature = Hex.FromHex(signatureHex);
|
||||
key = Hex.FromHex(keyHex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"hex parse error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var request = new SignRequest(payload, contentType);
|
||||
var signResult = new SignResult(signature, keyId, DateTimeOffset.MinValue, null);
|
||||
|
||||
var verifier = new HmacVerifier(new InMemoryKeyProvider(keyId, key));
|
||||
var result = await verifier.VerifyAsync(request, signResult);
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
valid = result.IsValid,
|
||||
reason = result.Reason,
|
||||
verifiedAt = result.VerifiedAt.ToUniversalTime().ToString("O")
|
||||
});
|
||||
Console.WriteLine(json);
|
||||
|
||||
return result.IsValid ? 0 : 2;
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
# stella-forensic-verify (preview)
|
||||
|
||||
Minimal dotnet tool for offline HMAC verification of provenance payloads.
|
||||
Minimal .NET 10 global tool for offline verification of provenance payloads signed with an HMAC key. No network access; deterministic JSON output.
|
||||
|
||||
## Usage
|
||||
|
||||
/mnt/e/dev/git.stella-ops.org /mnt/e/dev/git.stella-ops.org
|
||||
/mnt/e/dev/git.stella-ops.org
|
||||
```
|
||||
stella-forensic-verify \
|
||||
--payload payload.bin # or '-' to read stdin
|
||||
--signature-hex DEADBEEF... # hex-encoded HMAC
|
||||
--key-hex 001122... # hex-encoded HMAC key
|
||||
[--key-id hmac] # optional key id
|
||||
[--content-type application/octet-stream]
|
||||
[--signed-at 2025-11-21T12:00:00Z]
|
||||
[--not-after 2025-12-31T23:59:59Z]
|
||||
[--max-skew-minutes 5]
|
||||
```
|
||||
|
||||
Outputs deterministic JSON:
|
||||
Output (single line, deterministic field order):
|
||||
|
||||
```
|
||||
{"valid":true,"reason":"verified","verifiedAt":"2025-11-22T12:00:00.0000000Z","keyId":"hmac","contentType":"application/octet-stream"}
|
||||
```
|
||||
|
||||
## Exit codes
|
||||
- 0: signature valid
|
||||
- 2: signature invalid
|
||||
- 1: bad arguments/hex parse failure
|
||||
- 2: signature/time invalid
|
||||
- 1: bad arguments or hex parse failure
|
||||
|
||||
## Offline kit packaging (manual)
|
||||
1. `dotnet pack src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.csproj -c Release -o out/tools`
|
||||
2. Copy the produced nupkg into the offline kit under `tools/`.
|
||||
3. Install in air-gap host: `dotnet tool install --global --add-source tools stella-forensic-verify --version <pkg-version>`.
|
||||
4. Document expected SHA256 of the nupkg alongside the kit manifest.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation;
|
||||
|
||||
@@ -13,11 +14,13 @@ public sealed class HmacVerifier : IVerifier
|
||||
{
|
||||
private readonly IKeyProvider _keyProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _maxClockSkew;
|
||||
|
||||
public HmacVerifier(IKeyProvider keyProvider, TimeProvider? timeProvider = null)
|
||||
public HmacVerifier(IKeyProvider keyProvider, TimeProvider? timeProvider = null, TimeSpan? maxClockSkew = null)
|
||||
{
|
||||
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_maxClockSkew = maxClockSkew ?? TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default)
|
||||
@@ -30,11 +33,68 @@ public sealed class HmacVerifier : IVerifier
|
||||
var ok = CryptographicOperations.FixedTimeEquals(expected, signature.Signature) &&
|
||||
string.Equals(_keyProvider.KeyId, signature.KeyId, StringComparison.Ordinal);
|
||||
|
||||
// enforce not-after validity and basic clock skew checks for offline verification
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_keyProvider.NotAfter.HasValue && signature.SignedAt > _keyProvider.NotAfter.Value)
|
||||
{
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (signature.SignedAt - now > _maxClockSkew)
|
||||
{
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var result = new VerificationResult(
|
||||
IsValid: ok,
|
||||
Reason: ok ? "verified" : "signature mismatch",
|
||||
Reason: ok ? "verified" : "signature or time invalid",
|
||||
VerifiedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MerkleRootVerifier
|
||||
{
|
||||
public static VerificationResult VerifyRoot(IEnumerable<byte[]> leaves, byte[] expectedRoot, TimeProvider? timeProvider = null)
|
||||
{
|
||||
var provider = timeProvider ?? TimeProvider.System;
|
||||
if (leaves is null) throw new ArgumentNullException(nameof(leaves));
|
||||
expectedRoot ??= throw new ArgumentNullException(nameof(expectedRoot));
|
||||
|
||||
var leafList = leaves.ToList();
|
||||
var computed = MerkleTree.ComputeRoot(leafList);
|
||||
var ok = CryptographicOperations.FixedTimeEquals(computed, expectedRoot);
|
||||
return new VerificationResult(ok, ok ? "verified" : "merkle root mismatch", provider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
public static class ChainOfCustodyVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a simple chain-of-custody where each hop is hashed onto the previous aggregate.
|
||||
/// head = SHA256(hopN || ... || hop1)
|
||||
/// </summary>
|
||||
public static VerificationResult Verify(IEnumerable<byte[]> hops, byte[] expectedHead, TimeProvider? timeProvider = null)
|
||||
{
|
||||
var provider = timeProvider ?? TimeProvider.System;
|
||||
if (hops is null) throw new ArgumentNullException(nameof(hops));
|
||||
expectedHead ??= throw new ArgumentNullException(nameof(expectedHead));
|
||||
|
||||
var list = hops.ToList();
|
||||
if (list.Count == 0)
|
||||
{
|
||||
return new VerificationResult(false, "no hops", provider.GetUtcNow());
|
||||
}
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
byte[] aggregate = Array.Empty<byte>();
|
||||
foreach (var hop in list)
|
||||
{
|
||||
aggregate = sha.ComputeHash(aggregate.Concat(hop).ToArray());
|
||||
}
|
||||
|
||||
var ok = CryptographicOperations.FixedTimeEquals(aggregate, expectedHead);
|
||||
return new VerificationResult(ok, ok ? "verified" : "chain mismatch", provider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user