Skip to content
Draft
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
116 changes: 116 additions & 0 deletions source/Octostache.Tests/EncryptionFixture.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> { { "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<string, string> { { "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<string, string>())
.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<string, string>
{
{ "foo", value },
{ "key", publicKey },
});
}
}
1 change: 1 addition & 0 deletions source/Octostache/Templates/BuiltInFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
65 changes: 65 additions & 0 deletions source/Octostache/Templates/Functions/TextEncryptFunction.cs
Original file line number Diff line number Diff line change
@@ -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}]";
}
}