Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,369 @@
# StellaOps.ReachGraph Module
## Module Charter
The **ReachGraph** module provides a unified store for reachability subgraphs, enabling fast, deterministic, audit-ready answers to "*exactly why* a dependency is reachable."
### Mission
Consolidate reachability data from Scanner, Signals, and Attestor into a single, content-addressed store with:
- **Edge explainability**: Every edge carries "why" metadata (import, dynamic load, guards)
- **Deterministic replay**: Same inputs produce identical digests
- **Slice queries**: Fast queries by package, CVE, entrypoint, or file
- **Audit-ready proofs**: DSSE-signed artifacts verifiable offline
### Scope
| In Scope | Out of Scope |
|----------|--------------|
| ReachGraph schema and data model | Call graph extraction (handled by Scanner) |
| Content-addressed storage | Runtime signal collection (handled by Signals) |
| Slice query APIs | DSSE signing internals (handled by Attestor) |
| Deterministic serialization | VEX document ingestion (handled by Excititor) |
| Valkey caching | Policy evaluation (handled by Policy module) |
| Replay verification | UI components (handled by Web module) |
---
## Architecture
### Component Diagram
```
┌──────────────────────────────────────────────────────────────────┐
│ ReachGraph Module │
├──────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Schema Layer │ │ Serialization │ │ Signing Layer │ │
│ │ │ │ │ │ │ │
│ │ ReachGraphMin │ │ Canonical JSON │ │ DSSE Wrapper │ │
│ │ EdgeExplanation │ │ BLAKE3 Digest │ │ Verification │ │
│ │ Provenance │ │ Compression │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌────────▼────────────────────▼────────────────────▼────────┐ │
│ │ Store Layer │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Repository │ │ Slice Engine │ │ Replay Driver│ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼───────────────────────────────┐ │
│ │ Persistence Layer │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ PostgreSQL │ │ Valkey │ │ │
│ │ │ (primary) │ │ (cache) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### Project Structure
```
src/__Libraries/StellaOps.ReachGraph/
├── Schema/
│ ├── ReachGraphMinimal.cs # Top-level graph structure
│ ├── ReachGraphNode.cs # Node with metadata
│ ├── ReachGraphEdge.cs # Edge with explanation
│ ├── EdgeExplanation.cs # Why the edge exists
│ └── ReachGraphProvenance.cs # Input tracking
├── Serialization/
│ ├── CanonicalReachGraphSerializer.cs
│ ├── SortedKeysJsonConverter.cs
│ └── DeterministicArraySortConverter.cs
├── Hashing/
│ ├── ReachGraphDigestComputer.cs
│ └── Blake3HashProvider.cs
├── Signing/
│ ├── IReachGraphSignerService.cs
│ └── ReachGraphSignerService.cs
├── Store/
│ ├── IReachGraphRepository.cs
│ ├── PostgresReachGraphRepository.cs
│ └── SliceQueryEngine.cs
├── Cache/
│ ├── IReachGraphCache.cs
│ └── ValkeyReachGraphCache.cs
├── Replay/
│ ├── IReplayDriver.cs
│ └── DeterministicReplayDriver.cs
└── StellaOps.ReachGraph.csproj
src/__Libraries/StellaOps.ReachGraph.Persistence/
├── Migrations/
│ └── 001_reachgraph_store.sql
├── Models/
│ └── SubgraphEntity.cs
└── StellaOps.ReachGraph.Persistence.csproj
src/ReachGraph/
├── StellaOps.ReachGraph.WebService/
│ ├── Endpoints/
│ │ ├── ReachGraphEndpoints.cs
│ │ └── SliceQueryEndpoints.cs
│ ├── Contracts/
│ │ ├── UpsertRequest.cs
│ │ ├── SliceQueryRequest.cs
│ │ └── ReplayRequest.cs
│ ├── Program.cs
│ └── openapi.yaml
└── __Tests/
└── StellaOps.ReachGraph.WebService.Tests/
```
---
## Data Model
### ReachGraphMinimal Schema (v1)
```json
{
"schemaVersion": "reachgraph.min@v1",
"artifact": {
"name": "svc.payments",
"digest": "sha256:abc123...",
"env": ["linux/amd64"]
},
"scope": {
"entrypoints": ["/app/bin/svc"],
"selectors": ["prod"],
"cves": ["CVE-2024-1234"]
},
"nodes": [
{
"id": "sha256:nodeHash1",
"kind": "function",
"ref": "main()",
"file": "src/index.ts",
"line": 1,
"isEntrypoint": true
}
],
"edges": [
{
"from": "sha256:nodeHash1",
"to": "sha256:nodeHash2",
"why": {
"type": "Import",
"loc": "src/index.ts:3",
"confidence": 1.0
}
}
],
"provenance": {
"intoto": ["attestation-1.link"],
"inputs": {
"sbom": "sha256:sbomDigest",
"vex": "sha256:vexDigest",
"callgraph": "sha256:cgDigest"
},
"computedAt": "2025-12-27T10:00:00Z",
"analyzer": {
"name": "stellaops-scanner",
"version": "1.0.0",
"toolchainDigest": "sha256:..."
}
},
"signatures": [
{"keyId": "scanner-signing-2025", "sig": "base64..."}
]
}
```
### Edge Explanation Types
| Type | Description | Example Guard |
|------|-------------|---------------|
| `Import` | Static import statement | - |
| `DynamicLoad` | Runtime require/import | - |
| `Reflection` | Reflective invocation | - |
| `Ffi` | Foreign function call | - |
| `EnvGuard` | Environment variable check | `DEBUG=true` |
| `FeatureFlag` | Feature flag condition | `FEATURE_X=enabled` |
| `PlatformArch` | Platform/arch guard | `os=linux` |
| `TaintGate` | Sanitization/validation | - |
| `LoaderRule` | PLT/IAT/GOT entry | `RTLD_LAZY` |
| `DirectCall` | Direct function call | - |
| `Unknown` | Cannot determine | - |
---
## API Contracts
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/v1/reachgraphs` | Upsert subgraph |
| GET | `/v1/reachgraphs/{digest}` | Get full subgraph |
| GET | `/v1/reachgraphs/{digest}/slice` | Query slice |
| POST | `/v1/reachgraphs/replay` | Verify determinism |
| GET | `/v1/reachgraphs/by-artifact/{digest}` | List by artifact |
### Slice Query Parameters
| Parameter | Description |
|-----------|-------------|
| `q` | PURL pattern for package slice |
| `cve` | CVE ID for vulnerability slice |
| `entrypoint` | Entrypoint path/symbol |
| `file` | File path pattern (glob) |
| `depth` | Max traversal depth |
| `direction` | `upstream`, `downstream`, `both` |
---
## Coding Guidelines
### Determinism Rules
1. **All JSON serialization must use canonical format**
- Sorted object keys (lexicographic)
- Sorted arrays by deterministic field
- UTC ISO-8601 timestamps
- No null fields (omit when null)
2. **Hash computation excludes signatures**
- Remove `signatures` field before hashing
- Use BLAKE3-256 for all digests
3. **Tests must verify determinism**
- Same input must produce same digest
- Golden samples for regression testing
### Error Handling
- Return structured errors with codes
- Log correlation IDs for tracing
- Never expose internal details in errors
### Performance
- Cache hot slices in Valkey (30min TTL)
- Compress stored blobs with gzip
- Paginate large results (50 nodes per page)
- Timeout long queries (30s max)
---
## Integration Points
### Upstream (Data Producers)
| Module | Data | Integration |
|--------|------|-------------|
| Scanner.CallGraph | Call graph nodes/edges | `ICallGraphExtractor` produces input |
| Signals | Runtime facts | Correlates static + dynamic paths |
| Attestor | DSSE signing | `IReachGraphSignerService` delegates |
### Downstream (Data Consumers)
| Module | Usage | Integration |
|--------|-------|-------------|
| Policy | VEX decisions | `ReachabilityRequirementGate` queries slices |
| Web | UI panel | REST API for "Why Reachable?" |
| CLI | Proof export | `stella reachgraph` commands |
| ExportCenter | Batch reports | Includes subgraphs in evidence bundles |
---
## Testing Requirements
### Unit Tests
- `CanonicalSerializerTests.cs` - Deterministic serialization
- `DigestComputerTests.cs` - BLAKE3 hashing
- `EdgeExplanationTests.cs` - Type coverage
- `SliceEngineTests.cs` - Query correctness
### Integration Tests
- PostgreSQL with Testcontainers
- Valkey cache behavior
- Tenant isolation (RLS)
- Rate limiting enforcement
### Golden Samples
Located in `tests/ReachGraph/Fixtures/`:
- `simple-single-path.reachgraph.min.json`
- `multi-edge-java.reachgraph.min.json`
- `feature-flag-guards.reachgraph.min.json`
- `large-graph-50-nodes.reachgraph.min.json`
---
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `REACHGRAPH_POSTGRES_CONNECTION` | PostgreSQL connection string | - |
| `REACHGRAPH_VALKEY_CONNECTION` | Valkey connection string | - |
| `REACHGRAPH_CACHE_TTL_MINUTES` | Cache TTL for full graphs | 1440 |
| `REACHGRAPH_SLICE_CACHE_TTL_MINUTES` | Cache TTL for slices | 30 |
| `REACHGRAPH_MAX_GRAPH_SIZE_MB` | Max graph size in cache | 10 |
### YAML Configuration
```yaml
# etc/reachgraph.yaml
reachgraph:
store:
maxDepth: 10
maxPaths: 5
compressionLevel: 6
cache:
enabled: true
ttlMinutes: 30
replay:
enabled: true
logResults: true
```
---
## Observability
### Metrics
- `reachgraph_upsert_total` - Upsert count by result
- `reachgraph_query_duration_seconds` - Query latency histogram
- `reachgraph_cache_hit_ratio` - Cache hit rate
- `reachgraph_replay_match_total` - Replay verification results
- `reachgraph_slice_size_bytes` - Slice response sizes
### Logging
- Structured JSON logs
- Correlation ID in all entries
- Tenant context
- Query parameters (sanitized)
### Tracing
- OpenTelemetry spans for:
- Upsert operations
- Slice queries
- Cache lookups
- Replay verification
---
## Related Documentation
- `docs/implplan/SPRINT_1227_0012_0001_LB_reachgraph_core.md`
- `docs/implplan/SPRINT_1227_0012_0002_BE_reachgraph_store.md`
- `docs/implplan/SPRINT_1227_0012_0003_FE_reachgraph_integration.md`
- `src/Attestor/POE_PREDICATE_SPEC.md` (predecessor schema)
- `docs/modules/scanner/architecture.md`
- `docs/modules/signals/architecture.md`
---
_Module created: 2025-12-27. Owner: ReachGraph Guild._

