Skip to content

Commit d07d394

Browse files
committed
Merge Telegram.Bot.Extensions.Passport into the library
1 parent e5127c4 commit d07d394

16 files changed

+584
-1
lines changed

src/Telegram.Bot/Extend.Types.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,4 +407,19 @@ public static KeyboardButton WithRequestChat(string text, int requestId, bool ch
407407
=> new(text) { RequestChat = new(requestId, chatIsChannel) };
408408
}
409409
}
410+
411+
namespace Passport
412+
{
413+
public partial class IdDocumentData
414+
{
415+
/// <summary>Date of expiry if available</summary>
416+
public DateTime? Expiry => DateTime.TryParseExact(ExpiryDate, "dd.MM.yyyy", null, DateTimeStyles.None, out var result) ? result : null;
417+
}
418+
419+
public partial class PersonalDetails
420+
{
421+
/// <summary>Date of birth</summary>
422+
public DateTime Birthday => DateTime.ParseExact(BirthDate, "dd.MM.yyyy", null, DateTimeStyles.None);
423+
}
424+
}
410425
}

src/Telegram.Bot/ITelegramBotClient.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,25 @@ public static async Task<TGFile> GetInfoAndDownloadFile(this ITelegramBotClient
7979
await botClient.DownloadFile(filePath: file.FilePath!, destination, cancellationToken).ConfigureAwait(false);
8080
return file;
8181
}
82+
83+
/// <summary>Downloads an encrypted Passport file, decrypts it, and writes the content to <paramref name="destination"/> stream</summary>
84+
/// <param name="botClient">Instance of bot client</param>
85+
/// <param name="passportFile"></param>
86+
/// <param name="fileCredentials"></param>
87+
/// <param name="destination"></param>
88+
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
89+
/// <returns>File information of the encrypted Passport file on Telegram servers.</returns>
90+
/// <exception cref="ArgumentNullException"></exception>
91+
public static async Task<TGFile> DownloadAndDecryptPassportFileAsync(this ITelegramBotClient botClient, PassportFile passportFile,
92+
FileCredentials fileCredentials, Stream destination, CancellationToken cancellationToken = default)
93+
{
94+
if (passportFile == null) throw new ArgumentNullException(nameof(passportFile));
95+
if (fileCredentials == null) throw new ArgumentNullException(nameof(fileCredentials));
96+
if (destination == null) throw new ArgumentNullException(nameof(destination));
97+
using var encryptedContentStream = passportFile.FileSize > 0 ? new MemoryStream((int)passportFile.FileSize) : new MemoryStream();
98+
var fileInfo = await botClient.ThrowIfNull().GetInfoAndDownloadFile(passportFile.FileId, encryptedContentStream, cancellationToken).ConfigureAwait(false);
99+
encryptedContentStream.Position = 0;
100+
await new Passport.Decrypter().DecryptFileAsync(encryptedContentStream, fileCredentials, destination, cancellationToken).ConfigureAwait(false);
101+
return fileInfo;
102+
}
82103
}

