using System; using System.Collections.Concurrent; using System.Text; namespace Esiur.Security.Authority.Providers.Ppap; /// /// The kind of long-lived identity authenticated by PPAP ML-KEM. /// public enum PpapIdentityKind : byte { PasswordDerived = 1, StaticMlKem = 2, } /// /// Immutable Argon2id parameters used to turn a password into an ML-KEM seed. /// public sealed class PpapKdfProfile : IEquatable { public const int Argon2Version13 = 0x13; public static PpapKdfProfile Default { get; } = new PpapKdfProfile(Argon2Version13, 32 * 1024, 3, 1); public int Version { get; } public int MemoryKiB { get; } public int Iterations { get; } public int Parallelism { get; } public PpapKdfProfile(int version, int memoryKiB, int iterations, int parallelism) { if (version != Argon2Version13) throw new ArgumentOutOfRangeException(nameof(version), "Only Argon2 version 1.3 is supported."); if (memoryKiB < 8 * 1024 || memoryKiB > 256 * 1024) throw new ArgumentOutOfRangeException(nameof(memoryKiB)); if (iterations < 1 || iterations > 10) throw new ArgumentOutOfRangeException(nameof(iterations)); if (parallelism < 1 || parallelism > 16) throw new ArgumentOutOfRangeException(nameof(parallelism)); if (memoryKiB < parallelism * 8) throw new ArgumentOutOfRangeException(nameof(memoryKiB)); Version = version; MemoryKiB = memoryKiB; Iterations = iterations; Parallelism = parallelism; } public bool Equals(PpapKdfProfile other) => other != null && Version == other.Version && MemoryKiB == other.MemoryKiB && Iterations == other.Iterations && Parallelism == other.Parallelism; public override bool Equals(object obj) => Equals(obj as PpapKdfProfile); public override int GetHashCode() { unchecked { var hash = Version; hash = (hash * 397) ^ MemoryKiB; hash = (hash * 397) ^ Iterations; return (hash * 397) ^ Parallelism; } } } /// /// Local PPAP identity. Secret material is cloned and is never exposed by a property. /// public sealed class PpapLocalIdentity : IDisposable { readonly byte[] _secret; bool _disposed; public string Identity { get; } public PpapIdentityKind Kind { get; } public PpapKdfProfile KdfProfile { get; } PpapLocalIdentity(string identity, PpapIdentityKind kind, byte[] secret, PpapKdfProfile kdfProfile) { Identity = PpapCryptography.NormalizeIdentity(identity); Kind = kind; KdfProfile = kdfProfile; _secret = (byte[])secret.Clone(); } public static PpapLocalIdentity FromPassword(string identity, string password, PpapKdfProfile kdfProfile = null) { if (password == null) throw new ArgumentNullException(nameof(password)); var bytes = Encoding.UTF8.GetBytes(password.Normalize(NormalizationForm.FormC)); try { return FromPassword(identity, bytes, kdfProfile); } finally { PpapCryptography.Clear(bytes); } } public static PpapLocalIdentity FromPassword(string identity, byte[] password, PpapKdfProfile kdfProfile = null) { if (password == null || password.Length == 0) throw new ArgumentException("A password is required.", nameof(password)); if (password.Length > PpapProtocol.MaximumPasswordBytes) throw new ArgumentException("The password is too long.", nameof(password)); return new PpapLocalIdentity(identity, PpapIdentityKind.PasswordDerived, password, kdfProfile ?? PpapKdfProfile.Default); } public static PpapLocalIdentity FromStaticKey(string identity, byte[] privateKey) { PpapCryptography.ValidatePrivateKey(privateKey); return new PpapLocalIdentity(identity, PpapIdentityKind.StaticMlKem, privateKey, null); } /// /// Creates a new static ML-KEM-768 identity with a cryptographically random key. /// public static PpapLocalIdentity CreateStatic(string identity) { PpapCryptography.GenerateKeyPair(out var privateKey, out var publicKey); try { return FromStaticKey(identity, privateKey); } finally { PpapCryptography.Clear(privateKey); PpapCryptography.Clear(publicKey); } } /// /// Exports a copy of a static ML-KEM private key for durable secure storage. /// The caller owns the returned buffer and should erase it after persisting it. /// Password-derived identities cannot be exported. /// public byte[] ExportStaticPrivateKey() { ThrowIfDisposed(); if (Kind != PpapIdentityKind.StaticMlKem) throw new InvalidOperationException( "Password-derived PPAP identities cannot export private key material."); return (byte[])_secret.Clone(); } internal byte[] DerivePrivateKey(string domain, byte[] nonce, PpapKdfProfile profile) { ThrowIfDisposed(); if (Kind == PpapIdentityKind.StaticMlKem) { if (nonce != null && nonce.Length != 0) throw new InvalidOperationException("A static identity cannot have a KDF nonce."); return (byte[])_secret.Clone(); } if (profile == null || !KdfProfile.Equals(profile)) throw new InvalidOperationException("The registration KDF profile is not accepted by the local identity."); return PpapCryptography.DerivePasswordPrivateKey(domain, Identity, _secret, nonce, profile); } internal byte[] GetStaticPublicKey() { ThrowIfDisposed(); if (Kind != PpapIdentityKind.StaticMlKem) throw new InvalidOperationException("The identity is not backed by a static key."); return PpapCryptography.GetPublicKey(_secret); } void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(PpapLocalIdentity)); } public void Dispose() { if (_disposed) return; _disposed = true; PpapCryptography.Clear(_secret); } } /// /// Immutable verifier-side registration data for one PPAP identity. /// public sealed class PpapRegistrationRecord { readonly byte[] _nonce; readonly byte[] _encapsulationKey; public long Version { get; } public string Identity { get; } public PpapIdentityKind Kind { get; } public PpapKdfProfile KdfProfile { get; } public byte[] Nonce => (byte[])_nonce.Clone(); public byte[] EncapsulationKey => (byte[])_encapsulationKey.Clone(); internal byte[] NonceBytes => (byte[])_nonce.Clone(); internal byte[] EncapsulationKeyBytes => (byte[])_encapsulationKey.Clone(); public PpapRegistrationRecord(long version, string identity, PpapIdentityKind kind, byte[] nonce, byte[] encapsulationKey, PpapKdfProfile kdfProfile = null) { if (version < 1) throw new ArgumentOutOfRangeException(nameof(version)); if (!Enum.IsDefined(typeof(PpapIdentityKind), kind)) throw new ArgumentOutOfRangeException(nameof(kind)); Identity = PpapCryptography.NormalizeIdentity(identity); Version = version; Kind = kind; PpapCryptography.ValidatePublicKey(encapsulationKey); if (kind == PpapIdentityKind.PasswordDerived) { if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength) throw new ArgumentException("A password registration requires a 32-byte nonce.", nameof(nonce)); KdfProfile = kdfProfile ?? throw new ArgumentNullException(nameof(kdfProfile)); _nonce = (byte[])nonce.Clone(); } else { if (nonce != null && nonce.Length != 0) throw new ArgumentException("A static-key registration cannot contain a KDF nonce.", nameof(nonce)); if (kdfProfile != null) throw new ArgumentException("A static-key registration cannot contain a KDF profile.", nameof(kdfProfile)); _nonce = Array.Empty(); } _encapsulationKey = (byte[])encapsulationKey.Clone(); } public static PpapRegistrationRecord FromPassword(string domain, string identity, string password, long version = 1, byte[] nonce = null, PpapKdfProfile kdfProfile = null) { using (var local = PpapLocalIdentity.FromPassword(identity, password, kdfProfile)) return FromLocalIdentity(domain, local, version, nonce); } public static PpapRegistrationRecord FromLocalIdentity(string domain, PpapLocalIdentity identity, long version = 1, byte[] nonce = null) { if (identity == null) throw new ArgumentNullException(nameof(identity)); if (identity.Kind == PpapIdentityKind.StaticMlKem) return new PpapRegistrationRecord(version, identity.Identity, identity.Kind, Array.Empty(), identity.GetStaticPublicKey()); var registrationNonce = nonce == null ? PpapCryptography.RandomBytes(PpapProtocol.RegistrationNonceLength) : (byte[])nonce.Clone(); byte[] privateKey = null; byte[] publicKey = null; try { privateKey = identity.DerivePrivateKey(domain, registrationNonce, identity.KdfProfile); publicKey = PpapCryptography.GetPublicKey(privateKey); return new PpapRegistrationRecord(version, identity.Identity, identity.Kind, registrationNonce, publicKey, identity.KdfProfile); } finally { PpapCryptography.Clear(privateKey); PpapCryptography.Clear(publicKey); PpapCryptography.Clear(registrationNonce); } } } public interface IPpapRegistrationStore { PpapRegistrationRecord Get(string domain, string identity); /// /// Resolves a per-handshake, keyed identity mask. Implementations must not /// persist or use it as a stable lookup token. /// PpapRegistrationRecord ResolveMasked(string domain, byte[] mask, byte[] maskKey, byte[] maskedIdentity); /// /// Atomically replaces exactly the registration identified by /// , , and /// . Implementations must perform a /// linearizable compare-and-swap, preserve identity, kind, and KDF policy, /// reject reused nonce or encapsulation-key material, and return false /// without mutation when any precondition fails. A database implementation /// must use a conditional update/transaction, not a separate read then write. /// bool TryRotate(string domain, string identity, long expectedVersion, PpapRegistrationRecord replacement); } /// /// Thread-safe in-memory registration store intended for shortcuts, tests, and small deployments. /// Masked resolution deliberately scans all records in a domain. /// public sealed class InMemoryPpapRegistrationStore : IPpapRegistrationStore { struct RegistrationKey : IEquatable { public readonly string Domain; public readonly string Identity; public RegistrationKey(string domain, string identity) { Domain = domain; Identity = identity; } public bool Equals(RegistrationKey other) => string.Equals(Domain, other.Domain, StringComparison.Ordinal) && string.Equals(Identity, other.Identity, StringComparison.Ordinal); public override bool Equals(object obj) => obj is RegistrationKey && Equals((RegistrationKey)obj); public override int GetHashCode() { unchecked { return ((Domain?.GetHashCode() ?? 0) * 397) ^ (Identity?.GetHashCode() ?? 0); } } } readonly ConcurrentDictionary _records = new(); static RegistrationKey MakeKey(string domain, string identity) => new RegistrationKey(PpapCryptography.NormalizeDomain(domain), PpapCryptography.NormalizeIdentity(identity)); public bool TryAdd(string domain, PpapRegistrationRecord record) { if (record == null) throw new ArgumentNullException(nameof(record)); return _records.TryAdd(MakeKey(domain, record.Identity), record); } public void AddOrUpdate(string domain, PpapRegistrationRecord record) { if (record == null) throw new ArgumentNullException(nameof(record)); var key = MakeKey(domain, record.Identity); _records.AddOrUpdate(key, record, (_, current) => { if (RecordsEqual(current, record)) return current; throw new InvalidOperationException( "An existing PPAP registration can only be changed through TryRotate."); }); } public PpapRegistrationRecord Get(string domain, string identity) { _records.TryGetValue(MakeKey(domain, identity), out var record); return record; } public PpapRegistrationRecord ResolveMasked(string domain, byte[] mask, byte[] maskKey, byte[] maskedIdentity) { if (mask == null || mask.Length != PpapProtocol.IdentityMaskLength) return null; if (maskedIdentity == null || maskedIdentity.Length != PpapProtocol.HashLength) return null; if (maskKey == null || maskKey.Length != PpapProtocol.KemSecretLength) return null; var normalizedDomain = PpapCryptography.NormalizeDomain(domain); PpapRegistrationRecord match = null; var ambiguous = false; foreach (var pair in _records) { if (!string.Equals(pair.Key.Domain, normalizedDomain, StringComparison.Ordinal)) continue; var candidate = PpapCryptography.MaskIdentity( normalizedDomain, pair.Value.Identity, mask, maskKey); var equal = PpapCryptography.FixedTimeEquals(candidate, maskedIdentity); PpapCryptography.Clear(candidate); if (!equal) continue; if (match != null) ambiguous = true; match = pair.Value; } return ambiguous ? null : match; } public bool TryRotate(string domain, string identity, long expectedVersion, PpapRegistrationRecord replacement) { if (replacement == null) throw new ArgumentNullException(nameof(replacement)); var key = MakeKey(domain, identity); if (!string.Equals(key.Identity, replacement.Identity, StringComparison.Ordinal) || replacement.Version != expectedVersion + 1) return false; if (!_records.TryGetValue(key, out var current) || current.Version != expectedVersion || current.Kind != PpapIdentityKind.PasswordDerived || replacement.Kind != PpapIdentityKind.PasswordDerived) return false; if (!current.KdfProfile.Equals(replacement.KdfProfile) || PpapCryptography.FixedTimeEquals(current.NonceBytes, replacement.NonceBytes) || PpapCryptography.FixedTimeEquals(current.EncapsulationKeyBytes, replacement.EncapsulationKeyBytes)) return false; return _records.TryUpdate(key, replacement, current); } static bool RecordsEqual(PpapRegistrationRecord left, PpapRegistrationRecord right) => left.Version == right.Version && left.Kind == right.Kind && string.Equals(left.Identity, right.Identity, StringComparison.Ordinal) && Equals(left.KdfProfile, right.KdfProfile) && PpapCryptography.FixedTimeEquals(left.NonceBytes, right.NonceBytes) && PpapCryptography.FixedTimeEquals(left.EncapsulationKeyBytes, right.EncapsulationKeyBytes); }