View File

@@ -0,0 +1,113 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using Blake3;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.Serialization;
namespace StellaOps.ReachGraph.Hashing;
/// <summary>
/// Computes BLAKE3-256 digests for reachability graphs using canonical serialization.
/// </summary>
public sealed class ReachGraphDigestComputer
{
private readonly CanonicalReachGraphSerializer _serializer;
public ReachGraphDigestComputer()
: this(new CanonicalReachGraphSerializer())
{
}
public ReachGraphDigestComputer(CanonicalReachGraphSerializer serializer)
{
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
}
/// <summary>
/// Compute BLAKE3-256 digest of canonical JSON (excluding signatures).
/// </summary>
/// <param name="graph">The reachability graph to hash.</param>
/// <returns>Digest in format "blake3:{hex}".</returns>
public string ComputeDigest(ReachGraphMinimal graph)
{
ArgumentNullException.ThrowIfNull(graph);
// Remove signatures before hashing (avoid circular dependency)
var unsigned = graph with { Signatures = null };
var canonical = _serializer.SerializeMinimal(unsigned);
using var hasher = Hasher.New();
hasher.Update(canonical);
var hash = hasher.Finalize();
return $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}";
}
/// <summary>
/// Compute BLAKE3-256 digest from raw canonical JSON bytes.
/// </summary>
/// <param name="canonicalJson">The canonical JSON bytes to hash.</param>
/// <returns>Digest in format "blake3:{hex}".</returns>
public static string ComputeDigest(ReadOnlySpan<byte> canonicalJson)
{
using var hasher = Hasher.New();
hasher.Update(canonicalJson);
var hash = hasher.Finalize();
return $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}";
}
/// <summary>
/// Verify digest matches graph content.
/// </summary>
/// <param name="graph">The reachability graph to verify.</param>
/// <param name="expectedDigest">The expected digest.</param>
/// <returns>True if digest matches, false otherwise.</returns>
public bool VerifyDigest(ReachGraphMinimal graph, string expectedDigest)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(expectedDigest);
var computed = ComputeDigest(graph);
return string.Equals(computed, expectedDigest, StringComparison.Ordinal);
}
/// <summary>
/// Parse a digest string into its algorithm and hash components.
/// </summary>
/// <param name="digest">The digest string (e.g., "blake3:abc123...").</param>
/// <returns>Tuple of (algorithm, hash) or null if invalid format.</returns>
public static (string Algorithm, string Hash)? ParseDigest(string digest)
{
if (string.IsNullOrEmpty(digest))
return null;
var colonIndex = digest.IndexOf(':');
if (colonIndex <= 0 || colonIndex >= digest.Length - 1)
return null;
var algorithm = digest[..colonIndex];
var hash = digest[(colonIndex + 1)..];
return (algorithm, hash);
}
/// <summary>
/// Validate that a digest string has the correct format for BLAKE3.
/// </summary>
/// <param name="digest">The digest string to validate.</param>
/// <returns>True if valid BLAKE3 digest format, false otherwise.</returns>
public static bool IsValidBlake3Digest(string digest)
{
var parsed = ParseDigest(digest);
if (parsed is null)
return false;
var (algorithm, hash) = parsed.Value;
// BLAKE3-256 produces 64 hex characters (32 bytes)
return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) &&
hash.Length == 64 &&
hash.All(c => char.IsAsciiHexDigit(c));
}
}

