Skip to content

Commit 9f24867

Browse files
committed
span data protector unprotect
1 parent 614b569 commit 9f24867

File tree

6 files changed

+271
-86
lines changed

6 files changed

+271
-86
lines changed

src/DataProtection/Abstractions/src/ISpanDataProtector.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ public interface ISpanDataProtector : IDataProtector
1818
/// <summary>
1919
/// Determines the size of the protected data in order to then use <see cref="TryProtect(ReadOnlySpan{byte}, Span{byte}, out int)"/>."/>.
2020
/// </summary>
21-
/// <param name="plainText">The plain text that will be encrypted later</param>
21+
/// <param name="plainTextLength">The plain text length which will be encrypted later.</param>
2222
/// <returns>The size of the protected data.</returns>
23-
int GetProtectedSize(ReadOnlySpan<byte> plainText);
23+
int GetProtectedSize(int plainTextLength);
2424

2525
/// <summary>
2626
/// Returns the size of the decrypted data for a given ciphertext length.
@@ -42,12 +42,8 @@ public interface ISpanDataProtector : IDataProtector
4242
/// Attempts to validate the authentication tag of and decrypt a blob of encrypted data.
4343
/// </summary>
4444
/// <param name="cipherText">The encrypted data to decrypt.</param>
45-
/// <param name="additionalAuthenticatedData">
46-
/// A piece of data which was included in the authentication tag during encryption.
47-
/// This input may be zero bytes in length. The same AAD must be specified in the corresponding encryption call.
48-
/// </param>
4945
/// <param name="destination">The decrypted output.</param>
5046
/// <param name="bytesWritten">When this method returns, the total number of bytes written into destination</param>
5147
/// <returns>true if decryption was successful; otherwise, false.</returns>
52-
bool TryUnprotect(ReadOnlySpan<byte> cipherText, ReadOnlySpan<byte> additionalAuthenticatedData, Span<byte> destination, out int bytesWritten);
48+
bool TryUnprotect(ReadOnlySpan<byte> cipherText, Span<byte> destination, out int bytesWritten);
5349
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#nullable enable
22
Microsoft.AspNetCore.DataProtection.ISpanDataProtector
3-
Microsoft.AspNetCore.DataProtection.ISpanDataProtector.GetProtectedSize(System.ReadOnlySpan<byte> plainText) -> int
3+
Microsoft.AspNetCore.DataProtection.ISpanDataProtector.GetProtectedSize(int plainTextLength) -> int
44
Microsoft.AspNetCore.DataProtection.ISpanDataProtector.GetUnprotectedSize(int cipherTextLength) -> int
55
Microsoft.AspNetCore.DataProtection.ISpanDataProtector.TryProtect(System.ReadOnlySpan<byte> plainText, System.Span<byte> destination, out int bytesWritten) -> bool
6-
Microsoft.AspNetCore.DataProtection.ISpanDataProtector.TryUnprotect(System.ReadOnlySpan<byte> cipherText, System.ReadOnlySpan<byte> additionalAuthenticatedData, System.Span<byte> destination, out int bytesWritten) -> bool
6+
Microsoft.AspNetCore.DataProtection.ISpanDataProtector.TryUnprotect(System.ReadOnlySpan<byte> cipherText, System.Span<byte> destination, out int bytesWritten) -> bool

src/DataProtection/DataProtection/src/KeyManagement/KeyRingBasedDataProtector.cs

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,6 @@ protected static string JoinPurposesForLog(IEnumerable<string> purposes)
8989
return "(" + String.Join(", ", purposes.Select(p => "'" + p + "'")) + ")";
9090
}
9191

92-
// allows decrypting payloads whose keys have been revoked
93-
public byte[] DangerousUnprotect(byte[] protectedData, bool ignoreRevocationErrors, out bool requiresMigration, out bool wasRevoked)
94-
{
95-
// argument & state checking
96-
ArgumentNullThrowHelper.ThrowIfNull(protectedData);
97-
98-
UnprotectStatus status;
99-
var retVal = UnprotectCore(protectedData, ignoreRevocationErrors, status: out status);
100-
requiresMigration = (status != UnprotectStatus.Ok);
101-
wasRevoked = (status == UnprotectStatus.DecryptionKeyWasRevoked);
102-
return retVal;
103-
}
104-
10592
public byte[] Protect(byte[] plaintext)
10693
{
10794
ArgumentNullThrowHelper.ThrowIfNull(plaintext);
@@ -152,7 +139,7 @@ public byte[] Protect(byte[] plaintext)
152139
}
153140
}
154141

