diff --git a/source/Octostache.Tests/EncryptionFixture.cs b/source/Octostache.Tests/EncryptionFixture.cs new file mode 100644 index 0000000..ee842c9 --- /dev/null +++ b/source/Octostache.Tests/EncryptionFixture.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Xunit; + +namespace Octostache.Tests +{ + public class EncryptionFixture : BaseFixture + { + [Fact] + public void MissingArgumentIsReported() + { + var result = Evaluate("#{foo | RsaEncrypt}", new Dictionary { { "foo", "Test" } }); + result.Should().Be("[RsaEncrypt error: expected a single argument holding a base64 encoded RSA public key]"); + } + + [Fact] + public void TooManyArgumentsAreReported() + { + var result = Evaluate(@"#{foo | RsaEncrypt abc def}", new Dictionary { { "foo", "Test" } }); + result.Should().Be("[RsaEncrypt error: expected a single argument holding a base64 encoded RSA public key]"); + } + + [Fact] + public void MissingInputLeavesTheTemplateUnevaluated() + { + var result = Evaluate("#{missing | RsaEncrypt abc}", new Dictionary()) + .Replace("\"", ""); // function parameters have quotes added when evaluated back to a string, so we need to remove them + result.Should().Be("#{missing | RsaEncrypt abc}"); + } + +#if NETFRAMEWORK + [Fact] + public void EncryptionIsReportedAsUnsupportedOnDotNetFramework() + { + using (var rsa = RSA.Create(2048)) + { + var result = Encrypt("Test", PublicKeyOf(rsa)); + result.Should().Be("[RsaEncrypt error: not supported when Octostache is running on .NET Framework]"); + } + } +#else + [Fact] + public void EncryptedValueRoundTripsWithThePrivateKey() + { + using (var rsa = RSA.Create(2048)) + { + var result = Encrypt("Test", PublicKeyOf(rsa)); + + var plainText = rsa.Decrypt(Convert.FromBase64String(result), RSAEncryptionPadding.OaepSHA256); + Encoding.UTF8.GetString(plainText).Should().Be("Test"); + } + } + + [Fact] + public void EncryptingTheSameValueTwiceProducesDifferentCipherText() + { + using (var rsa = RSA.Create(2048)) + { + var publicKey = PublicKeyOf(rsa); + + Encrypt("Test", publicKey).Should().NotBe(Encrypt("Test", publicKey)); + } + } + + [Fact] + public void NonBase64PublicKeyIsReported() + { + var result = Encrypt("Test", "not base64!"); + result.Should().Be("[RsaEncrypt error: the public key is not valid base64]"); + } + + [Fact] + public void PublicKeyThatIsNotSubjectPublicKeyInfoIsReported() + { + var result = Encrypt("Test", Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a key"))); + result.Should().Be("[RsaEncrypt error: the public key could not be read as a SubjectPublicKeyInfo structure]"); + } + + [Fact] + public void InputLongerThanTheKeyCanEncryptIsReported() + { + using (var rsa = RSA.Create(2048)) + { + var result = Encrypt(new string('a', 200), PublicKeyOf(rsa)); + result.Should().Be("[RsaEncrypt error: the input is 200 bytes, which is more than the 190 bytes a 2048 bit key can encrypt directly]"); + } + } + + [Fact] + public void InputAtTheLimitOfWhatTheKeyCanEncryptIsAccepted() + { + using (var rsa = RSA.Create(2048)) + { + var value = new string('a', 190); + var result = Encrypt(value, PublicKeyOf(rsa)); + + var plainText = rsa.Decrypt(Convert.FromBase64String(result), RSAEncryptionPadding.OaepSHA256); + Encoding.UTF8.GetString(plainText).Should().Be(value); + } + } + + static string PublicKeyOf(RSA rsa) => Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()); +#endif + + string Encrypt(string value, string publicKey) + => Evaluate("#{foo | RsaEncrypt #{key}}", + new Dictionary + { + { "foo", value }, + { "key", publicKey }, + }); + } +} diff --git a/source/Octostache/Templates/BuiltInFunctions.cs b/source/Octostache/Templates/BuiltInFunctions.cs index aef4aa1..d2481d6 100644 --- a/source/Octostache/Templates/BuiltInFunctions.cs +++ b/source/Octostache/Templates/BuiltInFunctions.cs @@ -48,6 +48,7 @@ static class BuiltInFunctions { "versionmetadata", VersionParseFunction.VersionMetadata }, { "append", TextManipulationFunction.Append }, { "prepend", TextManipulationFunction.Prepend }, + { "rsaencrypt", TextEncryptFunction.RsaEncrypt }, { "md5", HashFunction.Md5 }, { "sha1", HashFunction.Sha1 }, { "sha256", HashFunction.Sha256 }, diff --git a/source/Octostache/Templates/Functions/TextEncryptFunction.cs b/source/Octostache/Templates/Functions/TextEncryptFunction.cs new file mode 100644 index 0000000..5eb8741 --- /dev/null +++ b/source/Octostache/Templates/Functions/TextEncryptFunction.cs @@ -0,0 +1,65 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Octostache.Templates.Functions +{ + static class TextEncryptFunction + { + // OAEP with SHA-256 costs two hashes plus two bytes of the modulus, leaving the rest for the payload. + const int OaepSha256Overhead = (2 * 32) + 2; + + public static string? RsaEncrypt(string? argument, string[] options) + { + if (argument == null) + return null; + + if (options.Length != 1) + return Error("expected a single argument holding a base64 encoded RSA public key"); + +#if NET462 + return Error("not supported when Octostache is running on .NET Framework"); +#else + byte[] subjectPublicKeyInfo; + try + { + subjectPublicKeyInfo = Convert.FromBase64String(options[0]); + } + catch (FormatException) + { + return Error("the public key is not valid base64"); + } + + using (var rsa = RSA.Create()) + { + try + { + rsa.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _); + } + catch (CryptographicException) + { + return Error("the public key could not be read as a SubjectPublicKeyInfo structure"); + } + + var data = Encoding.UTF8.GetBytes(argument); + var maximum = (rsa.KeySize / 8) - OaepSha256Overhead; + if (data.Length > maximum) + return Error($"the input is {data.Length} bytes, which is more than the {maximum} bytes a {rsa.KeySize} bit key can encrypt directly"); + + try + { + return Convert.ToBase64String(rsa.Encrypt(data, RSAEncryptionPadding.OaepSHA256)); + } + catch (CryptographicException e) + { + return Error(e.Message); + } + } +#endif + } + + // Encryption failures are reported in the output rather than returned as null, which would leave the + // raw `#{...}` in place and read as though no encryption had been asked for. Matches UriPart. + static string Error(string message) => $"[RsaEncrypt error: {message}]"; + } +}