Skip to content

Commit d440499

Browse files
committed
Adding Aes256GCM + Aes256SIV compliance tunning
1 parent c352393 commit d440499

File tree

14 files changed

+1277
-403
lines changed

14 files changed

+1277
-403
lines changed

Readme.md

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,27 @@ builder.ConfigureArgon2Id(options =>
5656
var app = builder.Build();
5757

5858
app.Run();
59-
6059
```
6160

62-
### AES256 Class (Old, Deprecated)
61+
### 🔥 Breaking change (short) Version 5 to 6
62+
63+
We introduced **two intentional** changes to make usage clearer and safer:
64+
65+
1. `Aes256Siv` **is now RFC-5297 compliant**.
66+
The previous, non-standard implementation is renamed `Aes256SivLegacy` (compat only).
67+
68+
New `Aes256Gcm` for files.
69+
Fully compliant AES-GCM with framed streaming and truncation detection. Prefer this for files of any size.
70+
71+
#### What you must do
72+
73+
- If you previously called `Aes256Siv`, rename those references to `Aes256SivLegacy` to keep current data working.
74+
- When ready, migrate legacy ciphertexts to the new format using `AesSivMigration` (single/batch helpers).
75+
- For files, use `Aes256Gcm` instead of SIV.
76+
77+
> That’s it. This section is intentionally short—see API notes below.
78+
79+
### 🔥 Breaking change Version 4 to 5
6380

6481
> Warning
6582
> `Aes256` is now deprecated because it used a SHA3 hash for deterministic output, which can weaken overall security.
@@ -123,27 +140,46 @@ string decryptedText = Encoding.UTF8.GetString(outputStream.ToArray());
123140
encrypting emails in your software and also want that emails to be unique. With our Aes256 class by default your
124141
emails will be unique as in front will be the unique hash.
125142

126-
### AES256Siv (New, Recommended)
143+
### Aes256Gcm - Perfect for files
127144

128-
**AES-SIV** (RFC 5297) is the new recommended approach in PandaTech.Crypto for deterministic AES encryption.
129-
It **does not** rely on storing a large hash for uniqueness, instead uses a **synthetic IV** approach to provide both
130-
authentication and deterministic encryption.
145+
**Use this for: images, videos, audio, PDF, XLSX, PPTX, etc.**
146+
147+
- AEAD (confidentiality + integrity)
148+
- Bounded memory, chunked frames (`64 KiB` by default)
149+
- Detects clean truncation via a terminal 0-length authenticated frame
131150

132151
```csharp
133152
// Encrypt
134-
byte[] sivCipher = Aes256Siv.Encrypt("your-plaintext");
135-
136-
// Decrypt
137-
string decrypted = Aes256Siv.Decrypt(sivCipher);
153+
Aes256Gcm.RegisterKey(key);
154+
using var fin = File.OpenRead("report.pdf");
155+
using var fout = File.Create("report.pdf.gcm");
156+
Aes256Gcm.Encrypt(fin, fout);
157+
158+
// decrypt
159+
using var ein = File.OpenRead("report.pdf.gcm");
160+
using var eout = File.Create("report.dec.pdf");
161+
Aes256Gcm.Decrypt(ein, eout);
138162
```
139163

140-
**Notes:**
164+
> Tip: You can pass a per-call override key: Encrypt(fin, fout, key) / Decrypt(...).
165+
166+
### Aes256Siv - Deterministic and perfect for PII
167+
168+
Use this for PII you need to **match deterministically** (e.g., names/IDs) and decrypt later.
169+
This is **spec-correct AES-SIV (RFC-5297)**: CMAC S2V + masked CTR, output is `V(16B) || C`.
141170

142-
- Deterministic: Encrypting the same plaintext with the same key always produces the same ciphertext.
171+
```csharp
172+
Aes256Siv.RegisterKey(key);
173+
174+
byte[] cipher = Aes256Siv.Encrypt("John Q Public");
175+
string plain = Aes256Siv.Decrypt(cipher);
176+
177+
// byte[] API
178+
var c2 = Aes256Siv.Encrypt(dataBytes);
179+
var p2 = Aes256Siv.DecryptToBytes(c2);
180+
```
143181

144-
- Security: AES-SIV is an AEAD mode, providing both authenticity (tamper detection) and deterministic encryption.
145-
- Stream-based usage is also available via `Encrypt(Stream in, Stream out, string? key = null) and Decrypt(Stream in,
146-
Stream out, string? key = null)`.
182+
> Note: SIV is two-pass by design → not ideal for big files. Use GCM for files.
147183
148184
### AesMigration
149185

src/Pandatech.Crypto/Extensions/WebAppExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ public static class WebAppExtensions
88
public static WebApplicationBuilder AddAes256Key(this WebApplicationBuilder builder, string aesKey)
99
{
1010
Aes256.RegisterKey(aesKey);
11+
Aes256SivLegacy.RegisterKey(aesKey);
12+
Aes256Gcm.RegisterKey(aesKey);
1113
Aes256Siv.RegisterKey(aesKey);
1214
return builder;
1315
}
1416

15-
public static WebApplicationBuilder ConfigureArgon2Id(this WebApplicationBuilder builder, Action<Argon2IdOptions> configure)
17+
public static WebApplicationBuilder ConfigureArgon2Id(this WebApplicationBuilder builder,
18+
Action<Argon2IdOptions> configure)
1619
{
1720
var options = new Argon2IdOptions();
1821
configure(options);
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using System.Buffers.Binary;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Security.Cryptography;
4+
5+
namespace Pandatech.Crypto.Helpers;
6+
7+
public static class Aes256Gcm
8+
{
9+
private const int KeySize = 32; // 256-bit
10+
private const int NonceSize = 12; // GCM recommended
11+
private const int TagSize = 16; // 128-bit tag
12+
private const int DefaultChunkSize = 64 * 1024;
13+
14+
// [Magic: 'PGCM'][Version:1][BaseNonce:12][ChunkSize:4 LE]
15+
// Frames: [PlainLen:4 LE][Tag:16][Ciphertext:PlainLen]
16+
private static readonly byte[] Magic = "PGCM"u8.ToArray();
17+
18+
private const byte Version = 1;
19+
20+
private static string? GlobalKey { get; set; }
21+
22+
internal static void RegisterKey(string key)
23+
{
24+
ValidateKey(key);
25+
GlobalKey = key;
26+
}
27+
28+
public static void Encrypt(Stream input, Stream output) => Encrypt(input, output, null);
29+
30+
public static void Encrypt(Stream input, Stream output, string? key)
31+
{
32+
ArgumentNullException.ThrowIfNull(input);
33+
ArgumentNullException.ThrowIfNull(output);
34+
35+
var k = GetKeyBytes(key);
36+
37+
Span<byte> baseNonce = stackalloc byte[NonceSize];
38+
RandomNumberGenerator.Fill(baseNonce);
39+
40+
// header
41+
Span<byte> header = stackalloc byte[Magic.Length + 1 + NonceSize + 4];
42+
Magic.CopyTo(header);
43+
header[4] = Version;
44+
baseNonce.CopyTo(header.Slice(5, NonceSize));
45+
BinaryPrimitives.WriteUInt32LittleEndian(header.Slice(5 + NonceSize, 4), (uint)DefaultChunkSize);
46+
output.Write(header);
47+
48+
using var aes = new AesGcm(k, TagSize);
49+
50+
var plain = new byte[DefaultChunkSize];
51+
var cipher = new byte[DefaultChunkSize]; // <— you removed this; we need it
52+
var tagBuf = new byte[TagSize];
53+
var nonceBuf = new byte[NonceSize];
54+
var frameLen4 = new byte[4];
55+
var aadLen = 5 + NonceSize + 4;
56+
57+
ulong counter = 0;
58+
int read;
59+
60+
// --- data frames ---
61+
while ((read = input.Read(plain, 0, plain.Length)) > 0)
62+
{
63+
DeriveNonce(baseNonce, counter, nonceBuf);
64+
65+
var p = plain.AsSpan(0, read);
66+
var c = cipher.AsSpan(0, read);
67+
var tag = tagBuf.AsSpan();
68+
69+
aes.Encrypt(nonceBuf, p, c, tag, header[..aadLen]);
70+
71+
BinaryPrimitives.WriteUInt32LittleEndian(frameLen4, (uint)read);
72+
output.Write(frameLen4); // len
73+
output.Write(tagBuf); // tag
74+
output.Write(c); // ciphertext
75+
76+
counter++;
77+
}
78+
79+
// --- single terminal 0-length authenticated frame ---
80+
DeriveNonce(baseNonce, counter, nonceBuf);
81+
aes.Encrypt(nonceBuf,
82+
ReadOnlySpan<byte>.Empty,
83+
Span<byte>.Empty,
84+
tagBuf,
85+
header[..aadLen]);
86+
87+
BinaryPrimitives.WriteUInt32LittleEndian(frameLen4, 0u);
88+
output.Write(frameLen4);
89+
output.Write(tagBuf);
90+
}
91+
92+
93+
public static void Decrypt(Stream input, Stream output) => Decrypt(input, output, null);
94+
95+
public static void Decrypt(Stream input, Stream output, string? key)
96+
{
97+
ArgumentNullException.ThrowIfNull(input);
98+
ArgumentNullException.ThrowIfNull(output);
99+
100+
var k = GetKeyBytes(key);
101+
102+
// header (single stackalloc outside loops)
103+
Span<byte> header = stackalloc byte[Magic.Length + 1 + NonceSize + 4];
104+
ReadExactly(input, header);
105+
106+
if (!header[..4]
107+
.SequenceEqual(Magic))
108+
{
109+
throw new CryptographicException("Invalid header.");
110+
}
111+
112+
if (header[4] != Version)
113+
{
114+
throw new CryptographicException("Unsupported version.");
115+
}
116+
117+
var baseNonce = header.Slice(5, NonceSize)
118+
.ToArray();
119+
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(5 + NonceSize, 4));
120+
if (chunkSize == 0 || chunkSize > 16 * 1024 * 1024)
121+
throw new CryptographicException("Invalid chunk size.");
122+
123+
using var aes = new AesGcm(k, TagSize);
124+
125+
var cipher = new byte[chunkSize];
126+
var plain = new byte[chunkSize];
127+
var tagBuf = new byte[TagSize];
128+
var nonceBuf = new byte[NonceSize];
129+
var lenBuf4 = new byte[4];
130+
var aadLen = 5 + NonceSize + 4;
131+
132+
ulong counter = 0;
133+
var sawTerminal = false;
134+
135+
while (true)
136+
{
137+
var got = input.Read(lenBuf4);
138+
if (got == 0)
139+
break; // we’ll check sawTerminal below
140+
141+
if (got != 4)
142+
throw new CryptographicException("Truncated frame.");
143+
144+
var len = BinaryPrimitives.ReadUInt32LittleEndian(lenBuf4);
145+
if (len > chunkSize)
146+
throw new CryptographicException("Frame too large.");
147+
148+
ReadExactly(input, tagBuf);
149+
150+
DeriveNonce(baseNonce, counter, nonceBuf);
151+
152+
if (len == 0)
153+
{
154+
// terminal frame: verify tag on empty payload
155+
aes.Decrypt(nonceBuf,
156+
ReadOnlySpan<byte>.Empty,
157+
tagBuf,
158+
Span<byte>.Empty,
159+
header[..aadLen]);
160+
161+
sawTerminal = true;
162+
163+
// after terminal, there MUST be no extra data
164+
if (input.Read(lenBuf4) != 0)
165+
throw new CryptographicException("Trailing data after terminal frame.");
166+
167+
break;
168+
}
169+
170+
var c = cipher.AsSpan(0, (int)len);
171+
ReadExactly(input, c);
172+
173+
var p = plain.AsSpan(0, (int)len);
174+
aes.Decrypt(nonceBuf, c, tagBuf, p, header[..aadLen]);
175+
output.Write(p);
176+
177+
counter++;
178+
}
179+
180+
if (!sawTerminal)
181+
throw new CryptographicException("Missing terminal authentication frame.");
182+
}
183+
184+
private static void DeriveNonce(ReadOnlySpan<byte> baseNonce, ulong counter, Span<byte> outNonce)
185+
{
186+
// outNonce = baseNonce; then XOR LE64(counter) into last 8 bytes — no temporaries.
187+
baseNonce.CopyTo(outNonce);
188+
for (var i = 0; i < 8; i++)
189+
{
190+
var b = (byte)((counter >> (8 * i)) & 0xFF);
191+
outNonce[NonceSize - 8 + i] ^= b;
192+
}
193+
}
194+
195+
private static void ReadExactly(Stream s, Span<byte> buffer)
196+
{
197+
var total = 0;
198+
while (total < buffer.Length)
199+
{
200+
var r = s.Read(buffer.Slice(total));
201+
if (r == 0) throw new CryptographicException("Truncated input.");
202+
total += r;
203+
}
204+
}
205+
206+
private static byte[] GetKeyBytes(string? overrideKey)
207+
{
208+
if (string.IsNullOrEmpty(overrideKey))
209+
{
210+
return GlobalKey is null
211+
? throw new InvalidOperationException("AES256 Key not configured. Call RegisterKey(...) or provide a key.")
212+
: Convert.FromBase64String(GlobalKey);
213+
}
214+
215+
ValidateKey(overrideKey);
216+
return Convert.FromBase64String(overrideKey);
217+
}
218+
219+
private static void ValidateKey([NotNull] string? key)
220+
{
221+
if (string.IsNullOrWhiteSpace(key) || !IsBase64String(key))
222+
{
223+
throw new ArgumentException("Key must be valid Base64.");
224+
}
225+
226+
if (Convert.FromBase64String(key)
227+
.Length != KeySize)
228+
{
229+
throw new ArgumentException("Key must be 32 bytes (256 bits).");
230+
}
231+
}
232+
233+
private static bool IsBase64String(string input)
234+
{
235+
var buffer = new Span<byte>(new byte[input.Length]);
236+
return Convert.TryFromBase64String(input, buffer, out _);
237+
}
238+
}

0 commit comments

Comments
 (0)