Add project files.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user