Files
git.stella-ops.org/src/VexLens/StellaOps.VexLens/Verification/InMemoryIssuerDirectory.cs
StellaOps Bot 75611a505f save progress
2026-01-04 19:08:47 +02:00

317 lines
10 KiB
C#

using System.Collections.Concurrent;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Verification;
/// <summary>
/// In-memory implementation of <see cref="IIssuerDirectory"/>.
/// Suitable for testing and single-instance deployments.
/// </summary>
public sealed class InMemoryIssuerDirectory : IIssuerDirectory
{
private readonly ConcurrentDictionary<string, IssuerRecord> _issuers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _fingerprintToIssuer = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryIssuerDirectory(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<IssuerRecord?> GetIssuerAsync(
string issuerId,
CancellationToken cancellationToken = default)
{
_issuers.TryGetValue(issuerId, out var issuer);
return Task.FromResult(issuer);
}
public Task<IssuerRecord?> GetIssuerByKeyFingerprintAsync(
string fingerprint,
CancellationToken cancellationToken = default)
{
if (_fingerprintToIssuer.TryGetValue(fingerprint, out var issuerId))
{
_issuers.TryGetValue(issuerId, out var issuer);
return Task.FromResult(issuer);
}
return Task.FromResult<IssuerRecord?>(null);
}
public Task<IReadOnlyList<IssuerRecord>> ListIssuersAsync(
IssuerListOptions? options = null,
CancellationToken cancellationToken = default)
{
var query = _issuers.Values.AsEnumerable();
if (options != null)
{
if (options.Category.HasValue)
{
query = query.Where(i => i.Category == options.Category.Value);
}
if (options.MinimumTrustTier.HasValue)
{
query = query.Where(i => i.TrustTier >= options.MinimumTrustTier.Value);
}
if (options.Status.HasValue)
{
query = query.Where(i => i.Status == options.Status.Value);
}
if (!string.IsNullOrWhiteSpace(options.SearchTerm))
{
var term = options.SearchTerm;
query = query.Where(i =>
i.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
i.IssuerId.Contains(term, StringComparison.OrdinalIgnoreCase));
}
if (options.Offset.HasValue)
{
query = query.Skip(options.Offset.Value);
}
if (options.Limit.HasValue)
{
query = query.Take(options.Limit.Value);
}
}
var result = query
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<IssuerRecord>>(result);
}
public Task<IssuerRecord> RegisterIssuerAsync(
IssuerRegistration registration,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var keyRecords = new List<KeyFingerprintRecord>();
if (registration.InitialKeys != null)
{
foreach (var key in registration.InitialKeys)
{
keyRecords.Add(new KeyFingerprintRecord(
Fingerprint: key.Fingerprint,
KeyType: key.KeyType,
Algorithm: key.Algorithm,
Status: KeyFingerprintStatus.Active,
RegisteredAt: now,
ExpiresAt: key.ExpiresAt,
RevokedAt: null,
RevocationReason: null));
_fingerprintToIssuer[key.Fingerprint] = registration.IssuerId;
}
}
var record = new IssuerRecord(
IssuerId: registration.IssuerId,
Name: registration.Name,
Category: registration.Category,
TrustTier: registration.TrustTier,
Status: IssuerStatus.Active,
KeyFingerprints: keyRecords,
Metadata: registration.Metadata,
RegisteredAt: now,
LastUpdatedAt: null,
RevokedAt: null,
RevocationReason: null);
_issuers[registration.IssuerId] = record;
return Task.FromResult(record);
}
public Task<bool> RevokeIssuerAsync(
string issuerId,
string reason,
CancellationToken cancellationToken = default)
{
if (!_issuers.TryGetValue(issuerId, out var current))
{
return Task.FromResult(false);
}
var now = _timeProvider.GetUtcNow();
var updated = current with
{
Status = IssuerStatus.Revoked,
RevokedAt = now,
RevocationReason = reason,
LastUpdatedAt = now
};
_issuers[issuerId] = updated;
// Also revoke all keys
foreach (var key in current.KeyFingerprints)
{
_fingerprintToIssuer.TryRemove(key.Fingerprint, out _);
}
return Task.FromResult(true);
}
public Task<IssuerRecord> AddKeyFingerprintAsync(
string issuerId,
KeyFingerprintRegistration keyRegistration,
CancellationToken cancellationToken = default)
{
if (!_issuers.TryGetValue(issuerId, out var current))
{
throw new InvalidOperationException($"Issuer '{issuerId}' not found");
}
var now = _timeProvider.GetUtcNow();
var newKey = new KeyFingerprintRecord(
Fingerprint: keyRegistration.Fingerprint,
KeyType: keyRegistration.KeyType,
Algorithm: keyRegistration.Algorithm,
Status: KeyFingerprintStatus.Active,
RegisteredAt: now,
ExpiresAt: keyRegistration.ExpiresAt,
RevokedAt: null,
RevocationReason: null);
var updatedKeys = current.KeyFingerprints.Append(newKey).ToList();
var updated = current with
{
KeyFingerprints = updatedKeys,
LastUpdatedAt = now
};
_issuers[issuerId] = updated;
_fingerprintToIssuer[keyRegistration.Fingerprint] = issuerId;
return Task.FromResult(updated);
}
public Task<bool> RevokeKeyFingerprintAsync(
string issuerId,
string fingerprint,
string reason,
CancellationToken cancellationToken = default)
{
if (!_issuers.TryGetValue(issuerId, out var current))
{
return Task.FromResult(false);
}
var keyIndex = current.KeyFingerprints
.Select((k, i) => (k, i))
.FirstOrDefault(x => x.k.Fingerprint == fingerprint);
if (keyIndex.k == null)
{
return Task.FromResult(false);
}
var now = _timeProvider.GetUtcNow();
var revokedKey = keyIndex.k with
{
Status = KeyFingerprintStatus.Revoked,
RevokedAt = now,
RevocationReason = reason
};
var updatedKeys = current.KeyFingerprints.ToList();
updatedKeys[keyIndex.i] = revokedKey;
var updated = current with
{
KeyFingerprints = updatedKeys,
LastUpdatedAt = now
};
_issuers[issuerId] = updated;
_fingerprintToIssuer.TryRemove(fingerprint, out _);
return Task.FromResult(true);
}
public Task<IssuerTrustValidation> ValidateTrustAsync(
string issuerId,
string? keyFingerprint,
CancellationToken cancellationToken = default)
{
var warnings = new List<string>();
if (!_issuers.TryGetValue(issuerId, out var issuer))
{
return Task.FromResult(new IssuerTrustValidation(
IsTrusted: false,
EffectiveTrustTier: TrustTier.Unknown,
IssuerStatus: IssuerTrustStatus.NotRegistered,
KeyStatus: null,
Warnings: ["Issuer is not registered in the directory"]));
}
var issuerStatus = issuer.Status switch
{
IssuerStatus.Active => IssuerTrustStatus.Trusted,
IssuerStatus.Suspended => IssuerTrustStatus.Suspended,
IssuerStatus.Revoked => IssuerTrustStatus.Revoked,
_ => IssuerTrustStatus.NotRegistered
};
if (issuerStatus != IssuerTrustStatus.Trusted)
{
return Task.FromResult(new IssuerTrustValidation(
IsTrusted: false,
EffectiveTrustTier: TrustTier.Untrusted,
IssuerStatus: issuerStatus,
KeyStatus: null,
Warnings: [$"Issuer status is {issuer.Status}"]));
}
KeyTrustStatus? keyStatus = null;
if (!string.IsNullOrWhiteSpace(keyFingerprint))
{
var key = issuer.KeyFingerprints
.FirstOrDefault(k => k.Fingerprint.Equals(keyFingerprint, StringComparison.OrdinalIgnoreCase));
if (key == null)
{
keyStatus = KeyTrustStatus.NotRegistered;
warnings.Add("Key fingerprint is not registered for this issuer");
}
else if (key.Status == KeyFingerprintStatus.Revoked)
{
keyStatus = KeyTrustStatus.Revoked;
warnings.Add($"Key was revoked: {key.RevocationReason}");
}
else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
keyStatus = KeyTrustStatus.Expired;
warnings.Add($"Key expired on {key.ExpiresAt.Value:O}");
}
else
{
keyStatus = KeyTrustStatus.Valid;
}
}
var isTrusted = issuerStatus == IssuerTrustStatus.Trusted &&
(keyStatus == null || keyStatus == KeyTrustStatus.Valid);
var effectiveTier = isTrusted ? issuer.TrustTier : TrustTier.Untrusted;
return Task.FromResult(new IssuerTrustValidation(
IsTrusted: isTrusted,
EffectiveTrustTier: effectiveTier,
IssuerStatus: issuerStatus,
KeyStatus: keyStatus,
Warnings: warnings));
}
}