317 lines
10 KiB
C#
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));
|
|
}
|
|
}
|