feat: Add MongoIdempotencyStoreOptions for MongoDB configuration
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:
StellaOps Bot
2025-11-22 16:42:56 +02:00
parent 967ae0ab16
commit dc7c75b496
85 changed files with 2272 additions and 917 deletions

View File

@@ -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;

View File

@@ -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.

View File

@@ -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());
}
}