src/Telegram.Bot/PassportDecrypter.cs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
6+
#pragma warning disable CA2208, MA0015
7+
#pragma warning disable CA1850, CA1835
8+
9+
namespace Telegram.Bot.Types.Passport
10+
{
11+
/// <summary>Marker interface for type of data in <see cref="EncryptedPassportElement.Data"/> property</summary>
12+
public interface IDecryptedValue { }
13+
}
14+
15+
namespace Telegram.Bot.Passport
16+
{
17+
/// <summary>Represents a fatal error in decryption of Telegram Passport Data</summary>
18+
public class PassportDataDecryptionException(string message) : Exception(message) { }
19+
20+
/// <summary>Provides decryption utilities for encrypted Telegram Passport data</summary>
21+
public class Decrypter
22+
{
23+
/// <summary>Decrypts encrypted credentials in <see cref="PassportData"/> using RSA key</summary>
24+
/// <param name="encryptedCredentials">Encrypted credentials in Passport data</param>
25+
/// <param name="key">RSA private key</param>
26+
/// <returns>Decrypted credentials</returns>
27+
/// <exception cref="ArgumentNullException"></exception>
28+
/// <exception cref="ArgumentException"></exception>
29+
/// <exception cref="FormatException"></exception>
30+
/// <exception cref="PassportDataDecryptionException"></exception>
31+
/// <exception cref="CryptographicException"></exception>
32+
public Credentials? DecryptCredentials(EncryptedCredentials encryptedCredentials, RSA key)
33+
{
34+
if (encryptedCredentials is null) throw new ArgumentNullException(nameof(encryptedCredentials));
35+
if (key is null) throw new ArgumentNullException(nameof(key));
36+
if (encryptedCredentials.Data is null) throw new ArgumentNullException(nameof(encryptedCredentials.Data));
37+
if (encryptedCredentials.Secret is null) throw new ArgumentNullException(nameof(encryptedCredentials.Secret));
38+
if (encryptedCredentials.Hash is null) throw new ArgumentNullException(nameof(encryptedCredentials.Hash));
39+
40+
byte[] data = Convert.FromBase64String(encryptedCredentials.Data);
41+
if (data.Length == 0) throw new ArgumentException("Data is empty.", nameof(encryptedCredentials.Data));
42+
if (data.Length % 16 != 0) throw new PassportDataDecryptionException($"Data length is not divisible by 16: {data.Length}.");
43+
44+
byte[] encryptedSecret = Convert.FromBase64String(encryptedCredentials.Secret);
45+
46+
byte[] hash = Convert.FromBase64String(encryptedCredentials.Hash);
47+
if (hash.Length != 32) throw new PassportDataDecryptionException($"Hash length is not 32: {hash.Length}.");
48+
49+
byte[] secret = key.Decrypt(encryptedSecret, RSAEncryptionPadding.OaepSHA1);
50+
51+
byte[] decryptedData = DecryptDataBytes(data, secret, hash);
52+
string json = Encoding.UTF8.GetString(decryptedData);
53+
return JsonSerializer.Deserialize<Credentials>(json, JsonBotAPI.Options);
54+
}
55+
56+
/// <summary>Decrypts encrypted data using its accompanying data credentials and deserializes the result from JSON to an instance of <typeparamref name="TValue"/></summary>
57+
/// <param name="encryptedData">Encrypted Passport data</param>
58+
/// <param name="dataCredentials">Accompanying data credentials required for decryption</param>
59+
/// <returns>Decrypted data</returns>
60+
/// <exception cref="ArgumentNullException"></exception>
61+
/// <exception cref="ArgumentException"></exception>
62+
/// <exception cref="FormatException"></exception>
63+
/// <exception cref="PassportDataDecryptionException"></exception>
64+
public TValue? DecryptData<TValue>(string encryptedData, DataCredentials dataCredentials) where TValue : class, IDecryptedValue
65+
{
66+
if (encryptedData is null) throw new ArgumentNullException(nameof(encryptedData));
67+
if (dataCredentials is null) throw new ArgumentNullException(nameof(dataCredentials));
68+
if (dataCredentials.Secret is null) throw new ArgumentNullException(nameof(dataCredentials.Secret));
69+
if (dataCredentials.DataHash is null) throw new ArgumentNullException(nameof(dataCredentials.DataHash));
70+
71+
byte[] data = Convert.FromBase64String(encryptedData);
72+
if (data.Length == 0) throw new ArgumentException("Data is empty.", nameof(encryptedData));
73+
if (data.Length % 16 != 0) throw new PassportDataDecryptionException($"Data length is not divisible by 16: {data.Length}.");
74+
75+
byte[] dataSecret = Convert.FromBase64String(dataCredentials.Secret);
76+
77+
byte[] dataHash = Convert.FromBase64String(dataCredentials.DataHash);
78+
if (dataHash.Length != 32) throw new PassportDataDecryptionException($"Hash length is not 32: {dataHash.Length}.");
79+
80+
byte[] decryptedData = DecryptDataBytes(data, dataSecret, dataHash);
81+
string json = Encoding.UTF8.GetString(decryptedData);
82+
return JsonSerializer.Deserialize<TValue>(json, JsonBotAPI.Options);
83+
}
84+
85+
/// <summary>Decrypts encrypted file bytes using its accompanying file credentials</summary>
86+
/// <param name="encryptedContent">Encrypted Passport file</param>
87+
/// <param name="fileCredentials">Accompanying file credentials required for decryption</param>
88+
/// <returns>Decrypted file bytes</returns>
89+
/// <exception cref="ArgumentNullException"></exception>
90+
/// <exception cref="ArgumentException"></exception>
91+
/// <exception cref="FormatException"></exception>
92+
/// <exception cref="PassportDataDecryptionException"></exception>
93+
public byte[] DecryptFile(
94+
byte[] encryptedContent,
95+
FileCredentials fileCredentials
96+
)
97+
{
98+
if (encryptedContent is null) throw new ArgumentNullException(nameof(encryptedContent));
99+
if (fileCredentials is null) throw new ArgumentNullException(nameof(fileCredentials));
100+
if (fileCredentials.Secret is null) throw new ArgumentNullException(nameof(fileCredentials.Secret));
101+
if (fileCredentials.FileHash is null) throw new ArgumentNullException(nameof(fileCredentials.FileHash));
102+
if (encryptedContent.Length == 0) throw new ArgumentException("Data array is empty.", nameof(encryptedContent));
103+
if (encryptedContent.Length % 16 != 0) throw new PassportDataDecryptionException
104+
($"Data length is not divisible by 16: {encryptedContent.Length}.");
105+
106+
byte[] dataSecret = Convert.FromBase64String(fileCredentials.Secret);
107+
byte[] dataHash = Convert.FromBase64String(fileCredentials.FileHash);
108+
if (dataHash.Length != 32) throw new PassportDataDecryptionException($"Hash length is not 32: {dataHash.Length}.");
109+
110+
return DecryptDataBytes(encryptedContent, dataSecret, dataHash);
111+
}
112+
113+
/// <summary>Decrypts encrypted file from stream using its accompanying file credentials and writes it to <paramref name="destination"/> stream</summary>
114+
/// <param name="encryptedContent">Encrypted Passport file stream</param>
115+
/// <param name="fileCredentials">Accompanying file credentials required for decryption</param>
116+
/// <param name="destination">Stream to write decrypted file content to</param>
117+
/// <param name="cancellationToken">The cancellation token to cancel operation</param>
118+
/// <exception cref="ArgumentNullException"></exception>
119+
/// <exception cref="ArgumentException"></exception>
120+
/// <exception cref="FormatException"></exception>
121+
/// <exception cref="PassportDataDecryptionException"></exception>
122+
/// <exception cref="CryptographicException"></exception>
123+
public Task DecryptFileAsync(Stream encryptedContent, FileCredentials fileCredentials, Stream destination, CancellationToken cancellationToken = default)
124+
{
125+
if (encryptedContent is null) throw new ArgumentNullException(nameof(encryptedContent));
126+
if (fileCredentials is null) throw new ArgumentNullException(nameof(fileCredentials));
127+
if (fileCredentials.Secret is null) throw new ArgumentNullException(nameof(fileCredentials.Secret));
128+
if (fileCredentials.FileHash is null) throw new ArgumentNullException(nameof(fileCredentials.FileHash));
129+
if (destination is null) throw new ArgumentNullException(nameof(destination));
130+
if (!encryptedContent.CanRead) throw new ArgumentException("Stream does not support reading.", nameof(encryptedContent));
131+
if (encryptedContent.CanSeek && encryptedContent.Length == 0) throw new ArgumentException("Stream is empty.", nameof(encryptedContent));
132+
if (encryptedContent.CanSeek && encryptedContent.Length % 16 != 0) throw new PassportDataDecryptionException($"Data length is not divisible by 16: {encryptedContent.Length}.");
133+
if (!destination.CanWrite) throw new ArgumentException("Stream does not support writing.", nameof(destination));
134+
135+
byte[] dataSecret = Convert.FromBase64String(fileCredentials.Secret);
136+
byte[] dataHash = Convert.FromBase64String(fileCredentials.FileHash);
137+
if (dataHash.Length != 32) throw new PassportDataDecryptionException($"Hash length is not 32: {dataHash.Length}.");
138+
139+
return DecryptDataStreamAsync(encryptedContent, dataSecret, dataHash, destination, cancellationToken);
140+
}
141+
142+
private static async Task DecryptDataStreamAsync(Stream data, byte[] secret, byte[] hash, Stream destination, CancellationToken cancellationToken)
143+
{
144+
FindDataKeyAndIv(secret, hash, out byte[] dataKey, out byte[] dataIv);
145+
146+
using var aes = Aes.Create();
147+
aes.KeySize = 256;
148+
aes.Mode = CipherMode.CBC;
149+
aes.Key = dataKey;
150+
aes.IV = dataIv;
151+
aes.Padding = PaddingMode.None;
152+
153+
using var decrypter = aes.CreateDecryptor();
154+
using CryptoStream aesStream = new CryptoStream(data, decrypter, CryptoStreamMode.Read);
155+
using var sha256 = SHA256.Create();
156+
using CryptoStream shaStream = new CryptoStream(aesStream, sha256, CryptoStreamMode.Read);
157+
byte[] paddingBuffer = new byte[256];
158+
int read = await shaStream.ReadAsync(paddingBuffer, 0, 256, cancellationToken).ConfigureAwait(false);
159+
160+
byte paddingLength = paddingBuffer[0];
161+
if (paddingLength < 32) throw new PassportDataDecryptionException($"Data padding length is invalid: {paddingLength}.");
162+
163+
int actualDataLength = read - paddingLength;
164+
if (actualDataLength < 1) throw new PassportDataDecryptionException($"Data length is invalid: {actualDataLength}.");
165+
166+
await destination.WriteAsync(paddingBuffer, paddingLength, actualDataLength, cancellationToken).ConfigureAwait(false);
167+
168+
// 81920 is the default Stream.CopyTo buffer size
169+
// The overload without the buffer size does not accept a cancellation token
170+
const int defaultBufferSize = 81920;
171+
await shaStream.CopyToAsync(destination, defaultBufferSize, cancellationToken).ConfigureAwait(false);
172+
173+
byte[] paddedDataHash = sha256.Hash!;
174+
for (int i = 0; i < hash.Length; i++)
175+
if (hash[i] != paddedDataHash[i])
176+
throw new PassportDataDecryptionException($"Data hash mismatch at position {i}.");
177+
}
178+
179+
private static byte[] DecryptDataBytes(byte[] data, byte[] secret, byte[] hash)
180+
{
181+
#region Step 1: find data Key & IV
182+
FindDataKeyAndIv(secret, hash, out byte[] dataKey, out byte[] dataIv);
183+
#endregion
184+
185+
#region Step 2.1: decrypt data to get "data with random padding"
186+
byte[] dataWithPadding;
187+
using (var aes = Aes.Create())
188+
{
189+
aes.KeySize = 256;
190+
aes.Mode = CipherMode.CBC;
191+
aes.Key = dataKey;
192+
aes.IV = dataIv;
193+
aes.Padding = PaddingMode.None;
194+
using var decrypter = aes.CreateDecryptor();
195+
dataWithPadding = decrypter.TransformFinalBlock(data, 0, data.Length);
196+
}
197+
#endregion
198+
199+
#region Step 2.2: verify "data_hash" and hash of "data with padding" are the same
200+
byte[] paddedDataHash;
201+
using (var sha256 = SHA256.Create())
202+
paddedDataHash = sha256.ComputeHash(dataWithPadding);
203+
204+
for (int i = 0; i < hash.Length; i++)
205+
if (hash[i] != paddedDataHash[i])
206+
throw new PassportDataDecryptionException($"Data hash mismatch at position {i}.");
207+
#endregion
208+
209+
#region Step 3: remove padding to get the actual data
210+
byte paddingLength = dataWithPadding[0];
211+
if (paddingLength < 32) throw new PassportDataDecryptionException($"Data padding length is invalid: {paddingLength}.");
212+
213+
int actualDataLength = dataWithPadding.Length - paddingLength;
214+
if (actualDataLength < 1) throw new PassportDataDecryptionException($"Data length is invalid: {actualDataLength}.");
215+
216+
var decryptedData = new byte[actualDataLength];
217+
Array.Copy(dataWithPadding, paddingLength, decryptedData, 0, actualDataLength);
218+
#endregion
219+
return decryptedData;
220+
}
221+
222+
private static void FindDataKeyAndIv(byte[] secret, byte[] hash, out byte[] dataKey, out byte[] dataIv)
223+
{
224+
byte[] dataSecretHash;
225+
using (var sha512 = SHA512.Create())
226+
{
227+
byte[] secretAndHashBytes = new byte[secret.Length + hash.Length];
228+
Array.Copy(secret, 0, secretAndHashBytes, 0, secret.Length);
229+
Array.Copy(hash, 0, secretAndHashBytes, secret.Length, hash.Length);
230+
dataSecretHash = sha512.ComputeHash(secretAndHashBytes);
231+
}
232+
233+
dataKey = new byte[32];
234+
Array.Copy(dataSecretHash, 0, dataKey, 0, 32);
235+
236+
dataIv = new byte[16];
237+
Array.Copy(dataSecretHash, 32, dataIv, 0, 16);
238+
}
239+
}
240+
}

src/Telegram.Bot/Serialization/JsonBotSerializerContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ namespace Telegram.Bot;
272272
[JsonSerializable(typeof(PassportElementErrorTranslationFile))]
273273
[JsonSerializable(typeof(PassportElementErrorTranslationFiles))]
274274
[JsonSerializable(typeof(PassportElementErrorUnspecified))]
275+
[JsonSerializable(typeof(PassportScope))]
276+
[JsonSerializable(typeof(PassportScopeElementOneOfSeveral))]
277+
[JsonSerializable(typeof(PersonalDetails))]
278+
[JsonSerializable(typeof(ResidentialAddress))]
279+
[JsonSerializable(typeof(IdDocumentData))]
280+
[JsonSerializable(typeof(Credentials))]
275281
[JsonSerializable(typeof(FileBase))]
276282
public partial class JsonBotSerializerContext : JsonSerializerContext;
277283
#endif

0 commit comments

Comments
 (0)