View File

@@ -0,0 +1,77 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Schema;
/// <summary>
/// Why an edge exists in the reachability graph.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<EdgeExplanationType>))]
public enum EdgeExplanationType
{
/// <summary>Static import (ES6 import, Python import, using directive).</summary>
Import,
/// <summary>Dynamic load (require(), dlopen, LoadLibrary).</summary>
DynamicLoad,
/// <summary>Reflection invocation (Class.forName, Type.GetType).</summary>
Reflection,
/// <summary>Foreign function interface (JNI, P/Invoke, ctypes).</summary>
Ffi,
/// <summary>Environment variable guard (process.env.X, os.environ.get).</summary>
EnvGuard,
/// <summary>Feature flag check (LaunchDarkly, unleash, custom flags).</summary>
FeatureFlag,
/// <summary>Platform/architecture guard (process.platform, runtime.GOOS).</summary>
PlatformArch,
/// <summary>Taint gate (sanitization, validation).</summary>
TaintGate,
/// <summary>Loader rule (PLT/IAT/GOT entry).</summary>
LoaderRule,
/// <summary>Direct call (static, virtual, delegate).</summary>
DirectCall,
/// <summary>Cannot determine explanation type.</summary>
Unknown
}
/// <summary>
/// Full edge explanation with metadata.
/// </summary>
public sealed record EdgeExplanation
{
/// <summary>
/// Gets the type of edge explanation.
/// </summary>
public required EdgeExplanationType Type { get; init; }
/// <summary>
/// Gets the source location (file:line).
/// </summary>
public string? Loc { get; init; }
/// <summary>
/// Gets the guard predicate expression (e.g., "FEATURE_X=true").
/// </summary>
public string? Guard { get; init; }
/// <summary>
/// Gets the confidence score [0.0, 1.0].
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Gets additional metadata (language-specific).
/// </summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,24 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
namespace StellaOps.ReachGraph.Schema;
/// <summary>
/// An edge in the reachability subgraph connecting two nodes.
/// </summary>
public sealed record ReachGraphEdge
{
/// <summary>
/// Gets the source node ID.
/// </summary>
public required string From { get; init; }
/// <summary>
/// Gets the target node ID.
/// </summary>
public required string To { get; init; }
/// <summary>
/// Gets the explanation of why this edge exists.
/// </summary>
public required EdgeExplanation Why { get; init; }
}

View File

@@ -0,0 +1,76 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Schema;
/// <summary>
/// Minimal reachability subgraph format optimized for:
/// - Compact serialization (delta-friendly, gzip-hot)
/// - Deterministic digest computation
/// - Offline verification with DSSE signatures
/// - VEX-first policy integration
/// </summary>
public sealed record ReachGraphMinimal
{
/// <summary>
/// Gets the schema version identifier.
/// </summary>
public string SchemaVersion { get; init; } = "reachgraph.min@v1";
/// <summary>
/// Gets the artifact this graph describes.
/// </summary>
public required ReachGraphArtifact Artifact { get; init; }
/// <summary>
/// Gets the scope/context of this graph.
/// </summary>
public required ReachGraphScope Scope { get; init; }
/// <summary>
/// Gets the nodes in this subgraph.
/// </summary>
public required ImmutableArray<ReachGraphNode> Nodes { get; init; }
/// <summary>
/// Gets the edges in this subgraph.
/// </summary>
public required ImmutableArray<ReachGraphEdge> Edges { get; init; }
/// <summary>
/// Gets the provenance information for this graph.
/// </summary>
public required ReachGraphProvenance Provenance { get; init; }
/// <summary>
/// Gets the DSSE signatures (optional, set after signing).
/// </summary>
public ImmutableArray<ReachGraphSignature>? Signatures { get; init; }
}
/// <summary>
/// Describes the artifact this reachability graph applies to.
/// </summary>
public sealed record ReachGraphArtifact(
string Name,
string Digest,
ImmutableArray<string> Env
);
/// <summary>
/// Defines the scope/context of the reachability analysis.
/// </summary>
public sealed record ReachGraphScope(
ImmutableArray<string> Entrypoints,
ImmutableArray<string> Selectors,
ImmutableArray<string>? Cves = null
);
/// <summary>
/// DSSE signature on the reachability graph.
/// </summary>
public sealed record ReachGraphSignature(
string KeyId,
string Sig
);

View File

@@ -0,0 +1,81 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Schema;
/// <summary>
/// Kind of node in the reachability graph.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ReachGraphNodeKind>))]
public enum ReachGraphNodeKind
{
/// <summary>Package/dependency node.</summary>
Package,
/// <summary>Source file node.</summary>
File,
/// <summary>Function/method node.</summary>
Function,
/// <summary>Binary symbol node.</summary>
Symbol,
/// <summary>Class/type node.</summary>
Class,
/// <summary>Module/namespace node.</summary>
Module
}
/// <summary>
/// A node in the reachability subgraph.
/// </summary>
public sealed record ReachGraphNode
{
/// <summary>
/// Gets the content-addressed node ID: sha256(canonical(kind:ref)).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Gets the kind of node.
/// </summary>
public required ReachGraphNodeKind Kind { get; init; }
/// <summary>
/// Gets the reference (PURL for package, path for file, symbol for function).
/// </summary>
public required string Ref { get; init; }
/// <summary>
/// Gets the source file path (if available).
/// </summary>
public string? File { get; init; }
/// <summary>
/// Gets the line number (if available).
/// </summary>
public int? Line { get; init; }
/// <summary>
/// Gets the module/library hash.
/// </summary>
public string? ModuleHash { get; init; }
/// <summary>
/// Gets the binary address (for native code).
/// </summary>
public string? Addr { get; init; }
/// <summary>
/// Gets whether this is an entry point.
/// </summary>
public bool? IsEntrypoint { get; init; }
/// <summary>
/// Gets whether this is a sink (vulnerable function).
/// </summary>
public bool? IsSink { get; init; }
}

View File

@@ -0,0 +1,71 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Schema;
/// <summary>
/// Provenance information for a reachability graph.
/// </summary>
public sealed record ReachGraphProvenance
{
/// <summary>
/// Gets the in-toto attestation links.
/// </summary>
public ImmutableArray<string>? Intoto { get; init; }
/// <summary>
/// Gets the input artifact digests.
/// </summary>
public required ReachGraphInputs Inputs { get; init; }
/// <summary>
/// Gets when this graph was computed (UTC).
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Gets the analyzer that produced this graph.
/// </summary>
public required ReachGraphAnalyzer Analyzer { get; init; }
}
/// <summary>
/// Input artifact digests for provenance tracking.
/// </summary>
public sealed record ReachGraphInputs
{
/// <summary>
/// Gets the SBOM digest (sha256:...).
/// </summary>
public required string Sbom { get; init; }
/// <summary>
/// Gets the VEX digest if available.
/// </summary>
public string? Vex { get; init; }
/// <summary>
/// Gets the call graph digest.
/// </summary>
public string? Callgraph { get; init; }
/// <summary>
/// Gets the runtime facts batch digest.
/// </summary>
public string? RuntimeFacts { get; init; }
/// <summary>
/// Gets the policy digest used for filtering.
/// </summary>
public string? Policy { get; init; }
}
/// <summary>
/// Analyzer metadata for reproducibility.
/// </summary>
public sealed record ReachGraphAnalyzer(
string Name,
string Version,
string ToolchainDigest
);

View File

@@ -0,0 +1,462 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Serialization;
/// <summary>
/// Serializer for canonical (deterministic) ReachGraph JSON.
/// Guarantees:
/// - Lexicographically sorted object keys
/// - Arrays sorted by deterministic field (Nodes by Id, Edges by From/To)
/// - UTC ISO-8601 timestamps with millisecond precision
/// - Null fields omitted
/// - Minified output for storage, prettified for debugging
/// </summary>
public sealed class CanonicalReachGraphSerializer
{
private readonly JsonSerializerOptions _minifiedOptions;
private readonly JsonSerializerOptions _prettyOptions;
public CanonicalReachGraphSerializer()
{
_minifiedOptions = CreateSerializerOptions(writeIndented: false);
_prettyOptions = CreateSerializerOptions(writeIndented: true);
}
/// <summary>
/// Serialize to canonical minified JSON bytes.
/// </summary>
public byte[] SerializeMinimal(ReachGraphMinimal graph)
{
ArgumentNullException.ThrowIfNull(graph);
var canonical = Canonicalize(graph);
return JsonSerializer.SerializeToUtf8Bytes(canonical, _minifiedOptions);
}
/// <summary>
/// Serialize to canonical prettified JSON for debugging.
/// </summary>
public string SerializePretty(ReachGraphMinimal graph)
{
ArgumentNullException.ThrowIfNull(graph);
var canonical = Canonicalize(graph);
return JsonSerializer.Serialize(canonical, _prettyOptions);
}
/// <summary>
/// Deserialize from JSON bytes.
/// </summary>
public ReachGraphMinimal Deserialize(ReadOnlySpan<byte> json)
{
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
return FromDto(dto);
}
/// <summary>
/// Deserialize from JSON string.
/// </summary>
public ReachGraphMinimal Deserialize(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
return FromDto(dto);
}
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
{
return new JsonSerializerOptions
{
WriteIndented = writeIndented,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
new Iso8601MillisecondConverter()
},
// Ensure consistent ordering
PropertyNameCaseInsensitive = true
};
}
/// <summary>
/// Create a canonical DTO with sorted arrays.
/// </summary>
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
{
// Sort nodes by Id lexicographically
var sortedNodes = graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Select(n => new ReachGraphNodeDto
{
Id = n.Id,
Kind = n.Kind,
Ref = n.Ref,
File = n.File,
Line = n.Line,
ModuleHash = n.ModuleHash,
Addr = n.Addr,
IsEntrypoint = n.IsEntrypoint,
IsSink = n.IsSink
})
.ToList();
// Sort edges by From, then To
var sortedEdges = graph.Edges
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.Select(e => new ReachGraphEdgeDto
{
From = e.From,
To = e.To,
Why = new EdgeExplanationDto
{
Type = e.Why.Type,
Loc = e.Why.Loc,
Guard = e.Why.Guard,
Confidence = e.Why.Confidence,
Metadata = e.Why.Metadata?.Count > 0
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
: null
}
})
.ToList();
// Sort signatures by KeyId if present
List<ReachGraphSignatureDto>? sortedSignatures = null;
if (graph.Signatures is { Length: > 0 } sigs)
{
sortedSignatures = sigs
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
.ToList();
}
// Sort entrypoints and selectors
var sortedEntrypoints = graph.Scope.Entrypoints
.OrderBy(e => e, StringComparer.Ordinal)
.ToList();
var sortedSelectors = graph.Scope.Selectors
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
List<string>? sortedCves = graph.Scope.Cves is { Length: > 0 } cves
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
: null;
// Sort env array
var sortedEnv = graph.Artifact.Env
.OrderBy(e => e, StringComparer.Ordinal)
.ToList();
// Sort intoto array if present
List<string>? sortedIntoto = graph.Provenance.Intoto is { Length: > 0 } intoto
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
: null;
return new ReachGraphMinimalDto
{
SchemaVersion = graph.SchemaVersion,
Artifact = new ReachGraphArtifactDto
{
Name = graph.Artifact.Name,
Digest = graph.Artifact.Digest,
Env = sortedEnv
},
Scope = new ReachGraphScopeDto
{
Entrypoints = sortedEntrypoints,
Selectors = sortedSelectors,
Cves = sortedCves
},
Nodes = sortedNodes,
Edges = sortedEdges,
Provenance = new ReachGraphProvenanceDto
{
Intoto = sortedIntoto,
Inputs = new ReachGraphInputsDto
{
Sbom = graph.Provenance.Inputs.Sbom,
Vex = graph.Provenance.Inputs.Vex,
Callgraph = graph.Provenance.Inputs.Callgraph,
RuntimeFacts = graph.Provenance.Inputs.RuntimeFacts,
Policy = graph.Provenance.Inputs.Policy
},
ComputedAt = graph.Provenance.ComputedAt,
Analyzer = new ReachGraphAnalyzerDto
{
Name = graph.Provenance.Analyzer.Name,
Version = graph.Provenance.Analyzer.Version,
ToolchainDigest = graph.Provenance.Analyzer.ToolchainDigest
}
},
Signatures = sortedSignatures
};
}
/// <summary>
/// Convert DTO back to domain record.
/// </summary>
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
{
return new ReachGraphMinimal
{
SchemaVersion = dto.SchemaVersion,
Artifact = new ReachGraphArtifact(
dto.Artifact.Name,
dto.Artifact.Digest,
[.. dto.Artifact.Env]
),
Scope = new ReachGraphScope(
[.. dto.Scope.Entrypoints],
[.. dto.Scope.Selectors],
dto.Scope.Cves is { Count: > 0 } ? [.. dto.Scope.Cves] : null
),
Nodes = [.. dto.Nodes.Select(n => new ReachGraphNode
{
Id = n.Id,
Kind = n.Kind,
Ref = n.Ref,
File = n.File,
Line = n.Line,
ModuleHash = n.ModuleHash,
Addr = n.Addr,
IsEntrypoint = n.IsEntrypoint,
IsSink = n.IsSink
})],
Edges = [.. dto.Edges.Select(e => new ReachGraphEdge
{
From = e.From,
To = e.To,
Why = new EdgeExplanation
{
Type = e.Why.Type,
Loc = e.Why.Loc,
Guard = e.Why.Guard,
Confidence = e.Why.Confidence,
Metadata = e.Why.Metadata?.Count > 0
? e.Why.Metadata.ToImmutableDictionary()
: null
}
})],
Provenance = new ReachGraphProvenance
{
Intoto = dto.Provenance.Intoto is { Count: > 0 } ? [.. dto.Provenance.Intoto] : null,
Inputs = new ReachGraphInputs
{
Sbom = dto.Provenance.Inputs.Sbom,
Vex = dto.Provenance.Inputs.Vex,
Callgraph = dto.Provenance.Inputs.Callgraph,
RuntimeFacts = dto.Provenance.Inputs.RuntimeFacts,
Policy = dto.Provenance.Inputs.Policy
},
ComputedAt = dto.Provenance.ComputedAt,
Analyzer = new ReachGraphAnalyzer(
dto.Provenance.Analyzer.Name,
dto.Provenance.Analyzer.Version,
dto.Provenance.Analyzer.ToolchainDigest
)
},
Signatures = dto.Signatures is { Count: > 0 }
? [.. dto.Signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))]
: null
};
}
#region DTOs for canonical serialization (alphabetically ordered properties)
private sealed class ReachGraphMinimalDto
{
[JsonPropertyOrder(1)]
public required string SchemaVersion { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphArtifactDto Artifact { get; init; }
[JsonPropertyOrder(3)]
public required ReachGraphScopeDto Scope { get; init; }
[JsonPropertyOrder(4)]
public required List<ReachGraphNodeDto> Nodes { get; init; }
[JsonPropertyOrder(5)]
public required List<ReachGraphEdgeDto> Edges { get; init; }
[JsonPropertyOrder(6)]
public required ReachGraphProvenanceDto Provenance { get; init; }
[JsonPropertyOrder(7)]
public List<ReachGraphSignatureDto>? Signatures { get; init; }
}
private sealed class ReachGraphArtifactDto
{
[JsonPropertyOrder(1)]
public required string Name { get; init; }
[JsonPropertyOrder(2)]
public required string Digest { get; init; }
[JsonPropertyOrder(3)]
public required List<string> Env { get; init; }
}
private sealed class ReachGraphScopeDto
{
[JsonPropertyOrder(1)]
public required List<string> Entrypoints { get; init; }
[JsonPropertyOrder(2)]
public required List<string> Selectors { get; init; }
[JsonPropertyOrder(3)]
public List<string>? Cves { get; init; }
}
private sealed class ReachGraphNodeDto
{
[JsonPropertyOrder(1)]
public required string Id { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphNodeKind Kind { get; init; }
[JsonPropertyOrder(3)]
public required string Ref { get; init; }
[JsonPropertyOrder(4)]
public string? File { get; init; }
[JsonPropertyOrder(5)]
public int? Line { get; init; }
[JsonPropertyOrder(6)]
public string? ModuleHash { get; init; }
[JsonPropertyOrder(7)]
public string? Addr { get; init; }
[JsonPropertyOrder(8)]
public bool? IsEntrypoint { get; init; }
[JsonPropertyOrder(9)]
public bool? IsSink { get; init; }
}
private sealed class ReachGraphEdgeDto
{
[JsonPropertyOrder(1)]
public required string From { get; init; }
[JsonPropertyOrder(2)]
public required string To { get; init; }
[JsonPropertyOrder(3)]
public required EdgeExplanationDto Why { get; init; }
}
private sealed class EdgeExplanationDto
{
[JsonPropertyOrder(1)]
public required EdgeExplanationType Type { get; init; }
[JsonPropertyOrder(2)]
public string? Loc { get; init; }
[JsonPropertyOrder(3)]
public string? Guard { get; init; }
[JsonPropertyOrder(4)]
public required double Confidence { get; init; }
[JsonPropertyOrder(5)]
public SortedDictionary<string, string>? Metadata { get; init; }
}
private sealed class ReachGraphProvenanceDto
{
[JsonPropertyOrder(1)]
public List<string>? Intoto { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphInputsDto Inputs { get; init; }
[JsonPropertyOrder(3)]
public required DateTimeOffset ComputedAt { get; init; }
[JsonPropertyOrder(4)]
public required ReachGraphAnalyzerDto Analyzer { get; init; }
}
private sealed class ReachGraphInputsDto
{
[JsonPropertyOrder(1)]
public required string Sbom { get; init; }
[JsonPropertyOrder(2)]
public string? Vex { get; init; }
[JsonPropertyOrder(3)]
public string? Callgraph { get; init; }
[JsonPropertyOrder(4)]
public string? RuntimeFacts { get; init; }
[JsonPropertyOrder(5)]
public string? Policy { get; init; }
}
private sealed class ReachGraphAnalyzerDto
{
[JsonPropertyOrder(1)]
public required string Name { get; init; }
[JsonPropertyOrder(2)]
public required string Version { get; init; }
[JsonPropertyOrder(3)]
public required string ToolchainDigest { get; init; }
}
private sealed class ReachGraphSignatureDto
{
[JsonPropertyOrder(1)]
public required string KeyId { get; init; }
[JsonPropertyOrder(2)]
public required string Sig { get; init; }
}
#endregion
}
/// <summary>
/// JSON converter for ISO-8601 timestamps with millisecond precision.
/// </summary>
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
{
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
return DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
// Always output in UTC with millisecond precision
writer.WriteStringValue(value.UtcDateTime.ToString(Format, System.Globalization.CultureInfo.InvariantCulture));
}
}

View File

@@ -0,0 +1,45 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
namespace StellaOps.ReachGraph.Signing;
/// <summary>
/// Key store abstraction for ReachGraph signing operations.
/// Wraps the underlying cryptographic key management (Attestor, Signer module, etc.).
/// </summary>
public interface IReachGraphKeyStore
{
/// <summary>
/// Sign data with the specified key.
/// </summary>
/// <param name="keyId">The key identifier.</param>
/// <param name="data">The data to sign (typically PAE-encoded).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The signature bytes.</returns>
Task<byte[]> SignAsync(string keyId, byte[] data, CancellationToken cancellationToken = default);
/// <summary>
/// Verify a signature with the specified key.
/// </summary>
/// <param name="keyId">The key identifier.</param>
/// <param name="data">The data that was signed.</param>
/// <param name="signature">The signature to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if signature is valid, false otherwise.</returns>
Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, CancellationToken cancellationToken = default);
/// <summary>
/// Check if a key exists and is available for signing.
/// </summary>
/// <param name="keyId">The key identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if key exists and can sign, false otherwise.</returns>
Task<bool> CanSignAsync(string keyId, CancellationToken cancellationToken = default);
/// <summary>
/// Check if a key exists and is available for verification.
/// </summary>
/// <param name="keyId">The key identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if key exists and can verify, false otherwise.</returns>
Task<bool> CanVerifyAsync(string keyId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,98 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Signing;
/// <summary>
/// Service for signing and verifying reachability graphs using DSSE envelopes.
/// </summary>
public interface IReachGraphSignerService
{
/// <summary>
/// Sign a reachability graph using DSSE envelope format.
/// </summary>
/// <param name="graph">The graph to sign.</param>
/// <param name="keyId">The key identifier to use for signing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The graph with signature attached.</returns>
Task<ReachGraphMinimal> SignAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Verify signatures on a reachability graph.
/// </summary>
/// <param name="graph">The graph to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with valid/invalid key IDs.</returns>
Task<ReachGraphVerificationResult> VerifyAsync(
ReachGraphMinimal graph,
CancellationToken cancellationToken = default);
/// <summary>
/// Create a DSSE envelope for a reachability graph.
/// </summary>
/// <param name="graph">The graph to envelope.</param>
/// <param name="keyId">The key identifier to use for signing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Serialized DSSE envelope bytes.</returns>
Task<byte[]> CreateDsseEnvelopeAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of reachability graph signature verification.
/// </summary>
public sealed record ReachGraphVerificationResult
{
/// <summary>
/// Gets whether all signatures are valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Gets the key IDs with valid signatures.
/// </summary>
public required ImmutableArray<string> ValidKeyIds { get; init; }
/// <summary>
/// Gets the key IDs with invalid signatures.
/// </summary>
public required ImmutableArray<string> InvalidKeyIds { get; init; }
/// <summary>
/// Gets the error message if verification failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static ReachGraphVerificationResult Success(ImmutableArray<string> validKeyIds) =>
new()
{
IsValid = true,
ValidKeyIds = validKeyIds,
InvalidKeyIds = []
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static ReachGraphVerificationResult Failure(
ImmutableArray<string> validKeyIds,
ImmutableArray<string> invalidKeyIds,
string? error = null) =>
new()
{
IsValid = false,
ValidKeyIds = validKeyIds,
InvalidKeyIds = invalidKeyIds,
Error = error
};
}

View File

@@ -0,0 +1,223 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.ReachGraph.Hashing;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.Serialization;
namespace StellaOps.ReachGraph.Signing;
/// <summary>
/// DSSE-based signing service for reachability graphs.
/// Wraps the Attestor envelope signing infrastructure.
/// </summary>
public sealed class ReachGraphSignerService : IReachGraphSignerService
{
private const string PayloadType = "application/vnd.stellaops.reachgraph.min+json";
private readonly IReachGraphKeyStore _keyStore;
private readonly CanonicalReachGraphSerializer _serializer;
private readonly ReachGraphDigestComputer _digestComputer;
private readonly ILogger<ReachGraphSignerService> _logger;
public ReachGraphSignerService(
IReachGraphKeyStore keyStore,
CanonicalReachGraphSerializer serializer,
ReachGraphDigestComputer digestComputer,
ILogger<ReachGraphSignerService> logger)
{
_keyStore = keyStore ?? throw new ArgumentNullException(nameof(keyStore));
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
_digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<ReachGraphMinimal> SignAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(keyId);
_logger.LogDebug("Signing reachability graph with key {KeyId}", keyId);
// Get canonical JSON (without existing signatures)
var unsigned = graph with { Signatures = null };
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
// Compute PAE (Pre-Authentication Encoding) for DSSE
var pae = ComputePae(PayloadType, canonicalBytes);
// Sign with the key
var signatureBytes = await _keyStore.SignAsync(keyId, pae, cancellationToken);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
// Create new signature entry
var newSignature = new ReachGraphSignature(keyId, signatureBase64);
// Append to existing signatures (if any) and return
var existingSignatures = graph.Signatures ?? [];
var allSignatures = existingSignatures.Add(newSignature);
// Sort signatures by KeyId for determinism
allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)];
_logger.LogInformation(
"Signed reachability graph with key {KeyId}, digest {Digest}",
keyId, _digestComputer.ComputeDigest(unsigned));
return graph with { Signatures = allSignatures };
}
/// <inheritdoc />
public async Task<ReachGraphVerificationResult> VerifyAsync(
ReachGraphMinimal graph,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
if (graph.Signatures is null or { Length: 0 })
{
return ReachGraphVerificationResult.Failure([], [], "No signatures to verify");
}
_logger.LogDebug("Verifying {Count} signature(s) on reachability graph", graph.Signatures.Value.Length);
// Get canonical JSON (without signatures)
var unsigned = graph with { Signatures = null };
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
// Compute PAE
var pae = ComputePae(PayloadType, canonicalBytes);
var validKeyIds = new List<string>();
var invalidKeyIds = new List<string>();
foreach (var signature in graph.Signatures.Value)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var signatureBytes = Convert.FromBase64String(signature.Sig);
var isValid = await _keyStore.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken);
if (isValid)
{
validKeyIds.Add(signature.KeyId);
}
else
{
invalidKeyIds.Add(signature.KeyId);
}
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
_logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId);
invalidKeyIds.Add(signature.KeyId);
}
}
var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0;
_logger.LogInformation(
"Verification result: {Valid} valid, {Invalid} invalid signatures",
validKeyIds.Count, invalidKeyIds.Count);
return isAllValid
? ReachGraphVerificationResult.Success([.. validKeyIds])
: ReachGraphVerificationResult.Failure(
[.. validKeyIds],
[.. invalidKeyIds],
invalidKeyIds.Count > 0 ? $"{invalidKeyIds.Count} signature(s) failed verification" : null);
}
/// <inheritdoc />
public async Task<byte[]> CreateDsseEnvelopeAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(keyId);
// Sign the graph first
var signedGraph = await SignAsync(graph, keyId, cancellationToken);
// Get canonical JSON for the signed graph
var canonicalBytes = _serializer.SerializeMinimal(signedGraph);
// Build DSSE envelope JSON
return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []);
}
/// <summary>
/// Compute DSSE Pre-Authentication Encoding (PAE).
/// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload
/// </summary>
private static byte[] ComputePae(string payloadType, byte[] payload)
{
// PAE format: "DSSEv1" + 8-byte LE length of type + type bytes + 8-byte LE length of payload + payload
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefix = Encoding.UTF8.GetBytes("DSSEv1 ");
var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length];
var offset = 0;
// Copy "DSSEv1 "
Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length);
offset += prefix.Length;
// Write type length as 8-byte LE
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length);
offset += 8;
// Copy type bytes
Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length);
offset += typeBytes.Length;
// Write payload length as 8-byte LE
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length);
offset += 8;
// Copy payload
Buffer.BlockCopy(payload, 0, result, offset, payload.Length);
return result;
}
/// <summary>
/// Build a DSSE envelope JSON document.
/// </summary>
private static byte[] BuildDsseEnvelopeJson(byte[] payload, ImmutableArray<ReachGraphSignature> signatures)
{
var payloadBase64 = Convert.ToBase64String(payload);
using var ms = new MemoryStream();
using var writer = new System.Text.Json.Utf8JsonWriter(ms);
writer.WriteStartObject();
writer.WriteString("payloadType", PayloadType);
writer.WriteString("payload", payloadBase64);
writer.WritePropertyName("signatures");
writer.WriteStartArray();
foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
{
writer.WriteStartObject();
writer.WriteString("keyid", sig.KeyId);
writer.WriteString("sig", sig.Sig);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return ms.ToArray();
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.ReachGraph</RootNamespace>
<Description>Unified reachability subgraph store for StellaOps</Description>
<Authors>StellaOps</Authors>
<PackageId>StellaOps.ReachGraph</PackageId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blake3" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.ReachGraph.Tests" />
</ItemGroup>
</Project>