up
This commit is contained in:
@@ -13,15 +13,38 @@ public sealed class CryptoProviderRegistryOptions
|
||||
private readonly Dictionary<string, CryptoProviderProfileOptions> profiles =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Registry configuration factory that aligns with the 2025-11-18 sovereign crypto decision.
|
||||
/// </summary>
|
||||
public static CryptoProviderRegistryOptions SovereignDefault()
|
||||
{
|
||||
var options = new CryptoProviderRegistryOptions
|
||||
{
|
||||
ActiveProfile = "ru-offline"
|
||||
};
|
||||
|
||||
options.PreferredProviders.Add("default");
|
||||
options.PreferredProviders.Add("ru.openssl.gost");
|
||||
options.PreferredProviders.Add("ru.pkcs11");
|
||||
|
||||
var ruOffline = new CryptoProviderProfileOptions();
|
||||
ruOffline.PreferredProviders.Add("ru.cryptopro.csp");
|
||||
ruOffline.PreferredProviders.Add("ru.openssl.gost");
|
||||
ruOffline.PreferredProviders.Add("ru.pkcs11");
|
||||
options.Profiles["ru-offline"] = ruOffline;
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of preferred provider names. Providers appearing here are consulted first.
|
||||
/// </summary>
|
||||
public IList<string> PreferredProviders { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional active profile name (e.g. "ru-offline") that overrides <see cref="PreferredProviders"/>.
|
||||
/// Active profile name (e.g. "ru-offline") that overrides <see cref="PreferredProviders"/>.
|
||||
/// </summary>
|
||||
public string? ActiveProfile { get; set; }
|
||||
public string ActiveProfile { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Regional or environment-specific provider preference profiles.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="IT.GostCryptography" Version="6.0.0.1" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
@@ -18,6 +17,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\third_party\\forks\\AlexMAS.GostCryptography\\Source\\GostCryptography\\GostCryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# CryptoPro Plugin Tasks
|
||||
|
||||
- [ ] SEC-CRYPTO-90-019: Run fork test suite on Windows runner with CryptoPro CSP; capture results.
|
||||
- [ ] SEC-CRYPTO-90-020: Run plugin smoke (sign/verify) on Windows runner with CSP; capture results.
|
||||
- [ ] Add platform gating in CI: ensure `cryptopro-optin` workflow wired to Windows runner that has CSP installed.
|
||||
- [ ] Publish runbook updates after tests pass (link to docs/security/rootpack_ru_crypto_fork.md).
|
||||
@@ -14,10 +14,12 @@ public sealed class CryptoProviderRegistry : ICryptoProviderRegistry
|
||||
private readonly IReadOnlyDictionary<string, ICryptoProvider> providersByName;
|
||||
private readonly IReadOnlyList<string> preferredOrder;
|
||||
private readonly HashSet<string> preferredOrderSet;
|
||||
private readonly CryptoRegistryProfiles profiles;
|
||||
|
||||
public CryptoProviderRegistry(
|
||||
IEnumerable<ICryptoProvider> providers,
|
||||
IEnumerable<string>? preferredProviderOrder = null)
|
||||
IEnumerable<string>? preferredProviderOrder = null,
|
||||
CryptoRegistryProfiles? registryProfiles = null)
|
||||
{
|
||||
if (providers is null)
|
||||
{
|
||||
@@ -33,10 +35,17 @@ public sealed class CryptoProviderRegistry : ICryptoProviderRegistry
|
||||
providersByName = providerList.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
|
||||
this.providers = new ReadOnlyCollection<ICryptoProvider>(providerList);
|
||||
|
||||
preferredOrder = preferredProviderOrder?
|
||||
var baseOrder = preferredProviderOrder?
|
||||
.Where(name => providersByName.ContainsKey(name))
|
||||
.Select(name => providersByName[name].Name)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
profiles = registryProfiles ?? new CryptoRegistryProfiles(baseOrder, "default",
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["default"] = baseOrder
|
||||
});
|
||||
|
||||
preferredOrder = profiles.ResolvePreferredOrder();
|
||||
preferredOrderSet = new HashSet<string>(preferredOrder, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
public sealed class CryptoRegistryProfiles
|
||||
{
|
||||
public IReadOnlyList<string> PreferredProviders { get; }
|
||||
public string ActiveProfile { get; }
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>> Profiles { get; }
|
||||
|
||||
public CryptoRegistryProfiles(
|
||||
IEnumerable<string> preferredProviders,
|
||||
string activeProfile,
|
||||
IDictionary<string, IReadOnlyList<string>> profiles)
|
||||
{
|
||||
PreferredProviders = (preferredProviders ?? throw new ArgumentNullException(nameof(preferredProviders)))
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p.Trim())
|
||||
.ToArray();
|
||||
|
||||
ActiveProfile = string.IsNullOrWhiteSpace(activeProfile)
|
||||
? throw new ArgumentException("Active profile is required", nameof(activeProfile))
|
||||
: activeProfile.Trim();
|
||||
|
||||
Profiles = new ReadOnlyDictionary<string, IReadOnlyList<string>>(profiles ??
|
||||
throw new ArgumentNullException(nameof(profiles)));
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ResolvePreferredOrder(string? profileName = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(profileName) && Profiles.TryGetValue(profileName!, out var specific))
|
||||
{
|
||||
return specific;
|
||||
}
|
||||
|
||||
if (Profiles.TryGetValue(ActiveProfile, out var active))
|
||||
{
|
||||
return active;
|
||||
}
|
||||
|
||||
return PreferredProviders;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
public class ReplayManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializesWithNamespacesAndAnalysis()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
AnalysisId = "analysis-123"
|
||||
}
|
||||
};
|
||||
|
||||
manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
CasUri = "cas://reachability_graphs/aa/aagraph.tar.zst",
|
||||
Sha256 = "aa",
|
||||
Namespace = "reachability_graphs",
|
||||
CallgraphId = "cg-1",
|
||||
Analyzer = "scanner",
|
||||
Version = "0.1"
|
||||
});
|
||||
|
||||
manifest.AddReachabilityTrace(new ReplayReachabilityTraceReference
|
||||
{
|
||||
Source = "runtime",
|
||||
CasUri = "cas://runtime_traces/bb/bbtrace.tar.zst",
|
||||
Sha256 = "bb",
|
||||
Namespace = "runtime_traces",
|
||||
RecordedAt = System.DateTimeOffset.Parse("2025-11-26T00:00:00Z")
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.Contains("\"analysisId\":\"analysis-123\"", json);
|
||||
Assert.Contains("\"namespace\":\"reachability_graphs\"", json);
|
||||
Assert.Contains("\"callgraphId\":\"cg-1\"", json);
|
||||
Assert.Contains("\"namespace\":\"runtime_traces\"", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<NoWarn>$(NoWarn);NETSDK1188</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -13,7 +13,7 @@ Own shared replay domain types, canonicalisation helpers, bundle hashing utiliti
|
||||
1. Maintain deterministic behaviour (lexicographic ordering, canonical JSON, fixed encodings).
|
||||
2. Keep APIs offline-friendly; no network dependencies.
|
||||
3. Coordinate schema and bundle changes with Scanner, Evidence Locker, CLI, and Docs guilds.
|
||||
4. Update module `TASKS.md` statuses alongside `docs/implplan/SPRINT_185_shared_replay_primitives.md`.
|
||||
4. Update module `TASKS.md` statuses alongside `docs/implplan/SPRINT_0185_0001_0001_shared_replay_primitives.md`.
|
||||
|
||||
## Contacts
|
||||
- BE-Base Platform Guild (primary)
|
||||
|
||||
89
src/__Libraries/StellaOps.Replay.Core/CanonicalJson.cs
Normal file
89
src/__Libraries/StellaOps.Replay.Core/CanonicalJson.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic, lexicographically ordered JSON suitable for hashing and DSSE payloads.
|
||||
/// </summary>
|
||||
public static class CanonicalJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value) => Encoding.UTF8.GetString(SerializeToUtf8Bytes(value));
|
||||
|
||||
public static byte[] SerializeToUtf8Bytes<T>(T value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var element = JsonSerializer.SerializeToElement(value, SerializerOptions);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false
|
||||
});
|
||||
|
||||
WriteCanonical(element, writer);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(property.Value, writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(item, writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
case JsonValueKind.Undefined:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/__Libraries/StellaOps.Replay.Core/DeterministicHash.cs
Normal file
59
src/__Libraries/StellaOps.Replay.Core/DeterministicHash.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic hashing helpers for canonical JSON payloads and Merkle construction.
|
||||
/// </summary>
|
||||
public static class DeterministicHash
|
||||
{
|
||||
public static string Sha256Hex(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(data, hash, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to compute SHA-256 hash.");
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Sha256Hex(string utf8) => Sha256Hex(Encoding.UTF8.GetBytes(utf8));
|
||||
|
||||
public static string MerkleRootHex(IEnumerable<byte[]> leaves)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leaves);
|
||||
|
||||
var currentLevel = leaves.Select(l => l ?? throw new ArgumentNullException(nameof(leaves), "Leaf cannot be null.")).Select(SHA256.HashData).ToList();
|
||||
if (currentLevel.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one leaf is required to compute a Merkle root.", nameof(leaves));
|
||||
}
|
||||
|
||||
while (currentLevel.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>((currentLevel.Count + 1) / 2);
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
var left = currentLevel[i];
|
||||
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
|
||||
|
||||
var combined = new byte[left.Length + right.Length];
|
||||
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
|
||||
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
|
||||
|
||||
nextLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(currentLevel[0]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string MerkleRootHex(IEnumerable<string> canonicalJsonNodes) =>
|
||||
MerkleRootHex(canonicalJsonNodes.Select(s => Encoding.UTF8.GetBytes(s ?? string.Empty)));
|
||||
}
|
||||
25
src/__Libraries/StellaOps.Replay.Core/DsseEnvelope.cs
Normal file
25
src/__Libraries/StellaOps.Replay.Core/DsseEnvelope.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public sealed record DsseSignature(string KeyId, string Sig);
|
||||
|
||||
public sealed record DsseEnvelope(string PayloadType, string Payload, IReadOnlyList<DsseSignature> Signatures)
|
||||
{
|
||||
public string DigestSha256 => DeterministicHash.Sha256Hex(Convert.FromBase64String(Payload));
|
||||
}
|
||||
|
||||
public static class DssePayloadBuilder
|
||||
{
|
||||
public const string ReplayPayloadType = "application/vnd.stellaops.replay+json";
|
||||
|
||||
public static DsseEnvelope BuildUnsigned<T>(T payload, string? payloadType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var bytes = CanonicalJson.SerializeToUtf8Bytes(payload);
|
||||
var envelope = new DsseEnvelope(payloadType ?? ReplayPayloadType, Convert.ToBase64String(bytes), Array.Empty<DsseSignature>());
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public sealed record ReplayBundleEntry(string Path, ReadOnlyMemory<byte> Content, int Mode = 0b110_100_100)
|
||||
{
|
||||
public const int DefaultFileMode = 0b110_100_100; // 0644
|
||||
}
|
||||
88
src/__Libraries/StellaOps.Replay.Core/ReplayBundleWriter.cs
Normal file
88
src/__Libraries/StellaOps.Replay.Core/ReplayBundleWriter.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public sealed record ReplayBundleWriteResult(string TarSha256, string ZstSha256, long TarBytes, long ZstBytes, string CasUri);
|
||||
|
||||
public static class ReplayBundleWriter
|
||||
{
|
||||
private const int DefaultBufferSize = 16 * 1024;
|
||||
|
||||
public static async Task<ReplayBundleWriteResult> WriteTarZstAsync(
|
||||
IEnumerable<ReplayBundleEntry> entries,
|
||||
Stream destination,
|
||||
int compressionLevel = 19,
|
||||
string? casPrefix = "replay",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
|
||||
var orderedEntries = entries.OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
|
||||
if (orderedEntries.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one bundle entry is required.", nameof(entries));
|
||||
}
|
||||
|
||||
await using var tarBuffer = new MemoryStream();
|
||||
await WriteDeterministicTarAsync(orderedEntries, tarBuffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tarBytes = tarBuffer.Length;
|
||||
var tarBytesSpan = tarBuffer.ToArray();
|
||||
var tarDigest = DeterministicHash.Sha256Hex(tarBytesSpan);
|
||||
|
||||
tarBuffer.Position = 0;
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
await using var hashingStream = new CryptoStream(destination, sha, CryptoStreamMode.Write, leaveOpen: true);
|
||||
await using (var compressor = new CompressionStream(hashingStream, compressionLevel, DefaultBufferSize, leaveOpen: true))
|
||||
{
|
||||
await tarBuffer.CopyToAsync(compressor, DefaultBufferSize, cancellationToken).ConfigureAwait(false);
|
||||
await compressor.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
hashingStream.FlushFinalBlock();
|
||||
var zstDigest = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
|
||||
var casUri = BuildCasUri(zstDigest, casPrefix);
|
||||
|
||||
return new ReplayBundleWriteResult(tarDigest, zstDigest, tarBytes, destination.CanSeek ? destination.Position : -1, casUri);
|
||||
}
|
||||
|
||||
private static async Task WriteDeterministicTarAsync(IReadOnlyCollection<ReplayBundleEntry> entries, Stream tarStream, CancellationToken ct)
|
||||
{
|
||||
using var writer = new TarWriter(tarStream, TarEntryFormat.Pax, leaveOpen: true);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.Path)
|
||||
{
|
||||
Mode = (UnixFileMode)entry.Mode,
|
||||
ModificationTime = DateTimeOffset.UnixEpoch,
|
||||
DataStream = new MemoryStream(entry.Content.ToArray(), writable: false)
|
||||
};
|
||||
|
||||
writer.WriteEntry(tarEntry);
|
||||
}
|
||||
|
||||
await writer.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string BuildCasUri(string sha256Hex, string? prefix = "replay")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sha256Hex);
|
||||
if (sha256Hex.Length < 2)
|
||||
{
|
||||
throw new ArgumentException("Digest must be at least two hex characters for prefixing.", nameof(sha256Hex));
|
||||
}
|
||||
|
||||
var head = sha256Hex[..2];
|
||||
var label = string.IsNullOrWhiteSpace(prefix) ? "replay" : prefix;
|
||||
return $"cas://{label}/{head}/{sha256Hex}.tar.zst";
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,13 @@ namespace StellaOps.Replay.Core;
|
||||
public sealed class ReplayManifest
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = "1.0";
|
||||
public string SchemaVersion { get; set; } = ReplayManifestVersions.V1;
|
||||
|
||||
[JsonPropertyName("scan")]
|
||||
public ReplayScanMetadata Scan { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ReplayReachabilitySection? Reachability { get; set; }
|
||||
public ReplayReachabilitySection Reachability { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReplayScanMetadata
|
||||
@@ -23,11 +22,14 @@ public sealed class ReplayScanMetadata
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("time")]
|
||||
public DateTimeOffset Time { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset Time { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
public sealed class ReplayReachabilitySection
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string? AnalysisId { get; set; }
|
||||
|
||||
[JsonPropertyName("graphs")]
|
||||
public List<ReplayReachabilityGraphReference> Graphs { get; set; } = new();
|
||||
|
||||
@@ -46,6 +48,12 @@ public sealed class ReplayReachabilityGraphReference
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "reachability_graphs";
|
||||
|
||||
[JsonPropertyName("callgraphId")]
|
||||
public string? CallgraphId { get; set; }
|
||||
|
||||
[JsonPropertyName("analyzer")]
|
||||
public string Analyzer { get; set; } = string.Empty;
|
||||
|
||||
@@ -64,6 +72,14 @@ public sealed class ReplayReachabilityTraceReference
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "runtime_traces";
|
||||
|
||||
[JsonPropertyName("recordedAt")]
|
||||
public DateTimeOffset RecordedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset RecordedAt { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
public static class ReplayManifestVersions
|
||||
{
|
||||
public const string V1 = "1.0";
|
||||
}
|
||||
|
||||
@@ -19,4 +19,22 @@ public static class ReplayManifestExtensions
|
||||
manifest.Reachability ??= new ReplayReachabilitySection();
|
||||
manifest.Reachability.RuntimeTraces.Add(trace);
|
||||
}
|
||||
|
||||
public static byte[] ToCanonicalJson(this ReplayManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return CanonicalJson.SerializeToUtf8Bytes(manifest);
|
||||
}
|
||||
|
||||
public static string ComputeCanonicalSha256(this ReplayManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return DeterministicHash.Sha256Hex(manifest.ToCanonicalJson());
|
||||
}
|
||||
|
||||
public static DsseEnvelope ToDsseEnvelope(this ReplayManifest manifest, string? payloadType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return DssePayloadBuilder.BuildUnsigned(manifest, payloadType);
|
||||
}
|
||||
}
|
||||
|
||||
94
src/__Libraries/StellaOps.Replay.Core/ReplayMongoModels.cs
Normal file
94
src/__Libraries/StellaOps.Replay.Core/ReplayMongoModels.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public static class ReplayCollections
|
||||
{
|
||||
public const string Runs = "replay_runs";
|
||||
public const string Bundles = "replay_bundles";
|
||||
public const string Subjects = "replay_subjects";
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReplayRunRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty; // scan UUID
|
||||
|
||||
public string ManifestHash { get; set; } = string.Empty; // sha256:...
|
||||
|
||||
public string Status { get; set; } = "pending"; // verified|failed|replayed
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
|
||||
public ReplayRunOutputs Outputs { get; set; } = new();
|
||||
|
||||
public List<ReplaySignatureRecord> Signatures { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReplayRunOutputs
|
||||
{
|
||||
public string Sbom { get; set; } = string.Empty; // sha256:...
|
||||
public string Findings { get; set; } = string.Empty;
|
||||
public string? Vex { get; set; }
|
||||
= null;
|
||||
public string? Log { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
public sealed class ReplaySignatureRecord
|
||||
{
|
||||
public string Profile { get; set; } = string.Empty; // e.g., FIPS, GOST
|
||||
public bool Verified { get; set; }
|
||||
= false;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReplayBundleRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty; // sha256 hex
|
||||
|
||||
public string Type { get; set; } = string.Empty; // input|output|rootpack|reachability
|
||||
|
||||
public long Size { get; set; }
|
||||
= 0;
|
||||
|
||||
public string Location { get; set; } = string.Empty; // cas://.../digest.tar.zst
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReplaySubjectRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string OciDigest { get; set; } = string.Empty;
|
||||
|
||||
public List<ReplayLayerRecord> Layers { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReplayLayerRecord
|
||||
{
|
||||
public string LayerDigest { get; set; } = string.Empty;
|
||||
public string MerkleRoot { get; set; } = string.Empty;
|
||||
public int LeafCount { get; set; }
|
||||
= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index names to keep mongod migrations deterministic.
|
||||
/// </summary>
|
||||
public static class ReplayIndexes
|
||||
{
|
||||
public const string Runs_ManifestHash = "runs_manifestHash_unique";
|
||||
public const string Runs_Status_CreatedAt = "runs_status_createdAt";
|
||||
public const string Bundles_Type = "bundles_type";
|
||||
public const string Bundles_Location = "bundles_location";
|
||||
public const string Subjects_LayerDigest = "subjects_layerDigest";
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="ZstdSharp.Port" Version="0.8.6" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="2.25.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
16
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
16
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# StellaOps.Replay.Core task board
|
||||
|
||||
Keep this table in sync with `docs/implplan/SPRINT_0185_0001_0001_shared_replay_primitives.md`.
|
||||
|
||||
| Task ID | Status | Owners | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| REPLAY-CORE-185-001 | DONE (2025-11-25) | BE-Base Platform Guild | Library scaffolding: manifest schema types, canonical JSON rules, Merkle utilities, DSSE payload builders. |
|
||||
| REPLAY-CORE-185-002 | DONE (2025-11-25) | Platform Guild | Deterministic bundle writer (tar.zst, CAS naming) and hashing abstractions; update platform architecture doc with “Replay CAS” subsection. |
|
||||
| REPLAY-CORE-185-003 | DONE (2025-11-25) | Platform Data Guild | Mongo collections (`replay_runs`, `replay_bundles`, `replay_subjects`) and indices aligned with schema doc. |
|
||||
| DOCS-REPLAY-185-003 | DONE (2025-11-25) | Docs Guild · Platform Data Guild | `docs/data/replay_schema.md` detailing collections, index guidance, offline sync strategy. |
|
||||
| DOCS-REPLAY-185-004 | DONE (2025-11-25) | Docs Guild | Expand `docs/replay/DEVS_GUIDE_REPLAY.md` with integration guidance and deterministic replay checklist. |
|
||||
|
||||
## Status rules
|
||||
- Use TODO → DOING → DONE/BLOCKED and mirror every change in the sprint Delivery Tracker.
|
||||
- Note dates in parentheses when flipping to DOING/DONE for traceability.
|
||||
- Capture contract or runbook changes in the relevant docs under `docs/replay` or `docs/data`.
|
||||
@@ -14,6 +14,16 @@ public class CryptoProGostSignerTests
|
||||
[Fact]
|
||||
public void ExportPublicJsonWebKey_ContainsCertificateChain()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return; // CryptoPro CSP is Windows-only; skip on other platforms
|
||||
}
|
||||
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_PRO_ENABLED"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
return; // opt-in only when a Windows agent has CryptoPro CSP installed
|
||||
}
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest("CN=stellaops.test", ecdsa, HashAlgorithmName.SHA256);
|
||||
using var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
Reference in New Issue
Block a user