diff --git a/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs b/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs
new file mode 100644
index 0000000..1661ee8
--- /dev/null
+++ b/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs
@@ -0,0 +1,38 @@
+namespace SquidStd.Crypto.Password.Data;
+
+/// Argon2id cost parameters for password-based key derivation.
+public sealed class PbkdfCost
+{
+ /// Argon2id memory cost in KiB.
+ public int MemoryKib { get; }
+
+ /// Argon2id iteration (time) cost.
+ public int Iterations { get; }
+
+ /// Argon2id parallelism (lanes).
+ public int Parallelism { get; }
+
+ public PbkdfCost(int memoryKib, int iterations, int parallelism = 1)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(memoryKib);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(iterations);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(parallelism);
+
+ // The envelope stores parallelism in a single byte, so reject values that would truncate and
+ // silently produce an undecryptable blob. Real Argon2 lane counts are far below this.
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(parallelism, 255);
+
+ MemoryKib = memoryKib;
+ Iterations = iterations;
+ Parallelism = parallelism;
+ }
+
+ /// Fast, for interactive logins (16 MiB, 2 passes).
+ public static PbkdfCost Interactive { get; } = new(16 * 1024, 2);
+
+ /// Balanced default (64 MiB, 3 passes).
+ public static PbkdfCost Moderate { get; } = new(64 * 1024, 3);
+
+ /// High cost for data at rest (256 MiB, 4 passes).
+ public static PbkdfCost Sensitive { get; } = new(256 * 1024, 4);
+}
diff --git a/src/SquidStd.Crypto/Password/Internal/PasswordEnvelope.cs b/src/SquidStd.Crypto/Password/Internal/PasswordEnvelope.cs
new file mode 100644
index 0000000..83de8cd
--- /dev/null
+++ b/src/SquidStd.Crypto/Password/Internal/PasswordEnvelope.cs
@@ -0,0 +1,35 @@
+namespace SquidStd.Crypto.Password.Internal;
+
+/// Parsed fields of a password-encryption envelope.
+internal sealed class PasswordEnvelope
+{
+ public byte Version { get; }
+ public int Iterations { get; }
+ public int MemoryKib { get; }
+ public int Parallelism { get; }
+ public byte[] Salt { get; }
+ public byte[] Nonce { get; }
+ public byte[] Tag { get; }
+ public byte[] Ciphertext { get; }
+
+ public PasswordEnvelope(
+ byte version,
+ int iterations,
+ int memoryKib,
+ int parallelism,
+ byte[] salt,
+ byte[] nonce,
+ byte[] tag,
+ byte[] ciphertext
+ )
+ {
+ Version = version;
+ Iterations = iterations;
+ MemoryKib = memoryKib;
+ Parallelism = parallelism;
+ Salt = salt;
+ Nonce = nonce;
+ Tag = tag;
+ Ciphertext = ciphertext;
+ }
+}
diff --git a/src/SquidStd.Crypto/Password/Internal/PasswordEnvelopeCodec.cs b/src/SquidStd.Crypto/Password/Internal/PasswordEnvelopeCodec.cs
new file mode 100644
index 0000000..1c38467
--- /dev/null
+++ b/src/SquidStd.Crypto/Password/Internal/PasswordEnvelopeCodec.cs
@@ -0,0 +1,95 @@
+using System.Buffers.Binary;
+
+namespace SquidStd.Crypto.Password.Internal;
+
+/// Encodes/decodes the self-describing SPBE password-encryption envelope.
+internal static class PasswordEnvelopeCodec
+{
+ public const byte Version = 1;
+ public const int SaltSize = 16;
+ public const int NonceSize = 12;
+ public const int TagSize = 16;
+
+ // magic(4) | version(1) | iterations(4) | memoryKib(4) | parallelism(1) | salt(16) | nonce(12)
+ public const int AadSize = 4 + 1 + 4 + 4 + 1 + SaltSize + NonceSize; // 42 — authenticated, excludes the tag
+ public const int HeaderSize = AadSize + TagSize; // 58 — everything before the ciphertext
+
+ private static readonly byte[] _magic = "SPBE"u8.ToArray();
+
+ public static byte[] BuildAad(
+ byte version, int iterations, int memoryKib, int parallelism, ReadOnlySpan salt, ReadOnlySpan nonce
+ )
+ {
+ var aad = new byte[AadSize];
+ var span = aad.AsSpan();
+
+ _magic.CopyTo(span);
+ span[4] = version;
+ BinaryPrimitives.WriteInt32BigEndian(span.Slice(5, 4), iterations);
+ BinaryPrimitives.WriteInt32BigEndian(span.Slice(9, 4), memoryKib);
+ span[13] = (byte)parallelism;
+ salt.CopyTo(span.Slice(14, SaltSize));
+ nonce.CopyTo(span.Slice(30, NonceSize));
+
+ return aad;
+ }
+
+ public static byte[] Encode(PasswordEnvelope envelope)
+ {
+ var aad = BuildAad(
+ envelope.Version, envelope.Iterations, envelope.MemoryKib, envelope.Parallelism, envelope.Salt, envelope.Nonce
+ );
+
+ var result = new byte[HeaderSize + envelope.Ciphertext.Length];
+ aad.CopyTo(result.AsSpan());
+ envelope.Tag.CopyTo(result.AsSpan(AadSize, TagSize));
+ envelope.Ciphertext.CopyTo(result.AsSpan(HeaderSize));
+
+ return result;
+ }
+
+ public static PasswordEnvelope Decode(byte[] blob)
+ {
+ ArgumentNullException.ThrowIfNull(blob);
+
+ if (blob.Length < HeaderSize)
+ {
+ throw new InvalidDataException("Encrypted payload is too short.");
+ }
+
+ var span = blob.AsSpan();
+
+ if (!span[..4].SequenceEqual(_magic))
+ {
+ throw new InvalidDataException("Encrypted payload has an invalid magic header.");
+ }
+
+ var version = span[4];
+
+ if (version != Version)
+ {
+ throw new InvalidDataException($"Unsupported encrypted payload version {version}.");
+ }
+
+ var iterations = BinaryPrimitives.ReadInt32BigEndian(span.Slice(5, 4));
+ var memoryKib = BinaryPrimitives.ReadInt32BigEndian(span.Slice(9, 4));
+ var parallelism = span[13];
+
+ if (iterations <= 0 || memoryKib <= 0 || parallelism <= 0)
+ {
+ throw new InvalidDataException("Encrypted payload has invalid KDF parameters.");
+ }
+
+ var salt = span.Slice(14, SaltSize).ToArray();
+ var nonce = span.Slice(30, NonceSize).ToArray();
+ var tag = span.Slice(AadSize, TagSize).ToArray();
+ var ciphertext = span[HeaderSize..].ToArray();
+
+ return new PasswordEnvelope(version, iterations, memoryKib, parallelism, salt, nonce, tag, ciphertext);
+ }
+
+ public static ReadOnlySpan AadOf(byte[] blob)
+ {
+ return blob.AsSpan(0, AadSize);
+ }
+}
diff --git a/src/SquidStd.Crypto/Password/Internal/PasswordKeyDerivation.cs b/src/SquidStd.Crypto/Password/Internal/PasswordKeyDerivation.cs
new file mode 100644
index 0000000..4e3e393
--- /dev/null
+++ b/src/SquidStd.Crypto/Password/Internal/PasswordKeyDerivation.cs
@@ -0,0 +1,28 @@
+using System.Text;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+
+namespace SquidStd.Crypto.Password.Internal;
+
+/// Derives a 32-byte key from a password with Argon2id (v1.3).
+internal static class PasswordKeyDerivation
+{
+ public static byte[] DeriveKey(string password, byte[] salt, int iterations, int memoryKib, int parallelism)
+ {
+ var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
+ .WithVersion(Argon2Parameters.Version13)
+ .WithSalt(salt)
+ .WithIterations(iterations)
+ .WithMemoryAsKB(memoryKib)
+ .WithParallelism(parallelism)
+ .Build();
+
+ var generator = new Argon2BytesGenerator();
+ generator.Init(parameters);
+
+ var key = new byte[32];
+ generator.GenerateBytes(Encoding.UTF8.GetBytes(password), key);
+
+ return key;
+ }
+}
diff --git a/src/SquidStd.Crypto/Password/PasswordCipher.cs b/src/SquidStd.Crypto/Password/PasswordCipher.cs
new file mode 100644
index 0000000..a3b5262
--- /dev/null
+++ b/src/SquidStd.Crypto/Password/PasswordCipher.cs
@@ -0,0 +1,97 @@
+using System.Security.Cryptography;
+using System.Text;
+using SquidStd.Crypto.Password.Data;
+using SquidStd.Crypto.Password.Internal;
+
+namespace SquidStd.Crypto.Password;
+
+///
+/// Password-based authenticated encryption. Argon2id derives a key from the password, AES-256-GCM encrypts
+/// the payload, and the result is a self-describing envelope carrying the salt, nonce, tag and KDF cost — so
+/// decryption needs only the password and the blob.
+///
+public static class PasswordCipher
+{
+ /// Encrypts under .
+ public static byte[] Encrypt(byte[] plaintext, string password, PbkdfCost? cost = null)
+ {
+ ArgumentNullException.ThrowIfNull(plaintext);
+ ArgumentException.ThrowIfNullOrEmpty(password);
+
+ cost ??= PbkdfCost.Moderate;
+
+ var salt = RandomNumberGenerator.GetBytes(PasswordEnvelopeCodec.SaltSize);
+ var nonce = RandomNumberGenerator.GetBytes(PasswordEnvelopeCodec.NonceSize);
+ var key = PasswordKeyDerivation.DeriveKey(password, salt, cost.Iterations, cost.MemoryKib, cost.Parallelism);
+
+ var aad = PasswordEnvelopeCodec.BuildAad(
+ PasswordEnvelopeCodec.Version, cost.Iterations, cost.MemoryKib, cost.Parallelism, salt, nonce
+ );
+ var ciphertext = new byte[plaintext.Length];
+ var tag = new byte[PasswordEnvelopeCodec.TagSize];
+
+ using (var aes = new AesGcm(key, PasswordEnvelopeCodec.TagSize))
+ {
+ aes.Encrypt(nonce, plaintext, ciphertext, tag, aad);
+ }
+
+ var envelope = new PasswordEnvelope(
+ PasswordEnvelopeCodec.Version, cost.Iterations, cost.MemoryKib, cost.Parallelism, salt, nonce, tag, ciphertext
+ );
+
+ return PasswordEnvelopeCodec.Encode(envelope);
+ }
+
+ /// Decrypts an envelope produced by .
+ public static byte[] Decrypt(byte[] envelope, string password)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+ ArgumentException.ThrowIfNullOrEmpty(password);
+
+ var parsed = PasswordEnvelopeCodec.Decode(envelope);
+ var key = PasswordKeyDerivation.DeriveKey(
+ password, parsed.Salt, parsed.Iterations, parsed.MemoryKib, parsed.Parallelism
+ );
+ var aad = PasswordEnvelopeCodec.AadOf(envelope);
+ var plaintext = new byte[parsed.Ciphertext.Length];
+
+ try
+ {
+ using var aes = new AesGcm(key, PasswordEnvelopeCodec.TagSize);
+ aes.Decrypt(parsed.Nonce, parsed.Ciphertext, parsed.Tag, plaintext, aad);
+ }
+ catch (AuthenticationTagMismatchException ex)
+ {
+ throw new PasswordDecryptionException("The password is incorrect or the data has been corrupted.", ex);
+ }
+
+ return plaintext;
+ }
+
+ /// Encrypts a UTF-8 string and returns the envelope as base64.
+ public static string EncryptString(string text, string password, PbkdfCost? cost = null)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ return Convert.ToBase64String(Encrypt(Encoding.UTF8.GetBytes(text), password, cost));
+ }
+
+ /// Decrypts a base64 envelope produced by back to its UTF-8 string.
+ public static string DecryptString(string base64, string password)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(base64);
+
+ byte[] envelope;
+
+ try
+ {
+ envelope = Convert.FromBase64String(base64);
+ }
+ catch (FormatException ex)
+ {
+ throw new InvalidDataException("Input is not valid base64.", ex);
+ }
+
+ return Encoding.UTF8.GetString(Decrypt(envelope, password));
+ }
+}
diff --git a/src/SquidStd.Crypto/Password/PasswordDecryptionException.cs b/src/SquidStd.Crypto/Password/PasswordDecryptionException.cs
new file mode 100644
index 0000000..eedb062
--- /dev/null
+++ b/src/SquidStd.Crypto/Password/PasswordDecryptionException.cs
@@ -0,0 +1,12 @@
+using System.Security.Cryptography;
+
+namespace SquidStd.Crypto.Password;
+
+/// Thrown when a password is incorrect or the encrypted payload was corrupted or tampered with.
+public sealed class PasswordDecryptionException : CryptographicException
+{
+ public PasswordDecryptionException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/SquidStd.Crypto/README.md b/src/SquidStd.Crypto/README.md
index cd5d843..7d0afed 100644
--- a/src/SquidStd.Crypto/README.md
+++ b/src/SquidStd.Crypto/README.md
@@ -50,6 +50,30 @@ await keyring.SaveAsync(container.Resolve());
await keyring.LoadAsync(container.Resolve());
```
+## Password-based encryption
+
+`PasswordCipher` encrypts a payload under a password — Argon2id derives the key, AES-256-GCM seals the data,
+and the result is a self-describing, versioned envelope (salt, nonce, tag and KDF cost are embedded, so
+decryption needs only the password and the blob).
+
+```csharp
+using SquidStd.Crypto.Password;
+using SquidStd.Crypto.Password.Data;
+
+byte[] blob = PasswordCipher.Encrypt(payloadBytes, "correct horse battery staple");
+byte[] back = PasswordCipher.Decrypt(blob, "correct horse battery staple");
+
+// Text + base64 envelope for storing in config/JSON:
+string protectedText = PasswordCipher.EncryptString("a secret", "pw");
+string clear = PasswordCipher.DecryptString(protectedText, "pw");
+
+// Tune the Argon2id cost (defaults to PbkdfCost.Moderate):
+byte[] strong = PasswordCipher.Encrypt(payloadBytes, "pw", PbkdfCost.Sensitive);
+```
+
+A wrong password or tampered data raises `PasswordDecryptionException`. For raw-key or app-key/KMS
+encryption use `CryptoUtils` / `ISecretProtector` instead.
+
## Key types
| Type | Purpose |
@@ -60,6 +84,8 @@ await keyring.LoadAsync(container.Resolve());
| `FilePgpKeyStore` | One armored `.asc` per key (gpg-interoperable). |
| `AesGcmPgpKeyStore` | The whole keyring serialized to a single file, encrypted at rest via `ISecretProtector`. |
| `CryptoFileSystem` | `ILockableFileSystem` that encrypts content and names over any `IVirtualFileSystem`. |
+| `PasswordCipher` | Password-based encryption: Argon2id + AES-256-GCM with a self-describing envelope. |
+| `PbkdfCost` | Argon2id cost presets (Interactive / Moderate / Sensitive) + custom. |
## Key stores
diff --git a/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs b/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs
new file mode 100644
index 0000000..46a3abc
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs
@@ -0,0 +1,89 @@
+using System.Text;
+using SquidStd.Crypto.Password;
+using SquidStd.Crypto.Password.Data;
+using SquidStd.Crypto.Password.Internal;
+
+namespace SquidStd.Tests.Crypto.Password;
+
+public class PasswordCipherTests
+{
+ // Keep tests fast: a low-cost preset is plenty to exercise the round-trip.
+ private static readonly PbkdfCost Fast = new(memoryKib: 8192, iterations: 1, parallelism: 1);
+
+ [Fact]
+ public void Encrypt_Decrypt_RoundTripsBytes()
+ {
+ var data = Encoding.UTF8.GetBytes("top secret payload");
+
+ var blob = PasswordCipher.Encrypt(data, "correct horse", Fast);
+
+ Assert.Equal(data, PasswordCipher.Decrypt(blob, "correct horse"));
+ }
+
+ [Fact]
+ public void EncryptString_DecryptString_RoundTrips()
+ {
+ var blob = PasswordCipher.EncryptString("ciao mondo", "pw", Fast);
+
+ Assert.Equal("ciao mondo", PasswordCipher.DecryptString(blob, "pw"));
+ }
+
+ [Fact]
+ public void Decrypt_WithWrongPassword_Throws()
+ {
+ var blob = PasswordCipher.Encrypt([1, 2, 3], "right", Fast);
+
+ Assert.Throws(() => PasswordCipher.Decrypt(blob, "wrong"));
+ }
+
+ [Fact]
+ public void Decrypt_TamperedCiphertext_Throws()
+ {
+ var blob = PasswordCipher.Encrypt([1, 2, 3, 4], "pw", Fast);
+ blob[^1] ^= 0xFF;
+
+ Assert.Throws(() => PasswordCipher.Decrypt(blob, "pw"));
+ }
+
+ [Fact]
+ public void Decrypt_TamperedHeader_Throws()
+ {
+ var blob = PasswordCipher.Encrypt([1, 2, 3, 4], "pw", Fast);
+ blob[14] ^= 0xFF; // flip a salt byte (part of the AAD)
+
+ Assert.Throws(() => PasswordCipher.Decrypt(blob, "pw"));
+ }
+
+ [Fact]
+ public void Encrypt_ProducesDifferentBlobsEachTime()
+ {
+ var a = PasswordCipher.Encrypt([9, 9, 9], "pw", Fast);
+ var b = PasswordCipher.Encrypt([9, 9, 9], "pw", Fast);
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void DefaultCost_RoundTrips()
+ {
+ var blob = PasswordCipher.Encrypt([7], "pw"); // PbkdfCost.Moderate
+
+ Assert.Equal(new byte[] { 7 }, PasswordCipher.Decrypt(blob, "pw"));
+ }
+
+ [Fact]
+ public void Encrypt_EmptyPlaintext_RoundTrips()
+ {
+ var blob = PasswordCipher.Encrypt([], "pw", Fast);
+
+ Assert.Equal([], PasswordCipher.Decrypt(blob, "pw"));
+ }
+
+ [Fact]
+ public void EncryptString_NonAsciiPassword_RoundTrips()
+ {
+ var blob = PasswordCipher.EncryptString("payload", "pâsswörd-日本語-🔐", Fast);
+
+ Assert.Equal("payload", PasswordCipher.DecryptString(blob, "pâsswörd-日本語-🔐"));
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Password/PasswordEnvelopeCodecTests.cs b/tests/SquidStd.Tests/Crypto/Password/PasswordEnvelopeCodecTests.cs
new file mode 100644
index 0000000..8c6e9b2
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Password/PasswordEnvelopeCodecTests.cs
@@ -0,0 +1,68 @@
+using SquidStd.Crypto.Password.Internal;
+
+namespace SquidStd.Tests.Crypto.Password;
+
+public class PasswordEnvelopeCodecTests
+{
+ private static PasswordEnvelope Sample()
+ {
+ return new PasswordEnvelope(
+ PasswordEnvelopeCodec.Version,
+ iterations: 3,
+ memoryKib: 65536,
+ parallelism: 1,
+ salt: Enumerable.Range(0, PasswordEnvelopeCodec.SaltSize).Select(i => (byte)i).ToArray(),
+ nonce: Enumerable.Range(0, PasswordEnvelopeCodec.NonceSize).Select(i => (byte)(i + 100)).ToArray(),
+ tag: Enumerable.Range(0, PasswordEnvelopeCodec.TagSize).Select(i => (byte)(i + 200)).ToArray(),
+ ciphertext: [1, 2, 3, 4, 5]);
+ }
+
+ [Fact]
+ public void Encode_Decode_RoundTripsAllFields()
+ {
+ var original = Sample();
+
+ var decoded = PasswordEnvelopeCodec.Decode(PasswordEnvelopeCodec.Encode(original));
+
+ Assert.Equal(original.Version, decoded.Version);
+ Assert.Equal(original.Iterations, decoded.Iterations);
+ Assert.Equal(original.MemoryKib, decoded.MemoryKib);
+ Assert.Equal(original.Parallelism, decoded.Parallelism);
+ Assert.Equal(original.Salt, decoded.Salt);
+ Assert.Equal(original.Nonce, decoded.Nonce);
+ Assert.Equal(original.Tag, decoded.Tag);
+ Assert.Equal(original.Ciphertext, decoded.Ciphertext);
+ }
+
+ [Fact]
+ public void AadOf_MatchesEverythingBeforeTheTag()
+ {
+ var blob = PasswordEnvelopeCodec.Encode(Sample());
+ var aad = PasswordEnvelopeCodec.AadOf(blob);
+
+ Assert.Equal(PasswordEnvelopeCodec.AadSize, aad.Length);
+ Assert.True(aad.SequenceEqual(blob.AsSpan(0, PasswordEnvelopeCodec.AadSize)));
+ }
+
+ [Fact]
+ public void Decode_TooShort_Throws()
+ => Assert.Throws(() => PasswordEnvelopeCodec.Decode(new byte[10]));
+
+ [Fact]
+ public void Decode_BadMagic_Throws()
+ {
+ var blob = PasswordEnvelopeCodec.Encode(Sample());
+ blob[0] ^= 0xFF;
+
+ Assert.Throws(() => PasswordEnvelopeCodec.Decode(blob));
+ }
+
+ [Fact]
+ public void Decode_UnknownVersion_Throws()
+ {
+ var blob = PasswordEnvelopeCodec.Encode(Sample());
+ blob[4] = 99;
+
+ Assert.Throws(() => PasswordEnvelopeCodec.Decode(blob));
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Password/PasswordKeyDerivationTests.cs b/tests/SquidStd.Tests/Crypto/Password/PasswordKeyDerivationTests.cs
new file mode 100644
index 0000000..fe4b36d
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Password/PasswordKeyDerivationTests.cs
@@ -0,0 +1,29 @@
+using SquidStd.Crypto.Password.Internal;
+
+namespace SquidStd.Tests.Crypto.Password;
+
+public class PasswordKeyDerivationTests
+{
+ private static readonly byte[] Salt = Enumerable.Range(0, 16).Select(i => (byte)i).ToArray();
+
+ [Fact]
+ public void DeriveKey_IsDeterministic_AndProduces32Bytes()
+ {
+ var a = PasswordKeyDerivation.DeriveKey("pw", Salt, iterations: 2, memoryKib: 8192, parallelism: 1);
+ var b = PasswordKeyDerivation.DeriveKey("pw", Salt, iterations: 2, memoryKib: 8192, parallelism: 1);
+
+ Assert.Equal(32, a.Length);
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void DeriveKey_DiffersByPasswordAndSalt()
+ {
+ var baseline = PasswordKeyDerivation.DeriveKey("pw", Salt, 2, 8192, 1);
+ var otherPw = PasswordKeyDerivation.DeriveKey("pw2", Salt, 2, 8192, 1);
+ var otherSalt = PasswordKeyDerivation.DeriveKey("pw", new byte[16], 2, 8192, 1);
+
+ Assert.NotEqual(baseline, otherPw);
+ Assert.NotEqual(baseline, otherSalt);
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs b/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs
new file mode 100644
index 0000000..0c626d7
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs
@@ -0,0 +1,42 @@
+using SquidStd.Crypto.Password.Data;
+
+namespace SquidStd.Tests.Crypto.Password;
+
+public class PbkdfCostTests
+{
+ [Fact]
+ public void Moderate_HasVaultEquivalentDefaults()
+ {
+ Assert.Equal(65536, PbkdfCost.Moderate.MemoryKib);
+ Assert.Equal(3, PbkdfCost.Moderate.Iterations);
+ Assert.Equal(1, PbkdfCost.Moderate.Parallelism);
+ }
+
+ [Fact]
+ public void Presets_AreOrderedByCost()
+ {
+ Assert.True(PbkdfCost.Interactive.MemoryKib < PbkdfCost.Moderate.MemoryKib);
+ Assert.True(PbkdfCost.Moderate.MemoryKib < PbkdfCost.Sensitive.MemoryKib);
+ }
+
+ [Fact]
+ public void Custom_StoresParameters()
+ {
+ var cost = new PbkdfCost(memoryKib: 131072, iterations: 4, parallelism: 2);
+
+ Assert.Equal(131072, cost.MemoryKib);
+ Assert.Equal(4, cost.Iterations);
+ Assert.Equal(2, cost.Parallelism);
+ }
+
+ [Theory]
+ [InlineData(0, 3, 1)]
+ [InlineData(1024, 0, 1)]
+ [InlineData(1024, 3, 0)]
+ public void Custom_RejectsNonPositiveParameters(int memoryKib, int iterations, int parallelism)
+ => Assert.Throws(() => new PbkdfCost(memoryKib, iterations, parallelism));
+
+ [Fact]
+ public void Custom_RejectsParallelismAbove255()
+ => Assert.Throws(() => new PbkdfCost(1024, 3, 256));
+}