feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s

Closes #7
This commit is contained in:
KyuubiYoru
2026-07-16 05:58:47 +02:00
parent 02ca502a76
commit 49564c7e7e
25 changed files with 2069 additions and 46 deletions
@@ -0,0 +1,158 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Sessions;
internal interface ISessionCapabilityService
{
string CreateDerivationSalt();
string DeriveCapability(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
Guid DeriveGuid(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
}
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
{
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
public string CreateDerivationSalt()
{
ObjectDisposedException.ThrowIf(_disposed, this);
byte[] salt = RandomNumberGenerator.GetBytes(32);
try
{
return Encode(salt);
}
finally
{
CryptographicOperations.ZeroMemory(salt);
}
}
public string DeriveCapability(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt)
{
byte[] digest = Derive(
purpose,
ownerSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public Guid DeriveGuid(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt)
{
byte[] digest = Derive(
purpose,
ownerSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
try
{
Span<byte> guidBytes = digest.AsSpan(0, 16);
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
return new Guid(guidBytes);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
{
fingerprint = default;
if (_disposed
|| capability is null
|| capability.Length != 43
|| capability.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
return false;
}
byte[] digest = Derive("fingerprint", capability);
try
{
fingerprint = new SecretFingerprint(Encode(digest));
return true;
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
private byte[] Derive(params string[] segments)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
Span<byte> length = stackalloc byte[sizeof(int)];
foreach (string segment in segments)
{
ArgumentException.ThrowIfNullOrEmpty(segment);
byte[] encoded = Encoding.UTF8.GetBytes(segment);
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
hmac.AppendData(length);
hmac.AppendData(encoded);
CryptographicOperations.ZeroMemory(encoded);
}
return hmac.GetHashAndReset();
}
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}