diff --git a/src/Microsoft.AspNet.Security.DataProtection/DataProtectionExtensions.cs b/src/Microsoft.AspNet.Security.DataProtection/DataProtectionExtensions.cs index cf7c9fa7cd..f46852c797 100644 --- a/src/Microsoft.AspNet.Security.DataProtection/DataProtectionExtensions.cs +++ b/src/Microsoft.AspNet.Security.DataProtection/DataProtectionExtensions.cs @@ -11,6 +11,17 @@ namespace Microsoft.AspNet.Security.DataProtection /// public static class DataProtectionExtensions { + /// + /// Creates a time-limited data protector based on an existing protector. + /// + /// The existing protector from which to derive a time-limited protector. + /// A time-limited data protector. + public static ITimeLimitedDataProtector AsTimeLimitedDataProtector([NotNull] this IDataProtector protector) + { + return (protector as ITimeLimitedDataProtector) + ?? new TimeLimitedDataProtector(protector.CreateProtector(TimeLimitedDataProtector.PurposeString)); + } + /// /// Cryptographically protects a piece of plaintext data. /// diff --git a/src/Microsoft.AspNet.Security.DataProtection/Error.cs b/src/Microsoft.AspNet.Security.DataProtection/Error.cs index aa75abce2c..8571a4074c 100644 --- a/src/Microsoft.AspNet.Security.DataProtection/Error.cs +++ b/src/Microsoft.AspNet.Security.DataProtection/Error.cs @@ -70,5 +70,11 @@ namespace Microsoft.AspNet.Security.DataProtection return new CryptographicException(Resources.Common_DecryptionFailed, inner); } + public static CryptographicException TimeLimitedDataProtector_PayloadExpired(ulong utcTicksExpiration) + { + DateTimeOffset expiration = new DateTimeOffset((long)utcTicksExpiration, TimeSpan.Zero).ToLocalTime(); + string message = String.Format(CultureInfo.CurrentCulture, Resources.TimeLimitedDataProtector_PayloadExpired, expiration); + return new CryptographicException(message); + } } } diff --git a/src/Microsoft.AspNet.Security.DataProtection/ITimeLimitedDataProtector.cs b/src/Microsoft.AspNet.Security.DataProtection/ITimeLimitedDataProtector.cs new file mode 100644 index 0000000000..f4770e410d --- /dev/null +++ b/src/Microsoft.AspNet.Security.DataProtection/ITimeLimitedDataProtector.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Security.DataProtection +{ + /// + /// An interface that can provide data protection services. + /// + public interface ITimeLimitedDataProtector : IDataProtector + { + /// + /// Creates an IDataProtector given a purpose. + /// + /// + /// The purpose to be assigned to the newly-created IDataProtector. + /// This parameter must be unique for the intended use case; two different IDataProtector + /// instances created with two different 'purpose' strings will not be able + /// to understand each other's payloads. The 'purpose' parameter is not intended to be + /// kept secret. + /// + /// An IDataProtector tied to the provided purpose. + new ITimeLimitedDataProtector CreateProtector(string purpose); + + /// + /// Cryptographically protects a piece of plaintext data and assigns an expiration date to the data. + /// + /// The plaintext data to protect. + /// The date after which the data can no longer be unprotected. + /// The protected form of the plaintext data. + byte[] Protect(byte[] unprotectedData, DateTimeOffset expiration); + + /// + /// Cryptographically unprotects a piece of protected data. + /// + /// The protected data to unprotect. + /// After unprotection, contains the expiration date of the protected data. + /// The plaintext form of the protected data. + /// + /// Implementations should throw CryptographicException if the protected data is invalid or malformed. + /// + byte[] Unprotect(byte[] protectedData, out DateTimeOffset expiration); + } +} diff --git a/src/Microsoft.AspNet.Security.DataProtection/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Security.DataProtection/Properties/Resources.Designer.cs index 8d35437c5a..f1d2151ed5 100644 --- a/src/Microsoft.AspNet.Security.DataProtection/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Security.DataProtection/Properties/Resources.Designer.cs @@ -202,6 +202,22 @@ namespace Microsoft.AspNet.Security.DataProtection return GetString("Common_PayloadProducedByNewerVersion"); } + /// + /// The payload expired at {0}. + /// + internal static string TimeLimitedDataProtector_PayloadExpired + { + get { return GetString("TimeLimitedDataProtector_PayloadExpired"); } + } + + /// + /// The payload expired at {0}. + /// + internal static string FormatTimeLimitedDataProtector_PayloadExpired(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("TimeLimitedDataProtector_PayloadExpired"), p0); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Security.DataProtection/Resources.resx b/src/Microsoft.AspNet.Security.DataProtection/Resources.resx index b03285c38d..8029969b2a 100644 --- a/src/Microsoft.AspNet.Security.DataProtection/Resources.resx +++ b/src/Microsoft.AspNet.Security.DataProtection/Resources.resx @@ -153,4 +153,7 @@ The protected payload cannot be decrypted because it was protected with a newer version of the protection provider. + + The payload expired at {0}. + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Security.DataProtection/TimeLimitedDataProtector.cs b/src/Microsoft.AspNet.Security.DataProtection/TimeLimitedDataProtector.cs new file mode 100644 index 0000000000..2bcf57dadd --- /dev/null +++ b/src/Microsoft.AspNet.Security.DataProtection/TimeLimitedDataProtector.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Cryptography; + +namespace Microsoft.AspNet.Security.DataProtection +{ + internal sealed class TimeLimitedDataProtector : ITimeLimitedDataProtector + { + internal const string PurposeString = "Microsoft.AspNet.Security.DataProtection.TimeLimitedDataProtector"; + + public TimeLimitedDataProtector(IDataProtector innerProtector) + { + InnerProtector = innerProtector; + } + + internal IDataProtector InnerProtector + { + get; + private set; + } + + public ITimeLimitedDataProtector CreateProtector([NotNull] string purpose) + { + return new TimeLimitedDataProtector(InnerProtector.CreateProtector(purpose)); + } + + public byte[] Protect([NotNull] byte[] unprotectedData) + { + return Protect(unprotectedData, DateTimeOffset.MaxValue); + } + + public byte[] Protect([NotNull] byte[] unprotectedData, DateTimeOffset expiration) + { + // We prepend the expiration time (as a big-endian 64-bit UTC tick count) to the unprotected data. + ulong utcTicksExpiration = (ulong)expiration.UtcTicks; + + byte[] unprotectedDataWithHeader = new byte[checked(8 + unprotectedData.Length)]; + unprotectedDataWithHeader[0] = (byte)(utcTicksExpiration >> 56); + unprotectedDataWithHeader[1] = (byte)(utcTicksExpiration >> 48); + unprotectedDataWithHeader[2] = (byte)(utcTicksExpiration >> 40); + unprotectedDataWithHeader[3] = (byte)(utcTicksExpiration >> 32); + unprotectedDataWithHeader[4] = (byte)(utcTicksExpiration >> 24); + unprotectedDataWithHeader[5] = (byte)(utcTicksExpiration >> 16); + unprotectedDataWithHeader[6] = (byte)(utcTicksExpiration >> 8); + unprotectedDataWithHeader[7] = (byte)(utcTicksExpiration); + Buffer.BlockCopy(unprotectedData, 0, unprotectedDataWithHeader, 8, unprotectedData.Length); + + return InnerProtector.Protect(unprotectedDataWithHeader); + } + + public byte[] Unprotect([NotNull] byte[] protectedData) + { + DateTimeOffset unused; + return Unprotect(protectedData, out unused); + } + + public byte[] Unprotect([NotNull] byte[] protectedData, out DateTimeOffset expiration) + { + try + { + byte[] unprotectedDataWithHeader = InnerProtector.Unprotect(protectedData); + CryptoUtil.Assert(unprotectedDataWithHeader.Length >= 8, "No header present."); + + // Read expiration time back out of the payload + ulong utcTicksExpiration = (((ulong)unprotectedDataWithHeader[0]) << 56) + | (((ulong)unprotectedDataWithHeader[1]) << 48) + | (((ulong)unprotectedDataWithHeader[2]) << 40) + | (((ulong)unprotectedDataWithHeader[3]) << 32) + | (((ulong)unprotectedDataWithHeader[4]) << 24) + | (((ulong)unprotectedDataWithHeader[5]) << 16) + | (((ulong)unprotectedDataWithHeader[6]) << 8) + | (ulong)unprotectedDataWithHeader[7]; + + // Are we expired? + DateTime utcNow = DateTime.UtcNow; + if ((ulong)utcNow.Ticks > utcTicksExpiration) + { + throw Error.TimeLimitedDataProtector_PayloadExpired(utcTicksExpiration); + } + + byte[] retVal = new byte[unprotectedDataWithHeader.Length - 8]; + Buffer.BlockCopy(unprotectedDataWithHeader, 8, retVal, 0, retVal.Length); + + expiration = new DateTimeOffset((long)utcTicksExpiration, TimeSpan.Zero); + return retVal; + } + catch (Exception ex) if (!(ex is CryptographicException)) + { + // Homogenize all failures to CryptographicException + throw Error.CryptCommon_GenericError(ex); + } + } + + IDataProtector IDataProtectionProvider.CreateProtector([NotNull] string purpose) + { + return CreateProtector(purpose); + } + } +} diff --git a/test/Microsoft.AspNet.Security.DataProtection.Test/DataProtectionExtensionsTests.cs b/test/Microsoft.AspNet.Security.DataProtection.Test/DataProtectionExtensionsTests.cs index 53fc15656c..6993f4ab9b 100644 --- a/test/Microsoft.AspNet.Security.DataProtection.Test/DataProtectionExtensionsTests.cs +++ b/test/Microsoft.AspNet.Security.DataProtection.Test/DataProtectionExtensionsTests.cs @@ -11,6 +11,34 @@ namespace Microsoft.AspNet.Security.DataProtection.Test { public class DataProtectionExtensionsTests { + [Fact] + public void AsTimeLimitedProtector_ProtectorIsAlreadyTimeLimited_ReturnsThis() + { + // Arrange + var originalProtector = new Mock().Object; + + // Act + var retVal = originalProtector.AsTimeLimitedDataProtector(); + + // Assert + Assert.Same(originalProtector, retVal); + } + + [Fact] + public void AsTimeLimitedProtector_ProtectorIsNotTimeLimited_CreatesNewProtector() + { + // Arrange + var innerProtector = new Mock().Object; + var outerProtectorMock = new Mock(); + outerProtectorMock.Setup(o => o.CreateProtector("Microsoft.AspNet.Security.DataProtection.TimeLimitedDataProtector")).Returns(innerProtector); + + // Act + var timeLimitedProtector = (TimeLimitedDataProtector)outerProtectorMock.Object.AsTimeLimitedDataProtector(); + + // Assert + Assert.Same(innerProtector, timeLimitedProtector.InnerProtector); + } + [Fact] public void Protect_InvalidUtf_Failure() { diff --git a/test/Microsoft.AspNet.Security.DataProtection.Test/TimeLimitedDataProtectorTests.cs b/test/Microsoft.AspNet.Security.DataProtection.Test/TimeLimitedDataProtectorTests.cs new file mode 100644 index 0000000000..671eb65aec --- /dev/null +++ b/test/Microsoft.AspNet.Security.DataProtection.Test/TimeLimitedDataProtectorTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Cryptography; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Security.DataProtection.Test +{ + public class TimeLimitedDataProtectorTests + { + [Fact] + public void CreateProtector_And_Protect() + { + // Arrange + // 0x08c1220247e44000 is the representation of midnight 2000-01-01 UTC. + DateTimeOffset expiration = new DateTimeOffset(new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Mock innerProtectorMock = new Mock(); + innerProtectorMock.Setup(o => o.Protect(new byte[] { 0x08, 0xc1, 0x22, 0x02, 0x47, 0xe4, 0x40, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 })).Returns(new byte[] { 0x10, 0x11 }); + Mock outerProtectorMock = new Mock(); + outerProtectorMock.Setup(p => p.CreateProtector("new purpose")).Returns(innerProtectorMock.Object); + + // Act + var timeLimitedProtector = new TimeLimitedDataProtector(outerProtectorMock.Object); + var subProtector = timeLimitedProtector.CreateProtector("new purpose"); + var protectedPayload = subProtector.Protect(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }, expiration); + + // Assert + Assert.Equal(new byte[] { 0x10, 0x11 }, protectedPayload); + } + + [Fact] + public void ExpiredData_Fails() + { + // Arrange + var timeLimitedProtector = CreateEphemeralTimeLimitedProtector(); + var expiration = DateTimeOffset.UtcNow.AddYears(-1); + + // Act & assert + var protectedData = timeLimitedProtector.Protect(new byte[] { 0x04, 0x08, 0x0c }, expiration); + Assert.Throws(() => + { + timeLimitedProtector.Unprotect(protectedData); + }); + } + + [Fact] + public void GoodData_RoundTrips() + { + // Arrange + var timeLimitedProtector = CreateEphemeralTimeLimitedProtector(); + var expectedExpiration = DateTimeOffset.UtcNow.AddYears(1); + + // Act + var protectedData = timeLimitedProtector.Protect(new byte[] { 0x04, 0x08, 0x0c }, expectedExpiration); + DateTimeOffset actualExpiration; + var unprotectedData = timeLimitedProtector.Unprotect(protectedData, out actualExpiration); + + // Assert + Assert.Equal(new byte[] { 0x04, 0x08, 0x0c }, unprotectedData); + Assert.Equal(expectedExpiration, actualExpiration); + } + + [Fact] + public void Protect_NoExpiration_UsesDateTimeOffsetMaxValue() + { + // Should pass DateTimeOffset.MaxValue (utc ticks = 0x2bca2875f4373fff) if no expiration date specified + + // Arrange + Mock innerProtectorMock = new Mock(); + innerProtectorMock.Setup(o => o.Protect(new byte[] { 0x2b, 0xca, 0x28, 0x75, 0xf4, 0x37, 0x3f, 0xff,0x01, 0x02, 0x03, 0x04, 0x05 })).Returns(new byte[] { 0x10, 0x11 }); + + // Act + var timeLimitedProtector = new TimeLimitedDataProtector(innerProtectorMock.Object); + var protectedPayload = timeLimitedProtector.Protect(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }); + + // Assert + Assert.Equal(new byte[] { 0x10, 0x11 }, protectedPayload); + } + + private static TimeLimitedDataProtector CreateEphemeralTimeLimitedProtector() + { + return new TimeLimitedDataProtector(new EphemeralDataProtectionProvider().CreateProtector("purpose")); + } + } +}