155-
private static Guid ReadGuid(void* ptr)
142+
protected static Guid ReadGuid(void* ptr)
156143
{
157144
#if NETCOREAPP
158145
// Performs appropriate endianness fixups
@@ -165,15 +152,7 @@ private static Guid ReadGuid(void* ptr)
165152
#endif
166153
}
167154

168-
private static uint ReadBigEndian32BitInteger(byte* ptr)
169-
{
170-
return ((uint)ptr[0] << 24)
171-
| ((uint)ptr[1] << 16)
172-
| ((uint)ptr[2] << 8)
173-
| ((uint)ptr[3]);
174-
}
175-
176-
private static bool TryGetVersionFromMagicHeader(uint magicHeader, out int version)
155+
protected static bool TryGetVersionFromMagicHeader(uint magicHeader, out int version)
177156
{
178157
const uint MAGIC_HEADER_VERSION_MASK = 0xFU;
179158
if ((magicHeader & ~MAGIC_HEADER_VERSION_MASK) == MAGIC_HEADER_V0)
@@ -199,14 +178,26 @@ public byte[] Unprotect(byte[] protectedData)
199178
wasRevoked: out _);
200179
}
201180

181+
// allows decrypting payloads whose keys have been revoked
182+
public byte[] DangerousUnprotect(byte[] protectedData, bool ignoreRevocationErrors, out bool requiresMigration, out bool wasRevoked)
183+
{
184+
// argument & state checking
185+
ArgumentNullThrowHelper.ThrowIfNull(protectedData);
186+
187+
UnprotectStatus status;
188+
var retVal = UnprotectCore(protectedData, ignoreRevocationErrors, status: out status);
189+
requiresMigration = (status != UnprotectStatus.Ok);
190+
wasRevoked = (status == UnprotectStatus.DecryptionKeyWasRevoked);
191+
return retVal;
192+
}
193+
202194
private byte[] UnprotectCore(byte[] protectedData, bool allowOperationsOnRevokedKeys, out UnprotectStatus status)
203195
{
204196
Debug.Assert(protectedData != null);
205197

206198
try
207199
{
208-
// argument & state checking
209-
if (protectedData.Length < sizeof(uint) /* magic header */ + sizeof(Guid) /* key id */)
200+
if (protectedData.Length < _magicHeaderKeyIdSize)
210201
{
211202
// payload must contain at least the magic header and key id
212203
throw Error.ProtectionProvider_BadMagicHeader();
@@ -215,17 +206,15 @@ private byte[] UnprotectCore(byte[] protectedData, bool allowOperationsOnRevoked
215206
// Need to check that protectedData := { magicHeader || keyId || encryptorSpecificProtectedPayload }
216207

217208
// Parse the payload version number and key id.
218-
uint magicHeaderFromPayload;
209+
var magicHeaderFromPayload = BinaryPrimitives.ReadUInt32BigEndian(protectedData.AsSpan(0, sizeof(uint)));
219210
Guid keyIdFromPayload;
220211
fixed (byte* pbInput = protectedData)
221212
{
222-
magicHeaderFromPayload = ReadBigEndian32BitInteger(pbInput);
223213
keyIdFromPayload = ReadGuid(&pbInput[sizeof(uint)]);
224214
}
225215

226216
// Are the magic header and version information correct?
227-
int payloadVersion;
228-
if (!TryGetVersionFromMagicHeader(magicHeaderFromPayload, out payloadVersion))
217+
if (!TryGetVersionFromMagicHeader(magicHeaderFromPayload, out var payloadVersion))
229218
{
230219
throw Error.ProtectionProvider_BadMagicHeader();
231220
}

src/DataProtection/DataProtection/src/KeyManagement/KeyRingBasedSpanDataProtector.cs

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public KeyRingBasedSpanDataProtector(IKeyRingProvider keyRingProvider, ILogger?
1919
{
2020
}
2121

22-
public int GetProtectedSize(ReadOnlySpan<byte> plainText)
22+
public int GetProtectedSize(int plainTextLength)
2323
{
2424
// Get the current key ring to access the encryptor
2525
var currentKeyRing = _keyRingProvider.GetCurrentKeyRing();
@@ -28,7 +28,7 @@ public int GetProtectedSize(ReadOnlySpan<byte> plainText)
2828

2929
// We allocate a 20-byte pre-buffer so that we can inject the magic header and key id into the return value.
3030
// See Protect() / TryProtect() for details
31-
return _magicHeaderKeyIdSize + defaultEncryptor.GetEncryptedSize(plainText.Length);
31+
return _magicHeaderKeyIdSize + defaultEncryptor.GetEncryptedSize(plainTextLength);
3232
}
3333

3434
public bool TryProtect(ReadOnlySpan<byte> plaintext, Span<byte> destination, out int bytesWritten)
@@ -83,4 +83,106 @@ public bool TryProtect(ReadOnlySpan<byte> plaintext, Span<byte> destination, out
8383
throw Error.Common_EncryptionFailed(ex);
8484
}
8585
}
86+
87+
public int GetUnprotectedSize(int cipherTextLength)
88+
{
89+
// The ciphertext includes the magic header and key id, so we need to subtract those
90+
if (cipherTextLength < _magicHeaderKeyIdSize)
91+
{
92+
throw Error.ProtectionProvider_BadMagicHeader();
93+
}
94+
95+
var currentKeyRing = _keyRingProvider.GetCurrentKeyRing();
96+
var defaultEncryptor = (ISpanAuthenticatedEncryptor)currentKeyRing.DefaultAuthenticatedEncryptor!;
97+
CryptoUtil.Assert(defaultEncryptor != null, "DefaultAuthenticatedEncryptor != null");
98+
99+
return defaultEncryptor.GetDecryptedSize(cipherTextLength - _magicHeaderKeyIdSize);
100+
}
101+
102+
public bool TryUnprotect(ReadOnlySpan<byte> cipherText, Span<byte> destination, out int bytesWritten)
103+
{
104+
try
105+
{
106+
if (cipherText.Length < _magicHeaderKeyIdSize)
107+
{
108+
// payload must contain at least the magic header and key id
109+
throw Error.ProtectionProvider_BadMagicHeader();
110+
}
111+
112+
// Parse the payload version number and key id.
113+
var magicHeaderFromPayload = BinaryPrimitives.ReadUInt32BigEndian(cipherText.Slice(0, sizeof(uint)));
114+
#if NET10_0_OR_GREATER
115+
var keyIdFromPayload = new Guid(cipherText.Slice(sizeof(uint), sizeof(Guid)));
116+
#else
117+
Guid keyIdFromPayload;
118+
fixed (byte* pbCipherText = cipherText)
119+
{
120+
keyIdFromPayload = ReadGuid(&pbCipherText[sizeof(uint)]);
121+
}
122+
#endif
123+
124+
// Are the magic header and version information correct?
125+
if (!TryGetVersionFromMagicHeader(magicHeaderFromPayload, out var payloadVersion))
126+
{
127+
throw Error.ProtectionProvider_BadMagicHeader();
128+
}
129+
else if (payloadVersion != 0)
130+
{
131+
throw Error.ProtectionProvider_BadVersion();
132+
}
133+
134+
if (_logger.IsDebugLevelEnabled())
135+
{
136+
_logger.PerformingUnprotectOperationToKeyWithPurposes(keyIdFromPayload, JoinPurposesForLog(Purposes));
137+
}
138+
139+
// Find the correct encryptor in the keyring.
140+
var currentKeyRing = _keyRingProvider.GetCurrentKeyRing();
141+
var requestedEncryptor = currentKeyRing.GetAuthenticatedEncryptorByKeyId(keyIdFromPayload, out bool keyWasRevoked);
142+
if (requestedEncryptor is null)
143+
{
144+
if (_keyRingProvider is KeyRingProvider provider && provider.InAutoRefreshWindow())
145+
{
146+
currentKeyRing = provider.RefreshCurrentKeyRing();
147+
requestedEncryptor = currentKeyRing.GetAuthenticatedEncryptorByKeyId(keyIdFromPayload, out keyWasRevoked);
148+
}
149+
150+
if (requestedEncryptor is null)
151+
{
152+
if (_logger.IsTraceLevelEnabled())
153+
{
154+
_logger.KeyWasNotFoundInTheKeyRingUnprotectOperationCannotProceed(keyIdFromPayload);
155+
}
156+
bytesWritten = 0;
157+
return false;
158+
}
159+
}
160+
161+
// Check if key was revoked - for simplified version, we disallow revoked keys
162+
if (keyWasRevoked)
163+
{
164+
if (_logger.IsDebugLevelEnabled())
165+
{
166+
_logger.KeyWasRevokedUnprotectOperationCannotProceed(keyIdFromPayload);
167+
}
168+
bytesWritten = 0;
169+
return false;
170+
}
171+
172+
// Perform the decryption operation.
173+
ReadOnlySpan<byte> actualCiphertext = cipherText.Slice(sizeof(uint) + sizeof(Guid)); // chop off magic header + encryptor id
174+
ReadOnlySpan<byte> aad = _aadTemplate.GetAadForKey(keyIdFromPayload, isProtecting: false);
175+
176+
// At this point, actualCiphertext := { encryptorSpecificPayload },
177+
// so all that's left is to invoke the decryption routine directly.
178+
var spanEncryptor = (ISpanAuthenticatedEncryptor)requestedEncryptor;
179+
return spanEncryptor.TryDecrypt(actualCiphertext, aad, destination, out bytesWritten);
180+
181+
}
182+
catch (Exception ex) when (ex.RequiresHomogenization())
183+
{
184+
// homogenize all errors to CryptographicException
185+
throw Error.DecryptionFailed(ex);
186+
}
187+
}
86188
}

src/DataProtection/DataProtection/test/Microsoft.AspNetCore.DataProtection.Tests/Internal/RoundtripEncryptionHelpers.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,61 @@ public static void AssertTryEncryptTryDecryptParity(IAuthenticatedEncryptor encr
7070
ArrayPool<byte>.Shared.Return(plainTextPooled);
7171
}
7272
}
73+
74+
/// <summary>
75+
/// <see cref="ISpanDataProtector.TryProtect"/> and <see cref="ISpanDataProtector.TryUnprotect"/> APIs should do the same steps
76+
/// as <see cref="IDataProtector.Protect"/> and <see cref="IDataProtector.Unprotect"/> APIs.
77+
/// <br/>
78+
/// Method ensures that the two APIs are equivalent in terms of their behavior by performing a roundtrip protect-unprotect test.
79+
/// </summary>
80+
public static void AssertTryProtectTryUnprotectParity(ISpanDataProtector protector, ReadOnlySpan<byte> plaintext)
81+
{
82+
// assert "allocatey" Protect/Unprotect APIs roundtrip correctly
83+
byte[] protectedData = protector.Protect(plaintext.ToArray());
84+
byte[] unprotectedData = protector.Unprotect(protectedData);
85+
Assert.Equal(plaintext, unprotectedData.AsSpan());
86+
87+
// assert calculated sizes are correct
88+
var expectedProtectedSize = protector.GetProtectedSize(plaintext.Length);
89+
Assert.Equal(expectedProtectedSize, protectedData.Length);
90+
var expectedUnprotectedSize = protector.GetUnprotectedSize(protectedData.Length);
91+
92+
// note: for unprotection we can't know exactly how many bytes will be written since it's the original plaintext
93+
Assert.True(expectedUnprotectedSize >= unprotectedData.Length);
94+
95+
// perform TryProtect and Unprotect roundtrip - ensures cross operation compatibility
96+
var protectedPooled = ArrayPool<byte>.Shared.Rent(expectedProtectedSize);
97+
try
98+
{
99+
var tryProtectResult = protector.TryProtect(plaintext, protectedPooled, out var bytesWritten);
100+
Assert.Equal(expectedProtectedSize, bytesWritten);
101+
Assert.True(tryProtectResult);
102+
103+
var unprotectedTryProtect = protector.Unprotect(protectedPooled.AsSpan(0, expectedProtectedSize).ToArray());
104+
Assert.Equal(plaintext, unprotectedTryProtect.AsSpan());
105+
}
106+
finally
107+
{
108+
ArrayPool<byte>.Shared.Return(protectedPooled);
109+
}
110+
111+
// perform Protect and TryUnprotect roundtrip - ensures cross operation compatibility
112+
// Note: This test is limited because we can't easily access the correct AAD from outside the protector
113+
// But we can test basic functionality with empty AAD and expect it to fail gracefully
114+
var unprotectedPooled = ArrayPool<byte>.Shared.Rent(expectedUnprotectedSize);
115+
try
116+
{
117+
var protectedByProtect = protector.Protect(plaintext.ToArray());
118+
var unprotectedTryUnprotect = protector.TryUnprotect(protectedByProtect, unprotectedPooled, out var bytesWritten);
119+
Assert.Equal(plaintext, unprotectedPooled.AsSpan(0, bytesWritten));
120+
Assert.True(unprotectedTryUnprotect);
121+
122+
// now we should know that bytesWritten is STRICTLY equal to the deciphered text
123+
Assert.Equal(unprotectedData.Length, bytesWritten);
124+
}
125+
finally
126+
{
127+
ArrayPool<byte>.Shared.Return(unprotectedPooled);
128+
}
129+
}
73130
}

0 commit comments

Comments
 (0)