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
+114
View File
@@ -0,0 +1,114 @@
using Google.Protobuf;
using Wingnal.Service.Account;
using Wingnal.Service.Messaging;
using Wingnal.Service.Protos.Backup;
using BackupContact = Wingnal.Service.Protos.Backup.Contact;
using StoreContact = Wingnal.Service.Account.Contact;
namespace Wingnal.Service.Sync;
/// <summary>
/// Imports a decrypted Signal Backup into Wingnal's stores: 1:1 <see cref="Contact"/> recipients become
/// named contacts (<see cref="ContactsStore"/>) and each text <see cref="ChatItem"/> becomes a stored
/// message (<see cref="MessageStore"/>) in the right peer's thread. Group/story/call frames and
/// non-text message types are skipped (1:1 text history is the goal — see docs/SYNC.md). Pure (no
/// network), so it's offline-testable end-to-end.
/// </summary>
public sealed class BackupImporter
{
private readonly MessageStore _messages;
private readonly ContactsStore _contacts;
private readonly string _ownAci;
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci)
{
_messages = messages;
_contacts = contacts;
_ownAci = ownAci.ToLowerInvariant();
}
public sealed record ImportSummary(int Contacts, int Messages);
/// <summary>One conversational peer resolved from a Recipient frame.</summary>
private sealed record ResolvedPeer(string ServiceId, string? Name);
public ImportSummary Import(BackupContents backup) => Import(backup.Frames);
public ImportSummary Import(IReadOnlyList<Frame> frames)
{
// Pass 1: recipientId -> peer (only 1:1 contacts + self are conversational here).
var recipients = new Dictionary<ulong, ResolvedPeer>();
int contactCount = 0;
foreach (Frame f in frames)
{
if (f.ItemCase != Frame.ItemOneofCase.Recipient) continue;
Recipient r = f.Recipient;
switch (r.DestinationCase)
{
case Recipient.DestinationOneofCase.Self:
recipients[r.Id] = new ResolvedPeer(_ownAci, "Note to Self");
break;
case Recipient.DestinationOneofCase.Contact:
string? aci = AciFromBinary(r.Contact.HasAci ? r.Contact.Aci : ByteString.Empty);
if (aci is null) break; // ACI-less (e164-only) contact: can't key a 1:1 thread
string? name = NameOf(r.Contact);
recipients[r.Id] = new ResolvedPeer(aci, name);
string? number = r.Contact.E164 != 0 ? "+" + r.Contact.E164 : null;
_contacts.Upsert(new StoreContact(aci, number, name, InboxPosition: 0));
contactCount++;
break;
default:
break; // group / distribution list / call link / release notes — not a 1:1 thread
}
}
// Pass 2: chatId -> recipientId.
var chatToRecipient = new Dictionary<ulong, ulong>();
foreach (Frame f in frames)
if (f.ItemCase == Frame.ItemOneofCase.Chat)
chatToRecipient[f.Chat.Id] = f.Chat.RecipientId;
// Pass 3: text chat items -> stored messages. Skip any we've already stored so a re-import (or a
// retried import) doesn't duplicate history.
HashSet<string> existing = _messages.ExistingKeys();
int messageCount = 0;
foreach (Frame f in frames)
{
if (f.ItemCase != Frame.ItemOneofCase.ChatItem) continue;
ChatItem item = f.ChatItem;
if (item.ItemCase != ChatItem.ItemOneofCase.StandardMessage) continue;
string body = item.StandardMessage.Text?.Body ?? "";
if (body.Length == 0) continue;
if (!chatToRecipient.TryGetValue(item.ChatId, out ulong recipientId)) continue;
if (!recipients.TryGetValue(recipientId, out ResolvedPeer? peer)) continue;
bool outgoing = item.DirectionalDetailsCase == ChatItem.DirectionalDetailsOneofCase.Outgoing;
long ts = (long)item.DateSent;
string key = MessageStore.KeyOf(peer.ServiceId, ts, outgoing, body);
if (!existing.Add(key)) continue; // duplicate — already stored
_messages.Add(new StoredMessage(peer.ServiceId, body, ts, outgoing));
messageCount++;
}
return new ImportSummary(contactCount, messageCount);
}
private static string? NameOf(BackupContact c)
{
string? Join(string? given, string? family)
{
string s = string.Join(' ', new[] { given, family }.Where(p => !string.IsNullOrWhiteSpace(p)));
return s.Length == 0 ? null : s;
}
return Join(c.SystemGivenName, c.SystemFamilyName)
?? Join(c.ProfileGivenName, c.ProfileFamilyName)
?? (c.Nickname is { } n ? Join(n.Given, n.Family) : null)
?? (string.IsNullOrWhiteSpace(c.Username) ? null : c.Username);
}
private static string? AciFromBinary(ByteString aci) => ServiceIds.StringFromBinary(aci.Span);
}
+65
View File
@@ -0,0 +1,65 @@
using System.Text;
using Wingnal.Protocol.Crypto;
namespace Wingnal.Service.Sync;
/// <summary>The AES + HMAC keys a Signal Backup (and the link'n'sync transfer archive) is encrypted
/// under, derived from a backup key + backup id. Layout matches libsignal MessageBackupKey: 64 bytes =
/// hmacKey[32] || aesKey[32].</summary>
public sealed record MessageBackupKey(byte[] HmacKey, byte[] AesKey);
/// <summary>
/// Key derivation for Signal Backups, byte-exact with libsignal v0.96.1 (account-keys/src/backup.rs,
/// message-backup/src/key.rs). For link'n'sync the 32-byte <c>ephemeralBackupKey</c> from the
/// ProvisionMessage IS the BackupKey; combine it with our ACI to get the message backup key.
/// </summary>
public static class BackupKey
{
// HKDF domain-separation strings (libsignal). expand_multi_info([info, suffix]) == HKDF info = info‖suffix.
private static readonly byte[] BackupKeyInfo = Encoding.ASCII.GetBytes("20240801_SIGNAL_BACKUP_KEY");
private static readonly byte[] BackupIdInfo = Encoding.ASCII.GetBytes("20241024_SIGNAL_BACKUP_ID:");
// OLD_DST — used when there is no backup forward-secrecy token, which is the link'n'sync case.
private static readonly byte[] EncryptMessageBackupInfo =
Encoding.ASCII.GetBytes("20241007_SIGNAL_BACKUP_ENCRYPT_MESSAGE_BACKUP:");
/// <summary>BackupKey from an AccountEntropyPool string (the remote-backup path; also used to
/// validate the chain against libsignal's test vector). Returns 32 bytes.</summary>
public static byte[] FromAccountEntropyPool(string accountEntropyPool) =>
CryptoPrimitives.Hkdf(Encoding.ASCII.GetBytes(accountEntropyPool), salt: null, BackupKeyInfo, 32);
/// <summary>Derives the 16-byte backup id from a 32-byte backup key and the 16-byte ACI (service-id
/// binary form = the raw RFC-4122 UUID bytes).</summary>
public static byte[] DeriveBackupId(byte[] backupKey, byte[] aciServiceIdBinary)
{
if (backupKey.Length != 32) throw new ArgumentException("backup key must be 32 bytes", nameof(backupKey));
if (aciServiceIdBinary.Length != 16) throw new ArgumentException("aci must be 16 bytes", nameof(aciServiceIdBinary));
return CryptoPrimitives.Hkdf(backupKey, salt: null, Concat(BackupIdInfo, aciServiceIdBinary), 16);
}
/// <summary>Derives the message backup key (hmac[32] || aes[32]) from a backup key + backup id.</summary>
public static MessageBackupKey DeriveMessageBackupKey(byte[] backupKey, byte[] backupId)
{
if (backupId.Length != 16) throw new ArgumentException("backup id must be 16 bytes", nameof(backupId));
byte[] material = CryptoPrimitives.Hkdf(backupKey, salt: null, Concat(EncryptMessageBackupInfo, backupId), 64);
return new MessageBackupKey(material.AsSpan(0, 32).ToArray(), material.AsSpan(32, 32).ToArray());
}
/// <summary>Convenience for link'n'sync: ephemeralBackupKey + our ACI → message backup key.</summary>
public static MessageBackupKey ForLinkAndSync(byte[] ephemeralBackupKey, Guid aci)
{
byte[] aciBinary = UuidToRfc4122(aci);
byte[] backupId = DeriveBackupId(ephemeralBackupKey, aciBinary);
return DeriveMessageBackupKey(ephemeralBackupKey, backupId);
}
/// <summary>A UUID's 16 bytes in RFC 4122 / big-endian order (service-id binary form for an ACI).</summary>
public static byte[] UuidToRfc4122(Guid id) => id.ToByteArray(bigEndian: true);
private static byte[] Concat(byte[] a, byte[] b)
{
var r = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, r, 0, a.Length);
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
return r;
}
}
+108
View File
@@ -0,0 +1,108 @@
using System.IO.Compression;
using System.Security.Cryptography;
using Google.Protobuf;
using Wingnal.Protocol.Crypto;
using Wingnal.Service.Protos.Backup;
namespace Wingnal.Service.Sync;
/// <summary>Thrown when a backup file is malformed or fails its HMAC.</summary>
public sealed class InvalidBackupException : Exception
{
public InvalidBackupException(string message) : base(message) { }
}
/// <summary>The parsed contents of a decrypted backup: the header plus every frame.</summary>
public sealed record BackupContents(BackupInfo Info, IReadOnlyList<Frame> Frames);
/// <summary>
/// Reads a Signal Backup / link'n'sync transfer archive. The container is
/// <c>IV[16] || AES-256-CBC(aesKey, IV, gzip(frames)) || HMAC-SHA256(hmacKey, IV||ciphertext)[32]</c>;
/// after MAC-verify + decrypt + PKCS7-unpad + gunzip the plaintext is a varint-delimited stream of a
/// <see cref="BackupInfo"/> header followed by <see cref="Frame"/>s. Byte-exact with libsignal v0.96.1
/// (message-backup/src/frame: mac_read, aes_read, unpad; gzip).
/// </summary>
public static class BackupReader
{
private const int IvLen = 16;
private const int MacLen = 32;
/// <summary>Full read: verify + decrypt + decompress + parse frames.</summary>
public static BackupContents Read(byte[] file, MessageBackupKey key) =>
ReadFrames(Decompress(DecryptContainer(file, key)));
/// <summary>Verifies the HMAC and AES-256-CBC-decrypts (PKCS7) to the gzip-compressed frame stream.</summary>
public static byte[] DecryptContainer(byte[] file, MessageBackupKey key)
{
if (file.Length < IvLen + MacLen)
throw new InvalidBackupException("backup file too short");
int macOffset = file.Length - MacLen;
byte[] theirMac = file.AsSpan(macOffset, MacLen).ToArray();
byte[] ourMac = CryptoPrimitives.HmacSha256(key.HmacKey, file.AsSpan(0, macOffset));
if (!CryptographicOperations.FixedTimeEquals(theirMac, ourMac))
throw new InvalidBackupException("backup HMAC mismatch");
byte[] iv = file.AsSpan(0, IvLen).ToArray();
byte[] ciphertext = file.AsSpan(IvLen, macOffset - IvLen).ToArray();
return CryptoPrimitives.AesCbcDecrypt(key.AesKey, iv, ciphertext);
}
/// <summary>gzip-inflates the decrypted backup payload.</summary>
public static byte[] Decompress(byte[] gzipped)
{
using var input = new MemoryStream(gzipped, writable: false);
using var gz = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gz.CopyTo(output);
return output.ToArray();
}
/// <summary>Parses the decompressed varint-delimited stream: a BackupInfo header then Frames.</summary>
public static BackupContents ReadFrames(byte[] frameStream)
{
using var stream = new MemoryStream(frameStream, writable: false);
BackupInfo info = BackupInfo.Parser.ParseDelimitedFrom(stream)
?? throw new InvalidBackupException("missing BackupInfo header");
var frames = new List<Frame>();
while (stream.Position < stream.Length)
frames.Add(Frame.Parser.ParseDelimitedFrom(stream));
return new BackupContents(info, frames);
}
/// <summary>
/// Builds an encrypted backup container from a decompressed frame stream (for tests / round-trip
/// validation): gzip → AES-256-CBC → prepend IV → append HMAC.
/// </summary>
public static byte[] WriteContainer(byte[] frameStream, MessageBackupKey key, byte[] iv)
{
if (iv.Length != IvLen) throw new ArgumentException("iv must be 16 bytes", nameof(iv));
using var compressed = new MemoryStream();
using (var gz = new GZipStream(compressed, CompressionLevel.Fastest, leaveOpen: true))
gz.Write(frameStream, 0, frameStream.Length);
byte[] ciphertext = CryptoPrimitives.AesCbcEncrypt(key.AesKey, iv, compressed.ToArray());
var withoutMac = new byte[IvLen + ciphertext.Length];
Buffer.BlockCopy(iv, 0, withoutMac, 0, IvLen);
Buffer.BlockCopy(ciphertext, 0, withoutMac, IvLen, ciphertext.Length);
byte[] mac = CryptoPrimitives.HmacSha256(key.HmacKey, withoutMac);
var file = new byte[withoutMac.Length + MacLen];
Buffer.BlockCopy(withoutMac, 0, file, 0, withoutMac.Length);
Buffer.BlockCopy(mac, 0, file, withoutMac.Length, MacLen);
return file;
}
/// <summary>Serializes a BackupInfo + frames into the varint-delimited stream (test helper).</summary>
public static byte[] WriteFrames(BackupInfo info, IEnumerable<Frame> frames)
{
using var ms = new MemoryStream();
info.WriteDelimitedTo(ms);
foreach (Frame f in frames) f.WriteDelimitedTo(ms);
return ms.ToArray();
}
}
@@ -0,0 +1,96 @@
using Wingnal.Service.Account;
using Wingnal.Service.Attachments;
using Wingnal.Service.Diagnostics;
using Wingnal.Service.Messaging;
using Wingnal.Service.Net;
namespace Wingnal.Service.Sync;
/// <summary>
/// Link'n'sync message-history backfill. After a re-link where the primary offered link+sync, the
/// account carries a one-time <c>EphemeralBackupKey</c>. This:
/// <list type="number">
/// <item>long-polls <c>GET /v1/devices/transfer_archive</c> for the descriptor the primary uploads;</item>
/// <item>downloads the encrypted archive from the CDN;</item>
/// <item>derives the <see cref="MessageBackupKey"/> (ephemeralBackupKey + our ACI);</item>
/// <item>decrypts + decompresses + parses it (<see cref="BackupReader"/>); and</item>
/// <item>imports it into the contact/message stores (<see cref="BackupImporter"/>).</item>
/// </list>
/// The crypto + parse + import core is offline-tested; the poll/download is live-only. See docs/SYNC.md.
/// </summary>
public sealed class MessageHistoryImporter
{
private readonly SignalAccount _account;
private readonly SignalRestClient _rest;
private readonly MessageStore _messages;
private readonly ContactsStore _contacts;
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages, ContactsStore contacts)
{
_account = account;
_rest = rest;
_messages = messages;
_contacts = contacts;
}
/// <param name="ShouldRetry">True when the failure was transient (timed out / network / parse error)
/// so the caller should KEEP the ephemeral key and retry on the next launch, rather than losing the
/// one-time chance at history. False when done (imported) or the primary definitively has no archive.</param>
public sealed record Result(bool Imported, BackupImporter.ImportSummary? Summary, string Detail, bool ShouldRetry = false);
/// <summary>Returns whether a backfill is even possible (the primary offered link+sync at link).</summary>
public bool IsAvailable => _account.EphemeralBackupKey is { Length: 32 };
/// <summary>
/// Runs the backfill once. Polls up to <paramref name="maxPolls"/> times (each a long-poll of
/// <paramref name="pollTimeoutSeconds"/>). Returns the import summary; the caller should clear
/// <see cref="SignalAccount.EphemeralBackupKey"/> and persist on success so it doesn't re-run.
/// </summary>
public async Task<Result> ImportAsync(int maxPolls = 12, int pollTimeoutSeconds = 30, CancellationToken ct = default)
{
if (_account.EphemeralBackupKey is not { Length: 32 } ephemeral)
return new Result(false, null, "no ephemeral backup key (link+sync not offered)");
if (!Guid.TryParse(_account.Aci, out Guid aci))
return new Result(false, null, "account ACI is not a valid UUID");
string auth = _account.BasicAuthToken();
TransferArchiveDescriptor? descriptor = null;
try
{
for (int i = 0; i < maxPolls && descriptor is null; i++)
descriptor = await _rest.WaitForTransferArchiveAsync(auth, pollTimeoutSeconds, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// Network hiccup while polling — keep the key and try again next launch.
FileLog.Write($"link'n'sync: poll failed (will retry): {ex.GetType().Name}: {ex.Message}");
return new Result(false, null, "transfer archive poll failed", ShouldRetry: true);
}
if (descriptor is null)
return new Result(false, null, "transfer archive not available (timed out)", ShouldRetry: true);
if (descriptor.IsError || string.IsNullOrEmpty(descriptor.Key))
return new Result(false, null, $"primary reported no archive: {descriptor.Error}", ShouldRetry: false);
try
{
using var downloader = new AttachmentDownloader();
byte[] file = await downloader.DownloadRawAsync((uint)descriptor.Cdn, descriptor.Key!, ct).ConfigureAwait(false);
MessageBackupKey key = BackupKey.ForLinkAndSync(ephemeral, aci);
BackupContents backup = BackupReader.Read(file, key);
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci).Import(backup);
FileLog.Write($"link'n'sync: imported {summary.Messages} message(s), {summary.Contacts} contact(s)");
return new Result(true, summary, $"imported {summary.Messages} messages, {summary.Contacts} contacts");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// Download/decrypt/parse failed — keep the key so a later launch can retry the (still-valid) archive.
FileLog.Write($"link'n'sync: download/import failed (will retry): {ex.GetType().Name}: {ex.Message}");
return new Result(false, null, $"history import failed: {ex.Message}", ShouldRetry: true);
}
}
}