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
8 changes: 4 additions & 4 deletions docs/06-global-tool/02-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import AsciinemaPlayer from '@site/src/components/AsciinemaPlayer';

Historically, secret values like passwords or auth-tokens are often saved as environment variables on local machines or CI/CD servers. This imposes both, security issues because other processes can access these environment variables and inconveniences when a build must be executed locally for emergency reasons (server downtime). Fallout has an integrated encryption utility, which can be used to save and load secret values to and from [parameter files](../02-fundamentals/06-parameters.md#passing-values-through-parameter-files).

:::danger
Our [custom encryption utility](https://github.com/ChrisonSimtian/Fallout/blob/main/src/Nuke.Utilities/Security/EncryptionUtility.cs) is provided "AS IS" without warranty of any kind.
:::info Cryptography
Our [encryption utility](https://github.com/ChrisonSimtian/Fallout/blob/main/src/Fallout.Utilities/Security/EncryptionUtility.cs) uses **AES-256-GCM** with **PBKDF2-SHA256** at **600,000 iterations** (OWASP 2023 recommendation) for key derivation. Each encrypted value carries a fresh random 16-byte salt and a fresh random 12-byte nonce. The 16-byte GCM authentication tag means tampered ciphertexts are rejected on decrypt.

The implementation uses your password, a static salt, 10.000 iterations, and SHA256 to generate a [key-derivation function](https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.rfc2898derivebytes) ([RFC2898](https://datatracker.ietf.org/doc/html/rfc2898)), which is then used to create a crypto-stream to encrypt and decrypt values via [Advanced Encryption Standard (AES)](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard).
Encrypted values are prefixed `v2:` to mark the format version.

**Please review the implementation carefully and [open an issue](https://github.com/ChrisonSimtian/Fallout/issues) for any possible flaws.**
**Legacy `v1:` values** (from earlier versions: static salt, 10,000 iterations, AES-CBC with KDF-derived IV, no authentication) are still decrypted for backward compatibility, but **all new encrypts emit `v2:`**. Re-saving any secret through `fallout :secrets` rewrites it in v2 form automatically — there's no separate migration step. See [#212](https://github.com/ChrisonSimtian/Fallout/issues/212) for the security audit that drove the v2 format.
:::

## Adding & Updating Secrets
Expand Down
162 changes: 134 additions & 28 deletions src/Fallout.Utilities/Security/EncryptionUtility.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2026 Maintainers of Fallout.
// Copyright 2026 Maintainers of Fallout.
// Originally based on NUKE by Matthias Koch and contributors.
// Distributed under the MIT License.
// https://github.com/ChrisonSimtian/Fallout/blob/main/LICENSE
Expand All @@ -7,27 +7,60 @@

using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace Fallout.Common.Utilities;

/// <summary>
/// Symmetric encryption for the <c>fallout :secrets</c> flow. Two ciphertext formats are recognised:
///
/// <list type="bullet">
/// <item>
/// <description><b>v2 (current)</b>: AES-GCM with random 16-byte salt + random 12-byte nonce per
/// encrypt, PBKDF2-SHA256 at 600,000 iterations (OWASP 2023). Authenticated — tampered ciphertext
/// is rejected by the GCM auth tag. Format: <c>v2:base64(salt[16] || nonce[12] || tag[16] || ciphertext)</c>.
/// All new encrypts emit v2.</description>
/// </item>
/// <item>
/// <description><b>v1 (legacy)</b>: AES-CBC with static salt (<c>"Ivan Medvedev"</c>) and
/// KDF-derived IV, PBKDF2-SHA256 at 10,000 iterations. Unauthenticated. Read-only — kept for
/// backward-compatible decryption of values committed under earlier versions. Re-saving any
/// secret via <c>fallout :secrets</c> migrates it to v2.</description>
/// </item>
/// </list>
///
/// See <see href="https://github.com/ChrisonSimtian/Fallout/issues/212">#212</see> for the security
/// audit that motivated the v2 format.
/// </summary>
internal static class EncryptionUtility
{
private const string V1Prefix = "v1:";
private const string V2Prefix = "v2:";

private const int V2SaltLength = 16;
private const int V2NonceLength = 12;
private const int V2TagLength = 16;
private const int V2KeyLength = 32;
private const int V2Iterations = 600_000;

// Legacy v1 constants — preserved verbatim so we can still decrypt values committed under the
// old algorithm. New encrypts never use these.
private const int V1KeyLength = 32;
private const int V1IvLength = 16;
private const int V1Iterations = 10_000;
private static readonly byte[] s_v1StaticSalt = { 0x49, 0x76, 0x61, 0x6e, 0x20, 0x4d, 0x65, 0x64, 0x76, 0x65, 0x64, 0x65, 0x76 };

public static string Decrypt(string cipherText, string password, string name)
{
try
{
var cipherBytes = Convert.FromBase64String(cipherText.Remove(startIndex: 0, count: 3));
var passwordBytes = Encoding.UTF8.GetBytes(password);

using var memoryStream = new MemoryStream();
using var cryptoStream = GetCryptoStream(memoryStream, passwordBytes, x => x.CreateDecryptor());
cryptoStream.Write(cipherBytes, offset: 0, cipherBytes.Length);
cryptoStream.Close();

return Encoding.UTF8.GetString(memoryStream.ToArray());
if (cipherText.StartsWith(V2Prefix, StringComparison.Ordinal))
return DecryptV2(cipherText[V2Prefix.Length..], password);
if (cipherText.StartsWith(V1Prefix, StringComparison.Ordinal))
return DecryptV1(cipherText[V1Prefix.Length..], password);
// Unprefixed ciphertext from very old setups — treat as v1.
return DecryptV1(cipherText, password);
}
catch
{
Expand All @@ -38,34 +71,107 @@ public static string Decrypt(string cipherText, string password, string name)

public static string Encrypt(string clearText, string password)
{
var clearBytes = Encoding.UTF8.GetBytes(clearText);
var plaintext = Encoding.UTF8.GetBytes(clearText);
var passwordBytes = Encoding.UTF8.GetBytes(password);

using var memoryStream = new MemoryStream();
using var cryptoStream = GetCryptoStream(memoryStream, passwordBytes, x => x.CreateEncryptor());
cryptoStream.Write(clearBytes, offset: 0, clearBytes.Length);
cryptoStream.Close();
var salt = new byte[V2SaltLength];
RandomNumberGenerator.Fill(salt);
var nonce = new byte[V2NonceLength];
RandomNumberGenerator.Fill(nonce);

return $"v1:{Convert.ToBase64String(memoryStream.ToArray())}";
}
var key = Rfc2898DeriveBytes.Pbkdf2(passwordBytes, salt, V2Iterations, HashAlgorithmName.SHA256, V2KeyLength);

private static Stream GetCryptoStream(Stream stream, byte[] password, Func<SymmetricAlgorithm, ICryptoTransform> transformSelector)
{
var salt = new byte[] { 0x49, 0x76, 0x61, 0x6e, 0x20, 0x4d, 0x65, 0x64, 0x76, 0x65, 0x64, 0x65, 0x76 };
var pdb = new Rfc2898DeriveBytes(password, salt, iterations: 10_000, HashAlgorithmName.SHA256);
using var symmetricAlgorithm = Aes.Create().NotNull();
symmetricAlgorithm.Key = pdb.GetBytes(32);
symmetricAlgorithm.IV = pdb.GetBytes(16);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[V2TagLength];
using (var aes = new AesGcm(key, V2TagLength))
aes.Encrypt(nonce, plaintext, ciphertext, tag);

return new CryptoStream(stream, transformSelector(symmetricAlgorithm), CryptoStreamMode.Write);
var blob = new byte[V2SaltLength + V2NonceLength + V2TagLength + ciphertext.Length];
Buffer.BlockCopy(salt, 0, blob, 0, V2SaltLength);
Buffer.BlockCopy(nonce, 0, blob, V2SaltLength, V2NonceLength);
Buffer.BlockCopy(tag, 0, blob, V2SaltLength + V2NonceLength, V2TagLength);
Buffer.BlockCopy(ciphertext, 0, blob, V2SaltLength + V2NonceLength + V2TagLength, ciphertext.Length);

return V2Prefix + Convert.ToBase64String(blob);
}

public static string GetGeneratedPassword(int bits = 256)
{
var randomNumberGenerator = RandomNumberGenerator.Create();
var password = new byte[bits / 8];
randomNumberGenerator.GetBytes(password);
RandomNumberGenerator.Fill(password);
return Convert.ToBase64String(password);
}

private static string DecryptV2(string base64Blob, string password)
{
var blob = Convert.FromBase64String(base64Blob);
Assert.True(blob.Length >= V2SaltLength + V2NonceLength + V2TagLength, "v2 ciphertext blob is too short");

var salt = new byte[V2SaltLength];
Buffer.BlockCopy(blob, 0, salt, 0, V2SaltLength);
var nonce = new byte[V2NonceLength];
Buffer.BlockCopy(blob, V2SaltLength, nonce, 0, V2NonceLength);
var tag = new byte[V2TagLength];
Buffer.BlockCopy(blob, V2SaltLength + V2NonceLength, tag, 0, V2TagLength);
var ciphertextLength = blob.Length - V2SaltLength - V2NonceLength - V2TagLength;
var ciphertext = new byte[ciphertextLength];
Buffer.BlockCopy(blob, V2SaltLength + V2NonceLength + V2TagLength, ciphertext, 0, ciphertextLength);

var passwordBytes = Encoding.UTF8.GetBytes(password);
var key = Rfc2898DeriveBytes.Pbkdf2(passwordBytes, salt, V2Iterations, HashAlgorithmName.SHA256, V2KeyLength);

var plaintext = new byte[ciphertextLength];
using (var aes = new AesGcm(key, V2TagLength))
aes.Decrypt(nonce, ciphertext, tag, plaintext);

return Encoding.UTF8.GetString(plaintext);
}

private static string DecryptV1(string base64Ciphertext, string password)
{
var ciphertext = Convert.FromBase64String(base64Ciphertext);
var passwordBytes = Encoding.UTF8.GetBytes(password);

var keyAndIv = Rfc2898DeriveBytes.Pbkdf2(passwordBytes, s_v1StaticSalt, V1Iterations, HashAlgorithmName.SHA256, V1KeyLength + V1IvLength);
var key = new byte[V1KeyLength];
var iv = new byte[V1IvLength];
Buffer.BlockCopy(keyAndIv, 0, key, 0, V1KeyLength);
Buffer.BlockCopy(keyAndIv, V1KeyLength, iv, 0, V1IvLength);

using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
using var memoryStream = new MemoryStream();
using (var cryptoStream = new CryptoStream(memoryStream, aes.CreateDecryptor(), CryptoStreamMode.Write))
cryptoStream.Write(ciphertext, 0, ciphertext.Length);
return Encoding.UTF8.GetString(memoryStream.ToArray());
}

/// <summary>
/// Test-only: produces a v1-formatted ciphertext using the legacy algorithm. Exposed via
/// InternalsVisibleTo so backward-compatibility tests can verify <see cref="Decrypt"/> still
/// reads historical values without depending on hardcoded ciphertext strings. Production code
/// must never call this — new encrypts must always go through <see cref="Encrypt"/> (v2).
/// </summary>
internal static string EncryptV1Legacy(string clearText, string password)
{
var plaintext = Encoding.UTF8.GetBytes(clearText);
var passwordBytes = Encoding.UTF8.GetBytes(password);

var keyAndIv = Rfc2898DeriveBytes.Pbkdf2(passwordBytes, s_v1StaticSalt, V1Iterations, HashAlgorithmName.SHA256, V1KeyLength + V1IvLength);
var key = new byte[V1KeyLength];
var iv = new byte[V1IvLength];
Buffer.BlockCopy(keyAndIv, 0, key, 0, V1KeyLength);
Buffer.BlockCopy(keyAndIv, V1KeyLength, iv, 0, V1IvLength);

using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
using var memoryStream = new MemoryStream();
using (var cryptoStream = new CryptoStream(memoryStream, aes.CreateEncryptor(), CryptoStreamMode.Write))
cryptoStream.Write(plaintext, 0, plaintext.Length);

return V1Prefix + Convert.ToBase64String(memoryStream.ToArray());
}
}
#endif
118 changes: 118 additions & 0 deletions tests/Fallout.Utilities.Tests/EncryptionUtilityTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright 2026 Maintainers of Fallout.
// Originally based on NUKE by Matthias Koch and contributors.
// Distributed under the MIT License.
// https://github.com/ChrisonSimtian/Fallout/blob/main/LICENSE

using System;
using Fallout.Common.Utilities;
using FluentAssertions;
using Xunit;

namespace Fallout.Common.Tests;

public class EncryptionUtilityTest
{
[Fact]
public void V2_RoundTrip()
{
var encrypted = EncryptionUtility.Encrypt("hello world", "correct horse battery staple");

encrypted.Should().StartWith("v2:");
EncryptionUtility.Decrypt(encrypted, "correct horse battery staple", name: "test")
.Should().Be("hello world");
}

[Fact]
public void V2_FreshSaltAndNonce_ProduceDifferentCiphertextForSameInput()
{
// Random salt + random nonce per encrypt → same plaintext+password produces different
// ciphertext each time. Regression guard against the v1 deterministic-IV weakness.
var a = EncryptionUtility.Encrypt("hello", "pw");
var b = EncryptionUtility.Encrypt("hello", "pw");

a.Should().NotBe(b);
}

[Fact]
public void V2_WrongPasswordFails()
{
var encrypted = EncryptionUtility.Encrypt("hello", "right-password");

var act = () => EncryptionUtility.Decrypt(encrypted, "wrong-password", name: "test");

act.Should().Throw<Exception>();
}

[Fact]
public void V2_TamperedCiphertextFails()
{
// AES-GCM authenticates the ciphertext — any bit-flip after encrypt should fail decrypt.
// Regression guard against the v1 malleability weakness (CBC with no MAC).
var encrypted = EncryptionUtility.Encrypt("hello", "pw");
var blob = Convert.FromBase64String(encrypted["v2:".Length..]);
blob[^1] ^= 0xFF; // Flip the last byte (in the ciphertext portion).
var tampered = "v2:" + Convert.ToBase64String(blob);

var act = () => EncryptionUtility.Decrypt(tampered, "pw", name: "test");

act.Should().Throw<Exception>();
}

[Fact]
public void V2_TamperedTagFails()
{
var encrypted = EncryptionUtility.Encrypt("hello", "pw");
var blob = Convert.FromBase64String(encrypted["v2:".Length..]);
// Tag sits at bytes [16+12 .. 16+12+16] = [28..44]. Flip a tag byte.
blob[28] ^= 0xFF;
var tampered = "v2:" + Convert.ToBase64String(blob);

var act = () => EncryptionUtility.Decrypt(tampered, "pw", name: "test");

act.Should().Throw<Exception>();
}

[Fact]
public void V1_LegacyDecryptStillWorks()
{
// Backward-compat: existing parameters.json files with v1-encrypted values must keep decrypting.
// Produce a known-good v1 ciphertext via the test-only legacy encrypt helper, then verify the
// public Decrypt API reads it.
var v1Ciphertext = EncryptionUtility.EncryptV1Legacy("legacy-value", "legacy-password");

v1Ciphertext.Should().StartWith("v1:");
EncryptionUtility.Decrypt(v1Ciphertext, "legacy-password", name: "legacy")
.Should().Be("legacy-value");
}

[Fact]
public void V1_WrongPasswordFails()
{
var v1Ciphertext = EncryptionUtility.EncryptV1Legacy("legacy-value", "right");

var act = () => EncryptionUtility.Decrypt(v1Ciphertext, "wrong", name: "legacy");

act.Should().Throw<Exception>();
}

[Fact]
public void Encrypt_AlwaysEmitsV2()
{
// The public Encrypt API must never emit v1 ciphertexts, even though Decrypt still reads them.
// Guards the "new encrypts upgrade in place" migration story.
EncryptionUtility.Encrypt("anything", "anything").Should().StartWith("v2:");
}

[Theory]
[InlineData("")]
[InlineData("a")]
[InlineData("hello world")]
[InlineData("üñíçødé 🔒")]
[InlineData("multi\nline\nvalue")]
public void V2_RoundTripsArbitraryUtf8(string plaintext)
{
var encrypted = EncryptionUtility.Encrypt(plaintext, "pw");

EncryptionUtility.Decrypt(encrypted, "pw", name: "test").Should().Be(plaintext);
}
}
Loading