Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/SquidStd.Crypto/Password/Data/PbkdfCost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace SquidStd.Crypto.Password.Data;

/// <summary>Argon2id cost parameters for password-based key derivation.</summary>
public sealed class PbkdfCost
{
/// <summary>Argon2id memory cost in KiB.</summary>
public int MemoryKib { get; }

/// <summary>Argon2id iteration (time) cost.</summary>
public int Iterations { get; }

/// <summary>Argon2id parallelism (lanes).</summary>
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;
}

/// <summary>Fast, for interactive logins (16 MiB, 2 passes).</summary>
public static PbkdfCost Interactive { get; } = new(16 * 1024, 2);

/// <summary>Balanced default (64 MiB, 3 passes).</summary>
public static PbkdfCost Moderate { get; } = new(64 * 1024, 3);

/// <summary>High cost for data at rest (256 MiB, 4 passes).</summary>
public static PbkdfCost Sensitive { get; } = new(256 * 1024, 4);
}
35 changes: 35 additions & 0 deletions src/SquidStd.Crypto/Password/Internal/PasswordEnvelope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace SquidStd.Crypto.Password.Internal;

/// <summary>Parsed fields of a password-encryption envelope.</summary>
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;
}
}
95 changes: 95 additions & 0 deletions src/SquidStd.Crypto/Password/Internal/PasswordEnvelopeCodec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Buffers.Binary;

namespace SquidStd.Crypto.Password.Internal;

/// <summary>Encodes/decodes the self-describing <c>SPBE</c> password-encryption envelope.</summary>
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<byte> salt, ReadOnlySpan<byte> 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<byte> AadOf(byte[] blob)
{
return blob.AsSpan(0, AadSize);
}
}
28 changes: 28 additions & 0 deletions src/SquidStd.Crypto/Password/Internal/PasswordKeyDerivation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;

namespace SquidStd.Crypto.Password.Internal;

/// <summary>Derives a 32-byte key from a password with Argon2id (v1.3).</summary>
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;
}
}
97 changes: 97 additions & 0 deletions src/SquidStd.Crypto/Password/PasswordCipher.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class PasswordCipher
{
/// <summary>Encrypts <paramref name="plaintext" /> under <paramref name="password" />.</summary>
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);
}

/// <summary>Decrypts an envelope produced by <see cref="Encrypt" />.</summary>
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;
}

/// <summary>Encrypts a UTF-8 string and returns the envelope as base64.</summary>
public static string EncryptString(string text, string password, PbkdfCost? cost = null)
{
ArgumentNullException.ThrowIfNull(text);

return Convert.ToBase64String(Encrypt(Encoding.UTF8.GetBytes(text), password, cost));
}

/// <summary>Decrypts a base64 envelope produced by <see cref="EncryptString" /> back to its UTF-8 string.</summary>
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));
}
}
12 changes: 12 additions & 0 deletions src/SquidStd.Crypto/Password/PasswordDecryptionException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Security.Cryptography;

namespace SquidStd.Crypto.Password;

/// <summary>Thrown when a password is incorrect or the encrypted payload was corrupted or tampered with.</summary>
public sealed class PasswordDecryptionException : CryptographicException
{
public PasswordDecryptionException(string message, Exception innerException)
: base(message, innerException)
{
}
}
26 changes: 26 additions & 0 deletions src/SquidStd.Crypto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ await keyring.SaveAsync(container.Resolve<IPgpKeyStore>());
await keyring.LoadAsync(container.Resolve<IPgpKeyStore>());
```

## 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 |
Expand All @@ -60,6 +84,8 @@ await keyring.LoadAsync(container.Resolve<IPgpKeyStore>());
| `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

Expand Down
Loading
Loading