Add project files.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
using System.Security.Cryptography;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
|
||||
namespace Wingnal.Service.Attachments;
|
||||
|
||||
/// <summary>Thrown when an attachment fails its MAC or digest check, or is malformed.</summary>
|
||||
public sealed class InvalidAttachmentException : Exception
|
||||
{
|
||||
public InvalidAttachmentException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a Signal attachment blob. Layout on the CDN is
|
||||
/// <c>iv[16] || AES-256-CBC(cipherKey, plaintext+padding) || HMAC-SHA256(macKey, iv||ciphertext)[32]</c>.
|
||||
/// The 64-byte attachment key is <c>cipherKey[32] || macKey[32]</c>; the optional <c>digest</c> is
|
||||
/// SHA-256 over the WHOLE blob. Mirrors libsignal/Signal-Android AttachmentCipherInputStream.
|
||||
/// </summary>
|
||||
public static class AttachmentCipher
|
||||
{
|
||||
private const int IvLength = 16;
|
||||
private const int MacLength = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the digest (if given) and the HMAC, then AES-256-CBC decrypts. If
|
||||
/// <paramref name="plaintextLength"/> is provided (the AttachmentPointer <c>size</c>), the result is
|
||||
/// truncated to it to strip bucket padding.
|
||||
/// </summary>
|
||||
public static byte[] Decrypt(byte[] blob, byte[] combinedKey, byte[]? digest = null, int? plaintextLength = null)
|
||||
{
|
||||
if (combinedKey.Length != 64)
|
||||
throw new InvalidAttachmentException($"attachment key must be 64 bytes, got {combinedKey.Length}");
|
||||
if (blob.Length <= IvLength + MacLength)
|
||||
throw new InvalidAttachmentException("attachment blob too short");
|
||||
|
||||
byte[] cipherKey = combinedKey.AsSpan(0, 32).ToArray();
|
||||
byte[] macKey = combinedKey.AsSpan(32, 32).ToArray();
|
||||
|
||||
// Whole-blob digest (covers iv + ciphertext + mac).
|
||||
if (digest is not null)
|
||||
{
|
||||
byte[] actual = SHA256.HashData(blob);
|
||||
if (!CryptographicOperations.FixedTimeEquals(actual, digest))
|
||||
throw new InvalidAttachmentException("attachment digest mismatch");
|
||||
}
|
||||
|
||||
int macOffset = blob.Length - MacLength;
|
||||
byte[] theirMac = blob.AsSpan(macOffset, MacLength).ToArray();
|
||||
byte[] ourMac = CryptoPrimitives.HmacSha256(macKey, blob.AsSpan(0, macOffset));
|
||||
if (!CryptographicOperations.FixedTimeEquals(theirMac, ourMac))
|
||||
throw new InvalidAttachmentException("attachment MAC mismatch");
|
||||
|
||||
byte[] iv = blob.AsSpan(0, IvLength).ToArray();
|
||||
byte[] ciphertext = blob.AsSpan(IvLength, macOffset - IvLength).ToArray();
|
||||
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(cipherKey, iv, ciphertext);
|
||||
|
||||
if (plaintextLength is { } len && len >= 0 && len < plaintext.Length)
|
||||
plaintext = plaintext.AsSpan(0, len).ToArray();
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an encrypted attachment blob the way Signal does (for tests / round-trip verification):
|
||||
/// <c>iv || AES-256-CBC(cipherKey, plaintext) || HMAC(macKey, iv||ct)</c>, returning the blob and
|
||||
/// its SHA-256 digest.
|
||||
/// </summary>
|
||||
public static (byte[] Blob, byte[] Digest) Encrypt(byte[] plaintext, byte[] combinedKey, byte[] iv)
|
||||
{
|
||||
if (combinedKey.Length != 64) throw new ArgumentException("key must be 64 bytes", nameof(combinedKey));
|
||||
if (iv.Length != IvLength) throw new ArgumentException("iv must be 16 bytes", nameof(iv));
|
||||
|
||||
byte[] cipherKey = combinedKey.AsSpan(0, 32).ToArray();
|
||||
byte[] macKey = combinedKey.AsSpan(32, 32).ToArray();
|
||||
|
||||
byte[] ciphertext = CryptoPrimitives.AesCbcEncrypt(cipherKey, iv, plaintext);
|
||||
|
||||
var withoutMac = new byte[IvLength + ciphertext.Length];
|
||||
Array.Copy(iv, 0, withoutMac, 0, IvLength);
|
||||
Array.Copy(ciphertext, 0, withoutMac, IvLength, ciphertext.Length);
|
||||
|
||||
byte[] mac = CryptoPrimitives.HmacSha256(macKey, withoutMac);
|
||||
var blob = new byte[withoutMac.Length + MacLength];
|
||||
Array.Copy(withoutMac, 0, blob, 0, withoutMac.Length);
|
||||
Array.Copy(mac, 0, blob, withoutMac.Length, MacLength);
|
||||
|
||||
return (blob, SHA256.HashData(blob));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Attachments;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an <see cref="AttachmentPointer"/> from the Signal CDN and decrypts it. The download is
|
||||
/// GET <c>{cdnUrl}/attachments/{cdnKey|cdnId}</c>; decryption is <see cref="AttachmentCipher"/>
|
||||
/// (AES-256-CBC + HMAC-SHA256 + whole-blob SHA-256 digest). Used for contact/group sync blobs now and
|
||||
/// media later.
|
||||
/// </summary>
|
||||
public sealed class AttachmentDownloader : IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
/// <param name="http">Optional shared client. If null, a cert-pinned client is created (see
|
||||
/// SHORTCUTS.md re: CDN cert pinning). Auth: the CDN serves attachments without account auth.</param>
|
||||
public AttachmentDownloader(HttpClient? http = null)
|
||||
{
|
||||
if (http is null)
|
||||
{
|
||||
var handler = new SocketsHttpHandler();
|
||||
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||
(sender, cert, chain, errors) => SignalTrust.Validate(sender, cert, chain, errors);
|
||||
_http = new HttpClient(handler);
|
||||
_ownsClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
_http.DefaultRequestHeaders.UserAgent.TryParseAdd(SignalServiceConfig.UserAgent);
|
||||
}
|
||||
|
||||
/// <summary>Downloads + decrypts the attachment, returning the plaintext bytes.</summary>
|
||||
public async Task<byte[]> DownloadAsync(AttachmentPointer pointer, CancellationToken ct = default)
|
||||
{
|
||||
if (pointer.Key is null || pointer.Key.Length != 64)
|
||||
throw new InvalidAttachmentException("attachment pointer has no/invalid 64-byte key");
|
||||
|
||||
string location = LocationFor(pointer);
|
||||
string url = $"{SignalServiceConfig.CdnUrl(pointer.CdnNumber)}/attachments/{location}";
|
||||
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"attachment download failed: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
|
||||
byte[] blob = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
byte[]? digest = pointer.HasDigest ? pointer.Digest.ToByteArray() : null;
|
||||
int? size = pointer.HasSize ? (int)pointer.Size : null;
|
||||
return AttachmentCipher.Decrypt(blob, pointer.Key.ToByteArray(), digest, size);
|
||||
}
|
||||
|
||||
/// <summary>Raw CDN GET (no attachment decryption) for a cdn-number + object key — used for the
|
||||
/// link'n'sync transfer archive, which is decrypted by <c>BackupReader</c> with a MessageBackupKey
|
||||
/// rather than an attachment key.</summary>
|
||||
public async Task<byte[]> DownloadRawAsync(uint cdnNumber, string cdnKey, CancellationToken ct = default)
|
||||
{
|
||||
string url = $"{SignalServiceConfig.CdnUrl(cdnNumber)}/attachments/{cdnKey}";
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"archive download failed: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string LocationFor(AttachmentPointer pointer)
|
||||
{
|
||||
// cdn2/cdn3 use a string cdnKey; the legacy cdn0 uses a numeric cdnId.
|
||||
if (pointer.AttachmentIdentifierCase == AttachmentPointer.AttachmentIdentifierOneofCase.CdnKey)
|
||||
return pointer.CdnKey;
|
||||
if (pointer.AttachmentIdentifierCase == AttachmentPointer.AttachmentIdentifierOneofCase.CdnId)
|
||||
return pointer.CdnId.ToString();
|
||||
throw new InvalidAttachmentException("attachment pointer has no cdn id/key");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient) _http.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Attachments;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads + decrypts an inbound <see cref="AttachmentPointer"/> and saves the plaintext to a local
|
||||
/// media file, returning its path (for the chat UI to show/open). Best-effort: returns null on any
|
||||
/// failure so a missing/expired attachment never breaks message display. Reuses the tested
|
||||
/// <see cref="AttachmentDownloader"/> (CDN GET) + <see cref="AttachmentCipher"/> (AES-CBC + HMAC + digest).
|
||||
/// </summary>
|
||||
public sealed class AttachmentService
|
||||
{
|
||||
private readonly string _mediaDir;
|
||||
|
||||
public AttachmentService(string? mediaDir = null)
|
||||
{
|
||||
_mediaDir = mediaDir ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal", "media");
|
||||
Directory.CreateDirectory(_mediaDir);
|
||||
}
|
||||
|
||||
public async Task<string?> SaveAsync(AttachmentPointer pointer, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var downloader = new AttachmentDownloader();
|
||||
byte[] plaintext = await downloader.DownloadAsync(pointer, ct).ConfigureAwait(false);
|
||||
string path = Path.Combine(_mediaDir, FileNameFor(pointer));
|
||||
await File.WriteAllBytesAsync(path, plaintext, ct).ConfigureAwait(false);
|
||||
return path;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"attachment: download failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return null; // show the placeholder; don't break the message
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes pre-decrypted bytes to the media folder (test/local helper); returns the path.</summary>
|
||||
public string Save(byte[] plaintext, string extension)
|
||||
{
|
||||
string path = Path.Combine(_mediaDir, Guid.NewGuid().ToString("N") + Normalize(extension));
|
||||
File.WriteAllBytes(path, plaintext);
|
||||
return path;
|
||||
}
|
||||
|
||||
private string FileNameFor(AttachmentPointer p)
|
||||
{
|
||||
string ext = !string.IsNullOrEmpty(p.FileName) && Path.HasExtension(p.FileName)
|
||||
? Path.GetExtension(p.FileName)
|
||||
: ExtensionForContentType(p.ContentType);
|
||||
return Guid.NewGuid().ToString("N") + ext;
|
||||
}
|
||||
|
||||
private static string ExtensionForContentType(string? contentType) => contentType switch
|
||||
{
|
||||
"image/jpeg" => ".jpg",
|
||||
"image/png" => ".png",
|
||||
"image/gif" => ".gif",
|
||||
"image/webp" => ".webp",
|
||||
"video/mp4" => ".mp4",
|
||||
"audio/aac" or "audio/mp4" => ".m4a",
|
||||
_ => ".bin",
|
||||
};
|
||||
|
||||
private static string Normalize(string ext) => ext.StartsWith('.') ? ext : "." + ext;
|
||||
}
|
||||
Reference in New Issue
Block a user