From b5d4ff0abac86dcdeedb016c42ff023a1c9accfe Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:32:23 +0200 Subject: [PATCH 1/6] feat(crypto): add PbkdfCost with Argon2 cost presets for PBE --- .../Password/Data/PbkdfCost.cs | 34 +++++++++++++++++ .../Crypto/Password/PbkdfCostTests.cs | 38 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/SquidStd.Crypto/Password/Data/PbkdfCost.cs create mode 100644 tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs diff --git a/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs b/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs new file mode 100644 index 0000000..dd4edf9 --- /dev/null +++ b/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs @@ -0,0 +1,34 @@ +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); + + 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/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs b/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs new file mode 100644 index 0000000..2f2584c --- /dev/null +++ b/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs @@ -0,0 +1,38 @@ +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)); +} From 83d933d092ec1d9602d42dece96a60474087c291 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:35:25 +0200 Subject: [PATCH 2/6] feat(crypto): add versioned AAD-bound envelope codec for PBE --- .../Password/Internal/PasswordEnvelope.cs | 35 +++++++ .../Internal/PasswordEnvelopeCodec.cs | 95 +++++++++++++++++++ .../Password/PasswordEnvelopeCodecTests.cs | 68 +++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 src/SquidStd.Crypto/Password/Internal/PasswordEnvelope.cs create mode 100644 src/SquidStd.Crypto/Password/Internal/PasswordEnvelopeCodec.cs create mode 100644 tests/SquidStd.Tests/Crypto/Password/PasswordEnvelopeCodecTests.cs 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/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)); + } +} From 223318bb3b3c2be061a75c99c968234304fad625 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:38:46 +0200 Subject: [PATCH 3/6] feat(crypto): add Argon2id key derivation for PBE --- .../Internal/PasswordKeyDerivation.cs | 28 ++++++++++++++++++ .../Password/PasswordKeyDerivationTests.cs | 29 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/SquidStd.Crypto/Password/Internal/PasswordKeyDerivation.cs create mode 100644 tests/SquidStd.Tests/Crypto/Password/PasswordKeyDerivationTests.cs 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/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); + } +} From 2544f22ac0c14135964fcd9fec264c76e9e96a03 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:41:11 +0200 Subject: [PATCH 4/6] feat(crypto): add PasswordCipher password-based encryption (Argon2id + AES-GCM) - Add PasswordCipher static class: Encrypt/Decrypt byte[], EncryptString/DecryptString (base64) - Add PasswordDecryptionException (sealed, extends CryptographicException) for wrong password or tampered data - AES-256-GCM with AAD-bound authentication; wrong password and ciphertext/header tampering all throw PasswordDecryptionException - Encrypt produces unique blobs per call via random salt + nonce - PbkdfCost defaults to Moderate when omitted --- .../Password/PasswordCipher.cs | 97 +++++++++++++++++++ .../Password/PasswordDecryptionException.cs | 12 +++ .../Crypto/Password/PasswordCipherTests.cs | 73 ++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/SquidStd.Crypto/Password/PasswordCipher.cs create mode 100644 src/SquidStd.Crypto/Password/PasswordDecryptionException.cs create mode 100644 tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs 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/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs b/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs new file mode 100644 index 0000000..82d5104 --- /dev/null +++ b/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs @@ -0,0 +1,73 @@ +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")); + } +} From 6bfbec04a11c27ffde14b552bb8d366f88d2686b Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:44:20 +0200 Subject: [PATCH 5/6] docs(crypto): document password-based encryption (PasswordCipher) --- src/SquidStd.Crypto/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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 From ca8af550c572dc5dae884ea9faa59ff439364c89 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:50:22 +0200 Subject: [PATCH 6/6] harden(crypto): reject Argon2 parallelism above 255 in PbkdfCost The envelope stores parallelism in a single byte, so a value above 255 would truncate silently and produce an undecryptable blob (the key is derived with the real lane count on encrypt but the envelope records the truncated one). Guard it with ArgumentOutOfRangeException.ThrowIfGreaterThan up front. Add tests for the guard plus empty-plaintext and non-ASCII-password round-trips. --- src/SquidStd.Crypto/Password/Data/PbkdfCost.cs | 4 ++++ .../Crypto/Password/PasswordCipherTests.cs | 16 ++++++++++++++++ .../Crypto/Password/PbkdfCostTests.cs | 4 ++++ 3 files changed, 24 insertions(+) diff --git a/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs b/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs index dd4edf9..1661ee8 100644 --- a/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs +++ b/src/SquidStd.Crypto/Password/Data/PbkdfCost.cs @@ -18,6 +18,10 @@ public PbkdfCost(int memoryKib, int iterations, int parallelism = 1) 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; diff --git a/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs b/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs index 82d5104..46a3abc 100644 --- a/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs +++ b/tests/SquidStd.Tests/Crypto/Password/PasswordCipherTests.cs @@ -70,4 +70,20 @@ public void DefaultCost_RoundTrips() 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/PbkdfCostTests.cs b/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs index 2f2584c..0c626d7 100644 --- a/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs +++ b/tests/SquidStd.Tests/Crypto/Password/PbkdfCostTests.cs @@ -35,4 +35,8 @@ public void Custom_StoresParameters() [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)); }