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)); +}