Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Introduced `SbomService` tasks documentation. - Updated `StellaOps.sln` to include new projects: `StellaOps.AirGap.Time` and `StellaOps.AirGap.Importer`. - Added unit tests for `BundleImportPlanner`, `DsseVerifier`, `ImportValidator`, and other components in the `StellaOps.AirGap.Importer.Tests` namespace. - Implemented `InMemoryBundleRepositories` for testing bundle catalog and item repositories. - Created `MerkleRootCalculator`, `RootRotationPolicy`, and `TufMetadataValidator` tests. - Developed `StalenessCalculator` and `TimeAnchorLoader` tests in the `StellaOps.AirGap.Time.Tests` namespace. - Added `fetch-sbomservice-deps.sh` script for offline dependency fetching.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the minimal trust-root inputs the importer requires before
|
||||
/// processing any offline bundle.
|
||||
/// </summary>
|
||||
public sealed record TrustRootConfig(
|
||||
string RootBundlePath,
|
||||
IReadOnlyCollection<string> TrustedKeyFingerprints,
|
||||
IReadOnlyCollection<string> AllowedSignatureAlgorithms,
|
||||
DateTimeOffset? NotBeforeUtc,
|
||||
DateTimeOffset? NotAfterUtc,
|
||||
IReadOnlyDictionary<string, byte[]> PublicKeys)
|
||||
{
|
||||
public static TrustRootConfig Empty(string rootBundlePath) =>
|
||||
new(rootBundlePath, Array.Empty<string>(), Array.Empty<string>(), null, null, new Dictionary<string, byte[]>());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.AirGap.Importer.Models;
|
||||
|
||||
public sealed record BundleCatalogEntry(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
string Digest,
|
||||
DateTimeOffset ImportedAtUtc,
|
||||
IReadOnlyList<string> ContentPaths);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.AirGap.Importer.Models;
|
||||
|
||||
public sealed record BundleItem(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
string Path,
|
||||
string Digest,
|
||||
long SizeBytes);
|
||||
@@ -0,0 +1,34 @@
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable plan describing the deterministic steps taken when importing an offline bundle.
|
||||
/// This keeps replay/attestation logic stable for audits.
|
||||
/// </summary>
|
||||
public sealed record BundleImportPlan(
|
||||
IReadOnlyList<string> Steps,
|
||||
IReadOnlyDictionary<string, string> Inputs,
|
||||
BundleValidationResult InitialState)
|
||||
{
|
||||
public static BundleImportPlan FromDefaults(string bundlePath) => new List<string>
|
||||
{
|
||||
"load-trust-roots",
|
||||
"read-bundle-manifest",
|
||||
"verify-dsse-signature",
|
||||
"verify-manifest-digests",
|
||||
"validate-tuf-metadata",
|
||||
"compute-merkle-root",
|
||||
"stage-object-store",
|
||||
"record-audit-entry"
|
||||
}.AsReadOnlyPlan(bundlePath);
|
||||
}
|
||||
|
||||
file static class BundleImportPlanExtensions
|
||||
{
|
||||
public static BundleImportPlan AsReadOnlyPlan(this IReadOnlyList<string> steps, string bundlePath) =>
|
||||
new(steps, new Dictionary<string, string>
|
||||
{
|
||||
["bundlePath"] = bundlePath
|
||||
}, BundleValidationResult.Success("plan-staged"));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic import plans and performs fast pre-flight checks before expensive validation work.
|
||||
/// </summary>
|
||||
public sealed class BundleImportPlanner
|
||||
{
|
||||
public BundleImportPlan CreatePlan(string bundlePath, TrustRootConfig trustRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
return new BundleImportPlan(
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string> { ["bundlePath"] = "(missing)" },
|
||||
BundleValidationResult.Failure("bundle-path-required"));
|
||||
}
|
||||
|
||||
if (trustRoot.TrustedKeyFingerprints.Count == 0)
|
||||
{
|
||||
return new BundleImportPlan(
|
||||
new[] { "load-trust-roots" },
|
||||
new Dictionary<string, string> { ["bundlePath"] = bundlePath },
|
||||
BundleValidationResult.Failure("trust-roots-required"));
|
||||
}
|
||||
|
||||
if (trustRoot.NotAfterUtc is { } notAfter && trustRoot.NotBeforeUtc is { } notBefore && notAfter < notBefore)
|
||||
{
|
||||
return new BundleImportPlan(
|
||||
new[] { "load-trust-roots" },
|
||||
new Dictionary<string, string> { ["bundlePath"] = bundlePath },
|
||||
BundleValidationResult.Failure("invalid-trust-window"));
|
||||
}
|
||||
|
||||
return BundleImportPlan.FromDefaults(bundlePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.AirGap.Importer.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Repositories;
|
||||
|
||||
public interface IBundleCatalogRepository
|
||||
{
|
||||
Task UpsertAsync(BundleCatalogEntry entry, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<BundleCatalogEntry>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.AirGap.Importer.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Repositories;
|
||||
|
||||
public interface IBundleItemRepository
|
||||
{
|
||||
Task UpsertManyAsync(IEnumerable<BundleItem> items, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<BundleItem>> ListByBundleAsync(string tenantId, string bundleId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.AirGap.Importer.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic in-memory implementations suitable for offline tests and as a template for Mongo-backed repos.
|
||||
/// Enforces tenant isolation and stable ordering (by BundleId then Path).
|
||||
/// </summary>
|
||||
public sealed class InMemoryBundleCatalogRepository : IBundleCatalogRepository
|
||||
{
|
||||
private readonly Dictionary<string, List<BundleCatalogEntry>> _catalog = new(StringComparer.Ordinal);
|
||||
|
||||
public Task UpsertAsync(BundleCatalogEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var bucket = _catalog.GetValueOrDefault(entry.TenantId) ?? new List<BundleCatalogEntry>();
|
||||
bucket.RemoveAll(e => e.BundleId == entry.BundleId);
|
||||
bucket.Add(entry);
|
||||
_catalog[entry.TenantId] = bucket;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BundleCatalogEntry>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var items = _catalog.GetValueOrDefault(tenantId) ?? new List<BundleCatalogEntry>();
|
||||
return Task.FromResult<IReadOnlyList<BundleCatalogEntry>>(items
|
||||
.OrderBy(e => e.BundleId, StringComparer.Ordinal)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InMemoryBundleItemRepository : IBundleItemRepository
|
||||
{
|
||||
private readonly Dictionary<(string TenantId, string BundleId), List<BundleItem>> _items = new();
|
||||
|
||||
public Task UpsertManyAsync(IEnumerable<BundleItem> items, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var key = (item.TenantId, item.BundleId);
|
||||
if (!_items.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<BundleItem>();
|
||||
_items[key] = list;
|
||||
}
|
||||
list.RemoveAll(i => i.Path == item.Path);
|
||||
list.Add(item);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BundleItem>> ListByBundleAsync(string tenantId, string bundleId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var key = (tenantId, bundleId);
|
||||
var list = _items.GetValueOrDefault(key) ?? new List<BundleItem>();
|
||||
return Task.FromResult<IReadOnlyList<BundleItem>>(list
|
||||
.OrderBy(i => i.Path, StringComparer.Ordinal)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Importer</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic validation outcome used by importer and CLI surfaces.
|
||||
/// </summary>
|
||||
public sealed record BundleValidationResult(bool IsValid, string Reason)
|
||||
{
|
||||
public static BundleValidationResult Success(string reason = "ok") => new(true, reason);
|
||||
public static BundleValidationResult Failure(string reason) => new(false, reason);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
public sealed record DsseEnvelope(
|
||||
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||
[property: JsonPropertyName("payload")] string Payload,
|
||||
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures)
|
||||
{
|
||||
public static DsseEnvelope Parse(string json)
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new InvalidDataException("dsse-envelope-invalid-json");
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DsseSignature(
|
||||
[property: JsonPropertyName("keyid")] string KeyId,
|
||||
[property: JsonPropertyName("sig")] string Signature);
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal DSSE verifier supporting RSA-PSS/SHA256. The implementation focuses on deterministic
|
||||
/// pre-authentication encoding (PAE) and fingerprint checks so sealed-mode environments can run
|
||||
/// without dragging additional deps.
|
||||
/// </summary>
|
||||
public sealed class DsseVerifier
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
|
||||
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots)
|
||||
{
|
||||
if (trustRoots.TrustedKeyFingerprints.Count == 0 || trustRoots.PublicKeys.Count == 0)
|
||||
{
|
||||
return BundleValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
foreach (var signature in envelope.Signatures)
|
||||
{
|
||||
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fingerprint = ComputeFingerprint(keyBytes);
|
||||
if (!trustRoots.TrustedKeyFingerprints.Contains(fingerprint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
|
||||
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
|
||||
{
|
||||
return BundleValidationResult.Success("dsse-signature-verified");
|
||||
}
|
||||
}
|
||||
|
||||
return BundleValidationResult.Failure("dsse-signature-untrusted-or-invalid");
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, string payloadBase64)
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var parts = new[]
|
||||
{
|
||||
PaePrefix,
|
||||
payloadType,
|
||||
Encoding.UTF8.GetString(payloadBytes)
|
||||
};
|
||||
|
||||
var paeBuilder = new StringBuilder();
|
||||
paeBuilder.Append("PAE:");
|
||||
paeBuilder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part.Length);
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
||||
}
|
||||
|
||||
private static bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, string signatureBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
||||
var sig = Convert.FromBase64String(signatureBase64);
|
||||
return rsa.VerifyData(pae, sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(byte[] publicKey)
|
||||
{
|
||||
var hash = SHA256.HashData(publicKey);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates DSSE, TUF, and Merkle verification for an offline import. Stateless and deterministic.
|
||||
/// </summary>
|
||||
public sealed class ImportValidator
|
||||
{
|
||||
private readonly DsseVerifier _dsse;
|
||||
private readonly TufMetadataValidator _tuf;
|
||||
private readonly MerkleRootCalculator _merkle;
|
||||
private readonly RootRotationPolicy _rotation;
|
||||
|
||||
public ImportValidator()
|
||||
{
|
||||
_dsse = new DsseVerifier();
|
||||
_tuf = new TufMetadataValidator();
|
||||
_merkle = new MerkleRootCalculator();
|
||||
_rotation = new RootRotationPolicy();
|
||||
}
|
||||
|
||||
public BundleValidationResult Validate(ImportValidationRequest request)
|
||||
{
|
||||
var tufResult = _tuf.Validate(request.RootJson, request.SnapshotJson, request.TimestampJson);
|
||||
if (!tufResult.IsValid)
|
||||
{
|
||||
return tufResult with { Reason = $"tuf:{tufResult.Reason}" };
|
||||
}
|
||||
|
||||
var dsseResult = _dsse.Verify(request.Envelope, request.TrustRoots);
|
||||
if (!dsseResult.IsValid)
|
||||
{
|
||||
return dsseResult with { Reason = $"dsse:{dsseResult.Reason}" };
|
||||
}
|
||||
|
||||
var merkleRoot = _merkle.ComputeRoot(request.PayloadEntries);
|
||||
if (string.IsNullOrEmpty(merkleRoot))
|
||||
{
|
||||
return BundleValidationResult.Failure("merkle-empty");
|
||||
}
|
||||
|
||||
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
|
||||
if (!rotationResult.IsValid)
|
||||
{
|
||||
return rotationResult with { Reason = $"rotation:{rotationResult.Reason}" };
|
||||
}
|
||||
|
||||
return BundleValidationResult.Success("import-validated");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ImportValidationRequest(
|
||||
DsseEnvelope Envelope,
|
||||
TrustRootConfig TrustRoots,
|
||||
string RootJson,
|
||||
string SnapshotJson,
|
||||
string TimestampJson,
|
||||
IReadOnlyList<NamedStream> PayloadEntries,
|
||||
TrustStore TrustStore,
|
||||
IReadOnlyCollection<string> ApproverIds);
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic Merkle-like root by hashing ordered leaf entries (path + SHA256 of content).
|
||||
/// Paths are sorted ordinally to stay stable across platforms.
|
||||
/// </summary>
|
||||
public sealed class MerkleRootCalculator
|
||||
{
|
||||
public string ComputeRoot(IEnumerable<NamedStream> entries)
|
||||
{
|
||||
var leaves = entries
|
||||
.OrderBy(e => e.Path, StringComparer.Ordinal)
|
||||
.Select(HashLeaf)
|
||||
.ToArray();
|
||||
|
||||
if (leaves.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
while (leaves.Length > 1)
|
||||
{
|
||||
leaves = Pairwise(leaves).ToArray();
|
||||
}
|
||||
|
||||
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte[] HashLeaf(NamedStream entry)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var buffer = new MemoryStream();
|
||||
entry.Stream.Seek(0, SeekOrigin.Begin);
|
||||
entry.Stream.CopyTo(buffer);
|
||||
var contentHash = sha256.ComputeHash(buffer.ToArray());
|
||||
|
||||
var leafBytes = Encoding.UTF8.GetBytes(entry.Path.ToLowerInvariant() + ":" + Convert.ToHexString(contentHash).ToLowerInvariant());
|
||||
return SHA256.HashData(leafBytes);
|
||||
}
|
||||
|
||||
private static IEnumerable<byte[]> Pairwise(IReadOnlyList<byte[]> nodes)
|
||||
{
|
||||
for (var i = 0; i < nodes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 >= nodes.Count)
|
||||
{
|
||||
yield return SHA256.HashData(nodes[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
|
||||
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
|
||||
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
|
||||
yield return SHA256.HashData(combined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NamedStream(string Path, Stream Stream);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces root rotation safety by requiring dual approval and non-empty key sets.
|
||||
/// </summary>
|
||||
public sealed class RootRotationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that pending keys can replace the active set. Requires at least two distinct approvers
|
||||
/// and non-empty pending keys. Approvers should be identities recorded in audit log.
|
||||
/// </summary>
|
||||
public BundleValidationResult Validate(IReadOnlyDictionary<string, byte[]> activeKeys, IReadOnlyDictionary<string, byte[]> pendingKeys, IReadOnlyCollection<string> approverIds)
|
||||
{
|
||||
if (pendingKeys.Count == 0)
|
||||
{
|
||||
return BundleValidationResult.Failure("rotation-pending-empty");
|
||||
}
|
||||
|
||||
if (approverIds.Count < 2 || approverIds.Distinct(StringComparer.Ordinal).Count() < 2)
|
||||
{
|
||||
return BundleValidationResult.Failure("rotation-dual-approval-required");
|
||||
}
|
||||
|
||||
// Prevent accidental no-op rotations.
|
||||
if (activeKeys.Count == pendingKeys.Count && activeKeys.Keys.All(k => pendingKeys.ContainsKey(k)))
|
||||
{
|
||||
return BundleValidationResult.Failure("rotation-no-change");
|
||||
}
|
||||
|
||||
return BundleValidationResult.Success("rotation-approved");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory trust store for DSSE/TUF public keys. Designed to be deterministic and side-effect free
|
||||
/// so it can be reused by offline pipelines and tests.
|
||||
/// </summary>
|
||||
public sealed class TrustStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _activeKeys = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, byte[]> _pendingKeys = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the active key set. Existing keys are replaced to keep runs deterministic.
|
||||
/// </summary>
|
||||
public void LoadActive(IDictionary<string, byte[]> keys)
|
||||
{
|
||||
_activeKeys.Clear();
|
||||
foreach (var kvp in keys)
|
||||
{
|
||||
_activeKeys[kvp.Key] = kvp.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds pending keys that require rotation approval.
|
||||
/// </summary>
|
||||
public void StagePending(IDictionary<string, byte[]> keys)
|
||||
{
|
||||
_pendingKeys.Clear();
|
||||
foreach (var kvp in keys)
|
||||
{
|
||||
_pendingKeys[kvp.Key] = kvp.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetActive(string keyId, out byte[] keyBytes) => _activeKeys.TryGetValue(keyId, out keyBytes!);
|
||||
|
||||
public IReadOnlyDictionary<string, byte[]> ActiveKeys => _activeKeys;
|
||||
public IReadOnlyDictionary<string, byte[]> PendingKeys => _pendingKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Promotes pending keys to active, after callers verify rotation policy (dual approval).
|
||||
/// </summary>
|
||||
public void PromotePending()
|
||||
{
|
||||
_activeKeys.Clear();
|
||||
foreach (var kvp in _pendingKeys)
|
||||
{
|
||||
_activeKeys[kvp.Key] = kvp.Value;
|
||||
}
|
||||
_pendingKeys.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
public sealed class TufMetadataValidator
|
||||
{
|
||||
public BundleValidationResult Validate(string rootJson, string snapshotJson, string timestampJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = JsonSerializer.Deserialize<TufRoot>(rootJson, Options()) ?? throw new InvalidDataException();
|
||||
var snapshot = JsonSerializer.Deserialize<TufSnapshot>(snapshotJson, Options()) ?? throw new InvalidDataException();
|
||||
var timestamp = JsonSerializer.Deserialize<TufTimestamp>(timestampJson, Options()) ?? throw new InvalidDataException();
|
||||
|
||||
if (root.Version <= 0 || snapshot.Version <= 0 || timestamp.Version <= 0)
|
||||
{
|
||||
return BundleValidationResult.Failure("tuf-version-invalid");
|
||||
}
|
||||
|
||||
if (root.ExpiresUtc <= DateTimeOffset.UnixEpoch || snapshot.ExpiresUtc <= DateTimeOffset.UnixEpoch || timestamp.ExpiresUtc <= DateTimeOffset.UnixEpoch)
|
||||
{
|
||||
return BundleValidationResult.Failure("tuf-expiry-invalid");
|
||||
}
|
||||
|
||||
// Minimal consistency check: timestamp references snapshot hash and version.
|
||||
if (!string.Equals(snapshot.MetadataHash, timestamp.Snapshot.Meta.Hashes.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BundleValidationResult.Failure("tuf-snapshot-hash-mismatch");
|
||||
}
|
||||
|
||||
return BundleValidationResult.Success("tuf-metadata-valid");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BundleValidationResult.Failure($"tuf-parse-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions Options() => new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private sealed record TufRoot(int Version, DateTimeOffset ExpiresUtc);
|
||||
|
||||
private sealed record TufSnapshot(int Version, DateTimeOffset ExpiresUtc, SnapshotMeta Meta)
|
||||
{
|
||||
public string MetadataHash => Meta?.Snapshot?.Hashes?.Sha256 ?? string.Empty;
|
||||
}
|
||||
|
||||
private sealed record SnapshotMeta(SnapshotFile Snapshot);
|
||||
|
||||
private sealed record SnapshotFile(SnapshotHashes Hashes);
|
||||
|
||||
private sealed record SnapshotHashes(string Sha256);
|
||||
|
||||
private sealed record TufTimestamp(int Version, DateTimeOffset ExpiresUtc, TimestampMeta Snapshot);
|
||||
|
||||
private sealed record TimestampMeta(TimestampSnapshot Meta);
|
||||
|
||||
private sealed record TimestampSnapshot(TimestampHashes Hashes);
|
||||
|
||||
private sealed record TimestampHashes(string Sha256);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/time")]
|
||||
public class TimeStatusController : ControllerBase
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorLoader _loader;
|
||||
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_loader = loader;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<TimeStatusDto>> GetStatus([FromQuery] string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return BadRequest("tenantId-required");
|
||||
}
|
||||
|
||||
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
|
||||
[HttpPost("anchor")]
|
||||
public async Task<ActionResult<TimeStatusDto>> SetAnchor([FromBody] SetAnchorRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
var trustRoot = new TimeTrustRoot(
|
||||
request.TrustRootKeyId,
|
||||
Convert.FromBase64String(request.TrustRootPublicKeyBase64),
|
||||
request.TrustRootAlgorithm);
|
||||
|
||||
var result = _loader.TryLoadHex(
|
||||
request.HexToken,
|
||||
request.Format,
|
||||
new[] { trustRoot },
|
||||
out var anchor);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return BadRequest(result.Reason);
|
||||
}
|
||||
|
||||
var budget = new StalenessBudget(
|
||||
request.WarningSeconds ?? StalenessBudget.Default.WarningSeconds,
|
||||
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
|
||||
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
}
|
||||
28
src/AirGap/StellaOps.AirGap.Time/Models/SetAnchorRequest.cs
Normal file
28
src/AirGap/StellaOps.AirGap.Time/Models/SetAnchorRequest.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed class SetAnchorRequest
|
||||
{
|
||||
[Required]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string HexToken { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public TimeTokenFormat Format { get; set; }
|
||||
|
||||
[Required]
|
||||
public string TrustRootKeyId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string TrustRootAlgorithm { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string TrustRootPublicKeyBase64 { get; set; } = string.Empty;
|
||||
|
||||
public long? WarningSeconds { get; set; }
|
||||
public long? BreachSeconds { get; set; }
|
||||
}
|
||||
22
src/AirGap/StellaOps.AirGap.Time/Models/StalenessBudget.cs
Normal file
22
src/AirGap/StellaOps.AirGap.Time/Models/StalenessBudget.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents tolerated staleness for time anchors. Budgets are seconds and must be non-negative.
|
||||
/// </summary>
|
||||
public sealed record StalenessBudget(long WarningSeconds, long BreachSeconds)
|
||||
{
|
||||
public static StalenessBudget Default => new(3600, 7200);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (WarningSeconds < 0 || BreachSeconds < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(StalenessBudget), "budgets-must-be-non-negative");
|
||||
}
|
||||
|
||||
if (WarningSeconds > BreachSeconds)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(StalenessBudget), "warning-cannot-exceed-breach");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed record StalenessEvaluation(
|
||||
long AgeSeconds,
|
||||
long WarningSeconds,
|
||||
long BreachSeconds,
|
||||
bool IsWarning,
|
||||
bool IsBreach)
|
||||
{
|
||||
public static StalenessEvaluation Unknown => new(0, 0, 0, false, false);
|
||||
}
|
||||
14
src/AirGap/StellaOps.AirGap.Time/Models/TimeAnchor.cs
Normal file
14
src/AirGap/StellaOps.AirGap.Time/Models/TimeAnchor.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical representation of a trusted time anchor extracted from a signed token.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchor(
|
||||
DateTimeOffset AnchorTime,
|
||||
string Source,
|
||||
string Format,
|
||||
string SignatureFingerprint,
|
||||
string TokenDigest)
|
||||
{
|
||||
public static TimeAnchor Unknown => new(DateTimeOffset.MinValue, "unknown", "unknown", "", "");
|
||||
}
|
||||
10
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatus.cs
Normal file
10
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed record TimeStatus(
|
||||
TimeAnchor Anchor,
|
||||
StalenessEvaluation Staleness,
|
||||
StalenessBudget Budget,
|
||||
DateTimeOffset EvaluatedAtUtc)
|
||||
{
|
||||
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
|
||||
}
|
||||
44
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatusDto.cs
Normal file
44
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatusDto.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed record TimeStatusDto(
|
||||
[property: JsonPropertyName("anchorTime")] string AnchorTime,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("fingerprint")] string Fingerprint,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("ageSeconds")] long AgeSeconds,
|
||||
[property: JsonPropertyName("warningSeconds")] long WarningSeconds,
|
||||
[property: JsonPropertyName("breachSeconds")] long BreachSeconds,
|
||||
[property: JsonPropertyName("isWarning")] bool IsWarning,
|
||||
[property: JsonPropertyName("isBreach")] bool IsBreach,
|
||||
[property: JsonPropertyName("evaluatedAtUtc")] string EvaluatedAtUtc)
|
||||
{
|
||||
public static TimeStatusDto FromStatus(TimeStatus status)
|
||||
{
|
||||
return new TimeStatusDto(
|
||||
status.Anchor.AnchorTime.ToUniversalTime().ToString("O"),
|
||||
status.Anchor.Format,
|
||||
status.Anchor.Source,
|
||||
status.Anchor.SignatureFingerprint,
|
||||
status.Anchor.TokenDigest,
|
||||
status.Staleness.AgeSeconds,
|
||||
status.Staleness.WarningSeconds,
|
||||
status.Staleness.BreachSeconds,
|
||||
status.Staleness.IsWarning,
|
||||
status.Staleness.IsBreach,
|
||||
status.EvaluatedAtUtc.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false
|
||||
};
|
||||
return JsonSerializer.Serialize(this, options);
|
||||
}
|
||||
}
|
||||
3
src/AirGap/StellaOps.AirGap.Time/Models/TimeTrustRoot.cs
Normal file
3
src/AirGap/StellaOps.AirGap.Time/Models/TimeTrustRoot.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed record TimeTrustRoot(string KeyId, byte[] PublicKey, string Algorithm);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for a time anchor parse/verify attempt.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorValidationResult(bool IsValid, string Reason)
|
||||
{
|
||||
public static TimeAnchorValidationResult Success(string reason = "ok") => new(true, reason);
|
||||
public static TimeAnchorValidationResult Failure(string reason) => new(false, reason);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
public enum TimeTokenFormat
|
||||
{
|
||||
Roughtime,
|
||||
Rfc3161
|
||||
}
|
||||
41
src/AirGap/StellaOps.AirGap.Time/Parsing/TimeTokenParser.cs
Normal file
41
src/AirGap/StellaOps.AirGap.Time/Parsing/TimeTokenParser.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Performs minimal, deterministic parsing of signed time tokens. Full cryptographic verification
|
||||
/// is intentionally deferred; this parser focuses on structure and hash derivation so downstream
|
||||
/// components can stub replay flows in sealed environments.
|
||||
/// </summary>
|
||||
public sealed class TimeTokenParser
|
||||
{
|
||||
public TimeAnchorValidationResult TryParse(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, out TimeAnchor anchor)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
var digestBytes = SHA256.HashData(tokenBytes);
|
||||
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
|
||||
|
||||
// Derive a deterministic anchor time from digest bytes (no wall clock use).
|
||||
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365)); // wrap within ~1y for stability
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case TimeTokenFormat.Roughtime:
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", "(pending)", digest);
|
||||
return TimeAnchorValidationResult.Success("structure-stubbed");
|
||||
case TimeTokenFormat.Rfc3161:
|
||||
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", "(pending)", digest);
|
||||
return TimeAnchorValidationResult.Success("structure-stubbed");
|
||||
default:
|
||||
return TimeAnchorValidationResult.Failure("unknown-format");
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/AirGap/StellaOps.AirGap.Time/Program.cs
Normal file
19
src/AirGap/StellaOps.AirGap.Time/Program.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddSingleton<StalenessCalculator>();
|
||||
builder.Services.AddSingleton<TimeStatusService>();
|
||||
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
|
||||
builder.Services.AddSingleton<TimeVerificationService>();
|
||||
builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public interface ITimeTokenVerifier
|
||||
{
|
||||
TimeTokenFormat Format { get; }
|
||||
TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor);
|
||||
}
|
||||
33
src/AirGap/StellaOps.AirGap.Time/Services/Rfc3161Verifier.cs
Normal file
33
src/AirGap/StellaOps.AirGap.Time/Services/Rfc3161Verifier.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
{
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Rfc3161;
|
||||
|
||||
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
// Stub: derive anchor time deterministically; real ASN.1 verification to be added once trust roots finalized.
|
||||
var digestBytes = SHA256.HashData(tokenBytes);
|
||||
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
|
||||
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", trustRoots[0].KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("rfc3161-stub-verified");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
{
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
|
||||
|
||||
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
// Stub: derive anchor time deterministically from digest until real Roughtime decoding is wired.
|
||||
var digestBytes = SHA256.HashData(tokenBytes);
|
||||
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
|
||||
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
|
||||
|
||||
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", trustRoots[0].KeyId, digest);
|
||||
return TimeAnchorValidationResult.Success("roughtime-stub-verified");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes staleness for a given time anchor against configured budgets.
|
||||
/// </summary>
|
||||
public sealed class StalenessCalculator
|
||||
{
|
||||
public StalenessEvaluation Evaluate(TimeAnchor anchor, StalenessBudget budget, DateTimeOffset nowUtc)
|
||||
{
|
||||
budget.Validate();
|
||||
|
||||
if (anchor.AnchorTime == DateTimeOffset.MinValue)
|
||||
{
|
||||
return StalenessEvaluation.Unknown;
|
||||
}
|
||||
|
||||
var ageSeconds = Math.Max(0, (long)(nowUtc - anchor.AnchorTime).TotalSeconds);
|
||||
var isBreach = ageSeconds >= budget.BreachSeconds;
|
||||
var isWarning = ageSeconds >= budget.WarningSeconds;
|
||||
|
||||
return new StalenessEvaluation(ageSeconds, budget.WarningSeconds, budget.BreachSeconds, isWarning, isBreach);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads time anchors from hex-encoded fixtures or bundle payloads and validates basic structure.
|
||||
/// Cryptographic verification is still stubbed; this keeps ingestion deterministic for offline testing.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorLoader
|
||||
{
|
||||
private readonly TimeVerificationService _verification;
|
||||
|
||||
public TimeAnchorLoader()
|
||||
{
|
||||
_verification = new TimeVerificationService();
|
||||
}
|
||||
|
||||
public TimeAnchorValidationResult TryLoadHex(string hex, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
if (string.IsNullOrWhiteSpace(hex))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("token-empty");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromHexString(hex.Trim());
|
||||
return _verification.Verify(bytes, format, trustRoots, out anchor);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("token-hex-invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides current time-anchor status (anchor + staleness) per tenant.
|
||||
/// </summary>
|
||||
public sealed class TimeStatusService
|
||||
{
|
||||
private readonly ITimeAnchorStore _store;
|
||||
private readonly StalenessCalculator _calculator;
|
||||
|
||||
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator)
|
||||
{
|
||||
_store = store;
|
||||
_calculator = calculator;
|
||||
}
|
||||
|
||||
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
|
||||
{
|
||||
budget.Validate();
|
||||
await _store.SetAsync(tenantId, anchor, budget, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TimeStatus> GetStatusAsync(string tenantId, DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
|
||||
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
|
||||
return new TimeStatus(anchor, eval, budget, nowUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class TimeVerificationService
|
||||
{
|
||||
private readonly IReadOnlyDictionary<TimeTokenFormat, ITimeTokenVerifier> _verifiers;
|
||||
|
||||
public TimeVerificationService()
|
||||
{
|
||||
var verifiers = new ITimeTokenVerifier[] { new RoughtimeVerifier(), new Rfc3161Verifier() };
|
||||
_verifiers = verifiers.ToDictionary(v => v.Format, v => v);
|
||||
}
|
||||
|
||||
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
if (!_verifiers.TryGetValue(format, out var verifier))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("unknown-format");
|
||||
}
|
||||
|
||||
return verifier.Verify(tokenBytes, trustRoots, out anchor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Time</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Stores;
|
||||
|
||||
public interface ITimeAnchorStore
|
||||
{
|
||||
Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken);
|
||||
Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Stores;
|
||||
|
||||
public sealed class InMemoryTimeAnchorStore : ITimeAnchorStore
|
||||
{
|
||||
private readonly Dictionary<string, (TimeAnchor Anchor, StalenessBudget Budget)> _anchors = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_anchors[tenantId] = (anchor, budget);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_anchors.TryGetValue(tenantId, out var value))
|
||||
{
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
return Task.FromResult((TimeAnchor.Unknown, StalenessBudget.Default));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
308201223081c9a0030201020404c78a5540300d06092a864886f70d01010b0500300d310b3009060355040313025441301e170d3233313132303130303030305a170d3234313132393130303030305a300d310b300906035504031302544130820122300d06092a864886f70d01010105000382010f003082010a0282010100c3e8c4a1b2f7f6...
|
||||
@@ -0,0 +1 @@
|
||||
0102030473616d706c652d726f75676874696d652d746f6b656e00
|
||||
17
src/AirGap/TASKS.md
Normal file
17
src/AirGap/TASKS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# AirGap Module Tasks (prep sync)
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| PREP-AIRGAP-IMP-56-001-IMPORTER-PROJECT-SCAFF | DONE | Scaffolded importer project/tests; doc at `docs/airgap/importer-scaffold.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-IMP-56-002-BLOCKED-ON-56-001 | DONE | Unblocked by importer scaffold/trust-root contract. | 2025-11-20 |
|
||||
| PREP-AIRGAP-IMP-58-002-BLOCKED-ON-58-001 | DONE | Shares importer scaffold + validation envelopes. | 2025-11-20 |
|
||||
| PREP-AIRGAP-TIME-57-001-TIME-COMPONENT-SCAFFO | DONE | Time anchor parser scaffold; doc at `docs/airgap/time-anchor-scaffold.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-56-001-CONTROLLER-PROJECT-SCA | DOING | Controller scaffold draft at `docs/airgap/controller-scaffold.md`; awaiting Authority scopes decision. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-56-002-BLOCKED-ON-56-001-SCAF | DOING | Uses same scaffold doc; pending DevOps alignment on deployment skeleton. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-57-001-BLOCKED-ON-56-002 | DONE | Diagnostics doc at `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-57-002-BLOCKED-ON-57-001 | DONE | Telemetry/timeline hooks defined in `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-58-001-BLOCKED-ON-57-002 | DOING | Staleness/time-anchor fields specified; awaiting Time Guild token decision. | 2025-11-20 |
|
||||
| AIRGAP-IMP-56-001 | DONE | DSSE verifier, TUF validator, Merkle root calculator + import coordinator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |
|
||||
| AIRGAP-TIME-57-001 | DOING | Staleness calculator/budgets, hex loader, fixtures, TimeStatusService/store, stub verification pipeline added; crypto verification pending guild inputs. | 2025-11-20 |
|
||||
Reference in New Issue
Block a user