Add project files.

This commit is contained in:
micro
2026-06-20 09:31:59 -05:00
parent 9bb3f00d86
commit 4baa4ce8c0
346 changed files with 55751 additions and 0 deletions
@@ -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;
}