Add project files.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
using System.Linq;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Signal protocol store for the linked account, seeded from the persisted <see cref="SignalAccount"/>
|
||||
/// (ACI identity + the signed/kyber prekeys registered at link time). Sessions and learned remote
|
||||
/// identities live in memory for the process lifetime (see SHORTCUTS.md: not yet persisted across runs).
|
||||
/// </summary>
|
||||
public sealed class AccountProtocolStore : ISignalProtocolStore
|
||||
{
|
||||
private readonly IdentityKeyPair _identityKeyPair;
|
||||
private readonly uint _registrationId;
|
||||
private readonly Dictionary<SignalProtocolAddress, IdentityKey> _identities = new();
|
||||
private readonly Dictionary<uint, PreKeyRecord> _preKeys = new();
|
||||
private readonly Dictionary<uint, SignedPreKeyRecord> _signedPreKeys = new();
|
||||
private readonly Dictionary<uint, KyberPreKeyRecord> _kyberPreKeys = new();
|
||||
private readonly Dictionary<SignalProtocolAddress, SessionRecord> _sessions = new();
|
||||
private readonly SignalAccount _account;
|
||||
private readonly Action? _onChanged;
|
||||
|
||||
/// <param name="onChanged">Invoked after a one-time prekey is consumed (removed from
|
||||
/// <see cref="SignalAccount.AciOneTimePreKeys"/>) so the caller can re-persist the account. Null =
|
||||
/// no persistence (tests / ephemeral use).</param>
|
||||
public AccountProtocolStore(SignalAccount account, Action? onChanged = null)
|
||||
{
|
||||
_account = account;
|
||||
_onChanged = onChanged;
|
||||
_identityKeyPair = account.AciIdentityKeyPair;
|
||||
_registrationId = account.AciRegistrationId;
|
||||
|
||||
RegisteredPreKeys pk = account.AciPreKeys;
|
||||
_signedPreKeys[pk.SignedPreKeyId] = new SignedPreKeyRecord(
|
||||
pk.SignedPreKeyId,
|
||||
new ECKeyPair(pk.SignedPreKeyPrivate, pk.SignedPreKeyPublic),
|
||||
pk.SignedPreKeySignature, timestamp: 0);
|
||||
_kyberPreKeys[pk.KyberPreKeyId] = new KyberPreKeyRecord(
|
||||
pk.KyberPreKeyId,
|
||||
new KyberKeyPair(pk.KyberPreKeyPublic, pk.KyberPreKeyPrivate),
|
||||
pk.KyberPreKeySignature, timestamp: 0);
|
||||
|
||||
foreach (OneTimePreKey otp in account.AciOneTimePreKeys)
|
||||
_preKeys[otp.Id] = new PreKeyRecord(otp.Id, new ECKeyPair(otp.Private, otp.Public));
|
||||
}
|
||||
|
||||
public IdentityKeyPair GetIdentityKeyPair() => _identityKeyPair;
|
||||
public uint GetLocalRegistrationId() => _registrationId;
|
||||
|
||||
public bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
bool changed = _identities.TryGetValue(address, out IdentityKey? existing)
|
||||
&& !existing!.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
_identities[address] = identity;
|
||||
return changed;
|
||||
}
|
||||
|
||||
public bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
if (!_identities.TryGetValue(address, out IdentityKey? existing)) return true; // first use
|
||||
return existing!.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
}
|
||||
|
||||
public IdentityKey? GetIdentity(SignalProtocolAddress address) =>
|
||||
_identities.TryGetValue(address, out IdentityKey? id) ? id : null;
|
||||
|
||||
public PreKeyRecord LoadPreKey(uint preKeyId) => _preKeys[preKeyId];
|
||||
public void StorePreKey(uint preKeyId, PreKeyRecord record) => _preKeys[preKeyId] = record;
|
||||
public bool ContainsPreKey(uint preKeyId) => _preKeys.ContainsKey(preKeyId);
|
||||
public void RemovePreKey(uint preKeyId)
|
||||
{
|
||||
_preKeys.Remove(preKeyId);
|
||||
// Persist consumption: a used one-time prekey must never be reused.
|
||||
int removed = _account.AciOneTimePreKeys.RemoveAll(k => k.Id == preKeyId);
|
||||
if (removed > 0) _onChanged?.Invoke();
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord LoadSignedPreKey(uint id) => _signedPreKeys[id];
|
||||
public void StoreSignedPreKey(uint id, SignedPreKeyRecord record) => _signedPreKeys[id] = record;
|
||||
public bool ContainsSignedPreKey(uint id) => _signedPreKeys.ContainsKey(id);
|
||||
|
||||
public KyberPreKeyRecord LoadKyberPreKey(uint id) => _kyberPreKeys[id];
|
||||
public void StoreKyberPreKey(uint id, KyberPreKeyRecord record) => _kyberPreKeys[id] = record;
|
||||
public bool ContainsKyberPreKey(uint id) => _kyberPreKeys.ContainsKey(id);
|
||||
public void MarkKyberPreKeyUsed(uint id) { /* last-resort key: kept */ }
|
||||
|
||||
public SessionRecord LoadSession(SignalProtocolAddress address) =>
|
||||
_sessions.TryGetValue(address, out SessionRecord? record) ? record : new SessionRecord();
|
||||
public bool ContainsSession(SignalProtocolAddress address) => _sessions.ContainsKey(address);
|
||||
public void StoreSession(SignalProtocolAddress address, SessionRecord record) => _sessions[address] = record;
|
||||
public void DeleteSession(SignalProtocolAddress address) => _sessions.Remove(address);
|
||||
|
||||
/// <summary>Device ids of <paramref name="name"/> that already have an established session — used to
|
||||
/// reuse active sessions on send (Sesame) instead of re-fetching a prekey bundle every time.</summary>
|
||||
public IReadOnlyList<uint> GetSubDeviceSessions(string name) =>
|
||||
_sessions.Keys.Where(a => a.Name == name).Select(a => a.DeviceId).ToList();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the linked <see cref="SignalAccount"/> to disk, encrypted at rest with Windows DPAPI
|
||||
/// (per-user). Stored under %LOCALAPPDATA%\Wingnal\account.bin.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class AccountStore
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.Account.v1"u8.ToArray();
|
||||
private readonly string _path;
|
||||
|
||||
public AccountStore(string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
_path = Path.Combine(directory, "account.bin");
|
||||
}
|
||||
|
||||
public bool Exists => File.Exists(_path);
|
||||
|
||||
public void Save(SignalAccount account)
|
||||
{
|
||||
byte[] json = JsonSerializer.SerializeToUtf8Bytes(account);
|
||||
byte[] encrypted = ProtectedData.Protect(json, Entropy, DataProtectionScope.CurrentUser);
|
||||
File.WriteAllBytes(_path, encrypted);
|
||||
}
|
||||
|
||||
public SignalAccount? Load()
|
||||
{
|
||||
if (!Exists)
|
||||
return null;
|
||||
byte[] encrypted = File.ReadAllBytes(_path);
|
||||
byte[] json = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||
return JsonSerializer.Deserialize<SignalAccount>(json);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
if (Exists)
|
||||
File.Delete(_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>A synced contact: ACI + display info, used to name conversations.</summary>
|
||||
public sealed record Contact(string Aci, string? Number, string? Name, int InboxPosition);
|
||||
|
||||
/// <summary>
|
||||
/// SQLite store of contacts learned from a SyncMessage.Contacts blob, so the conversation list can show
|
||||
/// names instead of raw ACIs (%LOCALAPPDATA%\Wingnal\contacts.db). Keyed by ACI. Names + numbers are
|
||||
/// encrypted at rest with <see cref="LocalCipher"/> (so the file can't be read to map an ACI → a real
|
||||
/// person); legacy plaintext rows decrypt-through unchanged.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ContactsStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public ContactsStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "contacts.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
aci TEXT PRIMARY KEY,
|
||||
number TEXT,
|
||||
name TEXT,
|
||||
inboxPosition INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Upsert(Contact contact)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO contacts (aci, number, name, inboxPosition) VALUES ($aci, $number, $name, $pos)
|
||||
ON CONFLICT(aci) DO UPDATE SET number = $number, name = $name, inboxPosition = $pos;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$aci", contact.Aci);
|
||||
cmd.Parameters.AddWithValue("$number", Enc(contact.Number));
|
||||
cmd.Parameters.AddWithValue("$name", Enc(contact.Name));
|
||||
cmd.Parameters.AddWithValue("$pos", contact.InboxPosition);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public string? NameFor(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM contacts WHERE aci = $aci;";
|
||||
cmd.Parameters.AddWithValue("$aci", aci);
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>Contacts whose name or number contains <paramref name="query"/> (empty = all named),
|
||||
/// ordered by inbox position then name. Filtered in memory because the columns are encrypted.</summary>
|
||||
public IReadOnlyList<Contact> Search(string? query, int limit = 25)
|
||||
{
|
||||
string q = (query ?? string.Empty).Trim();
|
||||
IEnumerable<Contact> contacts = All();
|
||||
contacts = q.Length == 0
|
||||
? contacts.Where(c => !string.IsNullOrEmpty(c.Name))
|
||||
: contacts.Where(c =>
|
||||
(c.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(c.Number?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
return contacts.OrderBy(c => c.InboxPosition).ThenBy(c => c.Name).Take(limit).ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<Contact> All()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT aci, number, name, inboxPosition FROM contacts ORDER BY inboxPosition;";
|
||||
var result = new List<Contact>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
result.Add(new Contact(
|
||||
reader.GetString(0),
|
||||
reader.IsDBNull(1) ? null : _cipher.Unprotect(reader.GetString(1)),
|
||||
reader.IsDBNull(2) ? null : _cipher.Unprotect(reader.GetString(2)),
|
||||
reader.GetInt32(3)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private object Enc(string? value) => value is null ? DBNull.Value : _cipher.Protect(value);
|
||||
|
||||
/// <summary>Removes all synced contacts (used when unlinking).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM contacts;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts sensitive local-database fields at rest (message bodies, contact names) with AES-256-GCM
|
||||
/// under a per-install key. The key itself is a random 32 bytes wrapped with Windows DPAPI
|
||||
/// (CurrentUser) on disk, so the SQLite files no longer contain plaintext content. Also provides a
|
||||
/// deterministic keyed MAC for fields that must remain queryable/dedupable.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class LocalCipher
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.LocalCipher.v1"u8.ToArray();
|
||||
private readonly byte[] _key; // 32 bytes
|
||||
|
||||
public LocalCipher(byte[] key)
|
||||
{
|
||||
if (key.Length != 32) throw new ArgumentException("key must be 32 bytes", nameof(key));
|
||||
_key = key;
|
||||
}
|
||||
|
||||
/// <summary>Loads (or creates) the DPAPI-wrapped per-install key at %LOCALAPPDATA%\Wingnal\local.key.</summary>
|
||||
public static LocalCipher Default(string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
string keyPath = Path.Combine(directory, "local.key");
|
||||
|
||||
byte[] key;
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
key = ProtectedData.Unprotect(File.ReadAllBytes(keyPath), Entropy, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
else
|
||||
{
|
||||
key = RandomNumberGenerator.GetBytes(32);
|
||||
File.WriteAllBytes(keyPath, ProtectedData.Protect(key, Entropy, DataProtectionScope.CurrentUser));
|
||||
}
|
||||
return new LocalCipher(key);
|
||||
}
|
||||
|
||||
/// <summary>Encrypts a string to base64( nonce[12] ‖ ciphertext ‖ tag[16] ).</summary>
|
||||
public string Protect(string plaintext)
|
||||
{
|
||||
byte[] nonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] ctAndTag = CryptoPrimitives.AesGcmEncrypt(_key, nonce, Encoding.UTF8.GetBytes(plaintext));
|
||||
var blob = new byte[nonce.Length + ctAndTag.Length];
|
||||
Buffer.BlockCopy(nonce, 0, blob, 0, nonce.Length);
|
||||
Buffer.BlockCopy(ctAndTag, 0, blob, nonce.Length, ctAndTag.Length);
|
||||
return Convert.ToBase64String(blob);
|
||||
}
|
||||
|
||||
/// <summary>Inverse of <see cref="Protect"/>. If <paramref name="stored"/> isn't a value we wrote
|
||||
/// (e.g. a legacy plaintext row from before encryption), it's returned unchanged.</summary>
|
||||
public string Unprotect(string stored)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] blob = Convert.FromBase64String(stored);
|
||||
if (blob.Length < 12 + 16) return stored;
|
||||
byte[] nonce = blob.AsSpan(0, 12).ToArray();
|
||||
byte[] ctAndTag = blob.AsSpan(12).ToArray();
|
||||
return Encoding.UTF8.GetString(CryptoPrimitives.AesGcmDecrypt(_key, nonce, ctAndTag));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return stored; // legacy plaintext or not ours — leave as-is
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic keyed identity of a value, for dedup/index columns (hex HMAC-SHA256).</summary>
|
||||
public string Mac(string value) =>
|
||||
Convert.ToHexString(CryptoPrimitives.HmacSha256(_key, Encoding.UTF8.GetBytes(value)));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Stores peers' profile keys (learned from the <c>profileKey</c> field of their inbound DataMessages),
|
||||
/// so we can derive their unidentified-access key and send them sealed-sender (metadata-minimized)
|
||||
/// messages. Profile keys are encrypted at rest with <see cref="LocalCipher"/>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileKeyStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public ProfileKeyStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "profilekeys.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS profile_keys (aci TEXT PRIMARY KEY, key TEXT NOT NULL);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Store(string aci, byte[] profileKey)
|
||||
{
|
||||
if (profileKey.Length != 32) return;
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT INTO profile_keys (aci, key) VALUES ($a, $k) ON CONFLICT(aci) DO UPDATE SET key = $k;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
cmd.Parameters.AddWithValue("$k", _cipher.Protect(Convert.ToBase64String(profileKey)));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public byte[]? Get(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT key FROM profile_keys WHERE aci = $a;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
if (cmd.ExecuteScalar() is not string enc) return null;
|
||||
try { return Convert.FromBase64String(_cipher.Unprotect(enc)); } catch { return null; }
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM profile_keys;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Everything this linked secondary device needs to authenticate to Signal and run the protocol:
|
||||
/// account identifiers, the device id/password assigned at link time, registration ids, and the ACI
|
||||
/// and PNI identity key pairs (received from the primary during provisioning).
|
||||
/// </summary>
|
||||
public sealed class SignalAccount
|
||||
{
|
||||
public required string Aci { get; init; }
|
||||
public required string Pni { get; init; }
|
||||
public required string Number { get; init; }
|
||||
public required int DeviceId { get; set; }
|
||||
public required string Password { get; init; }
|
||||
public required uint AciRegistrationId { get; init; }
|
||||
public required uint PniRegistrationId { get; init; }
|
||||
|
||||
/// <summary>33-byte serialized DjbECPublicKey.</summary>
|
||||
public required byte[] AciIdentityPublic { get; init; }
|
||||
public required byte[] AciIdentityPrivate { get; init; }
|
||||
public required byte[] PniIdentityPublic { get; init; }
|
||||
public required byte[] PniIdentityPrivate { get; init; }
|
||||
public required byte[] ProfileKey { get; init; }
|
||||
|
||||
/// <summary>Signed + last-resort kyber prekeys registered at link time (ACI identity).</summary>
|
||||
public required RegisteredPreKeys AciPreKeys { get; init; }
|
||||
|
||||
/// <summary>Signed + last-resort kyber prekeys registered at link time (PNI identity).</summary>
|
||||
public required RegisteredPreKeys PniPreKeys { get; init; }
|
||||
|
||||
/// <summary>Unused one-time EC prekeys uploaded for the ACI identity (consumed as inbound sessions
|
||||
/// use them; removed on consumption and re-persisted). Not <c>required</c> so older account.bin
|
||||
/// files (without this field) still deserialize as empty.</summary>
|
||||
public List<OneTimePreKey> AciOneTimePreKeys { get; set; } = new();
|
||||
|
||||
/// <summary>The one-time 32-byte link'n'sync backup key the primary sent in the ProvisionMessage,
|
||||
/// used to decrypt the message-history transfer archive once. Cleared after a successful import (or
|
||||
/// null when the primary didn't offer link+sync). Not <c>required</c> for back-compat.</summary>
|
||||
public byte[]? EphemeralBackupKey { get; set; }
|
||||
|
||||
public IdentityKeyPair AciIdentityKeyPair =>
|
||||
new(IdentityKey.Decode(AciIdentityPublic), AciIdentityPrivate);
|
||||
|
||||
public IdentityKeyPair PniIdentityKeyPair =>
|
||||
new(IdentityKey.Decode(PniIdentityPublic), PniIdentityPrivate);
|
||||
|
||||
/// <summary>HTTP Basic auth value (without the "Basic " prefix) for the authenticated chat API.</summary>
|
||||
public string BasicAuthToken()
|
||||
{
|
||||
string user = DeviceId == 1 ? Aci : $"{Aci}.{DeviceId}";
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{Password}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The durable private material for the prekeys this device registered for one identity.</summary>
|
||||
public sealed class RegisteredPreKeys
|
||||
{
|
||||
public required uint SignedPreKeyId { get; init; }
|
||||
public required byte[] SignedPreKeyPublic { get; init; } // raw 32
|
||||
public required byte[] SignedPreKeyPrivate { get; init; } // raw 32
|
||||
public required byte[] SignedPreKeySignature { get; init; }
|
||||
public required uint KyberPreKeyId { get; init; }
|
||||
public required byte[] KyberPreKeyPublic { get; init; } // ML-KEM encoded
|
||||
public required byte[] KyberPreKeyPrivate { get; init; }
|
||||
public required byte[] KyberPreKeySignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One unused one-time prekey's durable material (id + raw 32-byte EC key pair).</summary>
|
||||
public sealed class OneTimePreKey
|
||||
{
|
||||
public uint Id { get; set; }
|
||||
public byte[] Public { get; set; } = Array.Empty<byte>(); // raw 32
|
||||
public byte[] Private { get; set; } = Array.Empty<byte>(); // raw 32
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Durable <see cref="ISenderKeyStore"/>: persists group sender-key records to SQLite, keyed by
|
||||
/// (sender address, distribution id), so an established group chain survives an app restart — mirroring
|
||||
/// how <see cref="SqliteSignalProtocolStore"/> persists 1:1 sessions. Each record blob is DPAPI-protected
|
||||
/// at rest. State serialization is local-only (never sent to a peer).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class SqliteSenderKeyStore : ISenderKeyStore
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.SenderKeyStore.v1"u8.ToArray();
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public SqliteSenderKeyStore(string dbFileName = "senderkeys.db", string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
_connectionString = $"Data Source={Path.Combine(directory, dbFileName)}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sender_keys (
|
||||
name TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
distribution TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
PRIMARY KEY (name, device, distribution));
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT OR REPLACE INTO sender_keys (name, device, distribution, data) VALUES ($n, $d, $g, $data);";
|
||||
cmd.Parameters.AddWithValue("$n", sender.Name);
|
||||
cmd.Parameters.AddWithValue("$d", sender.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$g", distributionId.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("$data", Protect(record.Serialize()));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT data FROM sender_keys WHERE name = $n AND device = $d AND distribution = $g;";
|
||||
cmd.Parameters.AddWithValue("$n", sender.Name);
|
||||
cmd.Parameters.AddWithValue("$d", sender.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$g", distributionId.ToString("D"));
|
||||
return cmd.ExecuteScalar() is byte[] data ? SenderKeyRecord.Deserialize(Unprotect(data)) : null;
|
||||
}
|
||||
|
||||
/// <summary>Wipes all sender keys (used when unlinking, alongside the session/message wipe).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM sender_keys;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static byte[] Protect(byte[] plain) => ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
|
||||
private static byte[] Unprotect(byte[] enc) => ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Durable Signal protocol store: identity + signed/kyber/one-time prekeys are seeded from the
|
||||
/// persisted <see cref="SignalAccount"/> (same as <see cref="AccountProtocolStore"/>), while sessions
|
||||
/// and learned remote identities persist to a SQLite DB so conversations survive app restarts. Each
|
||||
/// stored blob is DPAPI-protected at rest. One-time prekey consumption re-persists the account via the
|
||||
/// <c>onChanged</c> callback.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class SqliteSignalProtocolStore : ISignalProtocolStore
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.ProtocolStore.v1"u8.ToArray();
|
||||
|
||||
private readonly SignalAccount _account;
|
||||
private readonly Action? _onChanged;
|
||||
private readonly IdentityKeyPair _identityKeyPair;
|
||||
private readonly uint _registrationId;
|
||||
private readonly Dictionary<uint, PreKeyRecord> _preKeys = new();
|
||||
private readonly Dictionary<uint, SignedPreKeyRecord> _signedPreKeys = new();
|
||||
private readonly Dictionary<uint, KyberPreKeyRecord> _kyberPreKeys = new();
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <param name="dbFileName">Distinct file lets send/receive keep separate session spaces.</param>
|
||||
public SqliteSignalProtocolStore(SignalAccount account, string dbFileName = "protocol.db",
|
||||
Action? onChanged = null, string? directory = null)
|
||||
{
|
||||
_account = account;
|
||||
_onChanged = onChanged;
|
||||
_identityKeyPair = account.AciIdentityKeyPair;
|
||||
_registrationId = account.AciRegistrationId;
|
||||
|
||||
RegisteredPreKeys pk = account.AciPreKeys;
|
||||
_signedPreKeys[pk.SignedPreKeyId] = new SignedPreKeyRecord(
|
||||
pk.SignedPreKeyId, new ECKeyPair(pk.SignedPreKeyPrivate, pk.SignedPreKeyPublic), pk.SignedPreKeySignature, 0);
|
||||
_kyberPreKeys[pk.KyberPreKeyId] = new KyberPreKeyRecord(
|
||||
pk.KyberPreKeyId, new KyberKeyPair(pk.KyberPreKeyPublic, pk.KyberPreKeyPrivate), pk.KyberPreKeySignature, 0);
|
||||
foreach (OneTimePreKey otp in account.AciOneTimePreKeys)
|
||||
_preKeys[otp.Id] = new PreKeyRecord(otp.Id, new ECKeyPair(otp.Private, otp.Public));
|
||||
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
_connectionString = $"Data Source={Path.Combine(directory, dbFileName)}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, device INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (name, device));
|
||||
CREATE TABLE IF NOT EXISTS identities (name TEXT NOT NULL, device INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (name, device));
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── identities ──
|
||||
public IdentityKeyPair GetIdentityKeyPair() => _identityKeyPair;
|
||||
public uint GetLocalRegistrationId() => _registrationId;
|
||||
|
||||
public bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
IdentityKey? existing = GetIdentity(address);
|
||||
bool changed = existing is not null && !existing.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
Upsert("identities", address, Protect(identity.PublicKey));
|
||||
return changed;
|
||||
}
|
||||
|
||||
public bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
IdentityKey? existing = GetIdentity(address);
|
||||
return existing is null || existing.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
}
|
||||
|
||||
public IdentityKey? GetIdentity(SignalProtocolAddress address)
|
||||
{
|
||||
byte[]? data = Load("identities", address);
|
||||
return data is null ? null : new IdentityKey(Unprotect(data));
|
||||
}
|
||||
|
||||
// ── prekeys (from account; consumption re-persists the account) ──
|
||||
public PreKeyRecord LoadPreKey(uint preKeyId) => _preKeys[preKeyId];
|
||||
public void StorePreKey(uint preKeyId, PreKeyRecord record) => _preKeys[preKeyId] = record;
|
||||
public bool ContainsPreKey(uint preKeyId) => _preKeys.ContainsKey(preKeyId);
|
||||
public void RemovePreKey(uint preKeyId)
|
||||
{
|
||||
_preKeys.Remove(preKeyId);
|
||||
if (_account.AciOneTimePreKeys.RemoveAll(k => k.Id == preKeyId) > 0) _onChanged?.Invoke();
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord LoadSignedPreKey(uint id) => _signedPreKeys[id];
|
||||
public void StoreSignedPreKey(uint id, SignedPreKeyRecord record) => _signedPreKeys[id] = record;
|
||||
public bool ContainsSignedPreKey(uint id) => _signedPreKeys.ContainsKey(id);
|
||||
|
||||
public KyberPreKeyRecord LoadKyberPreKey(uint id) => _kyberPreKeys[id];
|
||||
public void StoreKyberPreKey(uint id, KyberPreKeyRecord record) => _kyberPreKeys[id] = record;
|
||||
public bool ContainsKyberPreKey(uint id) => _kyberPreKeys.ContainsKey(id);
|
||||
public void MarkKyberPreKeyUsed(uint id) { /* last-resort key: kept */ }
|
||||
|
||||
// ── sessions (durable) ──
|
||||
public SessionRecord LoadSession(SignalProtocolAddress address)
|
||||
{
|
||||
byte[]? data = Load("sessions", address);
|
||||
return data is null ? new SessionRecord() : SessionRecord.Deserialize(Unprotect(data));
|
||||
}
|
||||
|
||||
public bool ContainsSession(SignalProtocolAddress address) => Load("sessions", address) is not null;
|
||||
public void StoreSession(SignalProtocolAddress address, SessionRecord record) =>
|
||||
Upsert("sessions", address, Protect(record.Serialize()));
|
||||
|
||||
public void DeleteSession(SignalProtocolAddress address)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM sessions WHERE name = $n AND device = $d;";
|
||||
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> GetSubDeviceSessions(string name)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT device FROM sessions WHERE name = $n;";
|
||||
cmd.Parameters.AddWithValue("$n", name);
|
||||
var result = new List<uint>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read()) result.Add((uint)reader.GetInt64(0));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Forgets a peer's learned identities + sessions across all their devices. Used to APPROVE
|
||||
/// a changed identity: after this, the next session re-establishes trust-on-first-use with the new
|
||||
/// key (and the dead sessions tied to the old key are dropped).</summary>
|
||||
public void ResetPeer(string name)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM identities WHERE name = $n; DELETE FROM sessions WHERE name = $n;";
|
||||
cmd.Parameters.AddWithValue("$n", name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Wipes all sessions + learned identities (used when unlinking, so a re-link starts fresh
|
||||
/// and never reuses sessions tied to the old identity keys).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM sessions; DELETE FROM identities;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
private void Upsert(string table, SignalProtocolAddress address, byte[] data)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"INSERT OR REPLACE INTO {table} (name, device, data) VALUES ($n, $d, $data);";
|
||||
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private byte[]? Load(string table, SignalProtocolAddress address)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT data FROM {table} WHERE name = $n AND device = $d;";
|
||||
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
private static byte[] Protect(byte[] plain) => ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
|
||||
private static byte[] Unprotect(byte[] enc) => ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a linked device's display name to the account's identity key, matching Signal's
|
||||
/// DeviceNameCipher: ECDH to an ephemeral key, HMAC-derived synthetic IV + cipher key, then
|
||||
/// AES-256-CTR. The primary device decrypts this to label us in its linked-devices list.
|
||||
/// </summary>
|
||||
public static class DeviceNameCipher
|
||||
{
|
||||
public static byte[] EncryptDeviceName(string deviceName, IdentityKeyPair identityKeyPair)
|
||||
{
|
||||
byte[] plaintext = Encoding.UTF8.GetBytes(deviceName);
|
||||
ECKeyPair ephemeral = Curve25519.GenerateKeyPair();
|
||||
byte[] masterSecret = Curve25519.CalculateAgreement(identityKeyPair.PublicKey.PublicKey, ephemeral.PrivateKey);
|
||||
|
||||
byte[] syntheticIv = ComputeSyntheticIv(masterSecret, plaintext);
|
||||
byte[] cipherKey = ComputeCipherKey(masterSecret, syntheticIv);
|
||||
// Signal encrypts with a zero CTR IV; the syntheticIv only derives the key and is sent in the proto.
|
||||
byte[] ciphertext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], plaintext);
|
||||
|
||||
var message = new DeviceName
|
||||
{
|
||||
EphemeralPublic = ByteString.CopyFrom(Curve25519.EncodePoint(ephemeral.PublicKey)),
|
||||
SyntheticIv = ByteString.CopyFrom(syntheticIv),
|
||||
Ciphertext = ByteString.CopyFrom(ciphertext),
|
||||
};
|
||||
return message.ToByteArray();
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a serialized DeviceName proto with the identity key, mirroring Signal's
|
||||
/// decrypt (re-derives and verifies the synthetic IV). Returns null if undecryptable/tampered.</summary>
|
||||
public static string? DecryptDeviceName(byte[] serialized, IdentityKeyPair identityKeyPair)
|
||||
{
|
||||
DeviceName message = DeviceName.Parser.ParseFrom(serialized);
|
||||
if (message.EphemeralPublic.IsEmpty || message.SyntheticIv.IsEmpty || message.Ciphertext.IsEmpty)
|
||||
return null;
|
||||
|
||||
byte[] ephemeralPublic = Curve25519.DecodePoint(message.EphemeralPublic.Span);
|
||||
byte[] masterSecret = Curve25519.CalculateAgreement(ephemeralPublic, identityKeyPair.PrivateKey);
|
||||
byte[] syntheticIv = message.SyntheticIv.ToByteArray();
|
||||
|
||||
byte[] cipherKey = ComputeCipherKey(masterSecret, syntheticIv);
|
||||
byte[] plaintext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], message.Ciphertext.ToByteArray());
|
||||
|
||||
byte[] expectedIv = ComputeSyntheticIv(masterSecret, plaintext);
|
||||
if (!CryptographicOperations.FixedTimeEquals(expectedIv, syntheticIv))
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
private static byte[] ComputeSyntheticIv(byte[] masterSecret, byte[] plaintext)
|
||||
{
|
||||
byte[] keyMaterial = CryptoPrimitives.HmacSha256(masterSecret, "auth"u8);
|
||||
byte[] mac = CryptoPrimitives.HmacSha256(keyMaterial, plaintext);
|
||||
return mac[..16];
|
||||
}
|
||||
|
||||
private static byte[] ComputeCipherKey(byte[] masterSecret, byte[] syntheticIv)
|
||||
{
|
||||
byte[] keyMaterial = CryptoPrimitives.HmacSha256(masterSecret, "cipher"u8);
|
||||
return CryptoPrimitives.HmacSha256(keyMaterial, syntheticIv);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Wingnal.Protocol.Crypto;
|
||||
|
||||
namespace Wingnal.Service.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Derives a recipient's "unidentified access key" from their profile key, used to send sealed-sender
|
||||
/// (metadata-minimized) messages without our own auth credentials. Matches Signal-Android
|
||||
/// <c>UnidentifiedAccess.deriveAccessKeyFrom</c>: the first 16 bytes of AES-256-GCM(profileKey, iv=0¹²,
|
||||
/// plaintext=0¹⁶).
|
||||
/// </summary>
|
||||
public static class UnidentifiedAccess
|
||||
{
|
||||
public static byte[] DeriveAccessKey(byte[] profileKey)
|
||||
{
|
||||
if (profileKey.Length != 32) throw new ArgumentException("profile key must be 32 bytes", nameof(profileKey));
|
||||
byte[] ctAndTag = CryptoPrimitives.AesGcmEncrypt(profileKey, new byte[12], new byte[16]);
|
||||
return ctAndTag.AsSpan(0, 16).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Wingnal.Service.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Dead-simple append-only log to %LOCALAPPDATA%\Wingnal\wingnal.log for diagnosing live behavior
|
||||
/// (we can't attach a debugger to the deployed app easily). Best-effort; never throws.
|
||||
/// </summary>
|
||||
public static class FileLog
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly string Path = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal", "wingnal.log");
|
||||
|
||||
public static string LogPath => Path;
|
||||
|
||||
/// <summary>Dumps raw bytes next to the log for offline analysis (e.g. an undecryptable envelope).</summary>
|
||||
public static void Dump(string fileName, byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
string dir = System.IO.Path.GetDirectoryName(Path)!;
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(System.IO.Path.Combine(dir, fileName), bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
string line = $"{DateTime.Now:HH:mm:ss.fff} {message}{Environment.NewLine}";
|
||||
lock (Gate)
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
|
||||
File.AppendAllText(Path, line);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging must never break the app.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Wingnal.Service.Groups;
|
||||
|
||||
/// <summary>A group's state after decrypting the storage-service <c>Group</c> blob with the group's
|
||||
/// secret params: plaintext title/roster/revision the app can render.</summary>
|
||||
public sealed record DecryptedGroup(
|
||||
string Title,
|
||||
string? Description,
|
||||
uint Revision,
|
||||
IReadOnlyList<DecryptedGroupMember> Members);
|
||||
|
||||
/// <summary>A decrypted group member: their service id (lowercase UUID string), role, and join revision.</summary>
|
||||
public sealed record DecryptedGroupMember(string ServiceId, bool IsPni, GroupMemberRole Role, uint JoinedAtRevision);
|
||||
|
||||
public enum GroupMemberRole { Unknown = 0, Default = 1, Administrator = 2 }
|
||||
@@ -0,0 +1,68 @@
|
||||
using Wingnal.Protocol.ZkGroup;
|
||||
using Wingnal.Service.Protos.Groups;
|
||||
|
||||
namespace Wingnal.Service.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a storage-service <c>GroupChange.Actions</c> delta to a local <see cref="DecryptedGroup"/>,
|
||||
/// decrypting the change's encrypted member/title fields with the group's secret params. Handles the core
|
||||
/// membership/attribute actions (add/remove/promote members, modify title/description); other action kinds
|
||||
/// (pending/requesting/banned members, access control, timer) are recognised and skipped for now. The read
|
||||
/// half of Phase F — incremental reconciliation of <c>GET /v2/groups/logs</c>.
|
||||
///
|
||||
/// NOTE: server-signature verification of each change is a separate step (<see cref="GroupSignatureVerifier"/>)
|
||||
/// that needs Signal's published server sig key; callers should verify BEFORE applying.
|
||||
/// </summary>
|
||||
public static class GroupChangeApplier
|
||||
{
|
||||
public static DecryptedGroup Apply(DecryptedGroup current, GroupChange.Types.Actions actions, GroupSecretParams gsp)
|
||||
{
|
||||
var members = new List<DecryptedGroupMember>(current.Members);
|
||||
string title = current.Title;
|
||||
string? description = current.Description;
|
||||
uint revision = actions.Version;
|
||||
|
||||
foreach (GroupChange.Types.Actions.Types.AddMemberAction add in actions.AddMembers)
|
||||
{
|
||||
if (add.Added is not { } m) continue;
|
||||
string id = DecryptId(m.UserId.Span, gsp);
|
||||
members.RemoveAll(x => x.ServiceId == id);
|
||||
members.Add(new DecryptedGroupMember(id, IsPni(id), (GroupMemberRole)(int)m.Role, revision));
|
||||
}
|
||||
|
||||
foreach (GroupChange.Types.Actions.Types.DeleteMemberAction del in actions.DeleteMembers)
|
||||
{
|
||||
string id = DecryptId(del.DeletedUserId.Span, gsp);
|
||||
members.RemoveAll(x => x.ServiceId == id);
|
||||
}
|
||||
|
||||
foreach (GroupChange.Types.Actions.Types.ModifyMemberRoleAction mod in actions.ModifyMemberRoles)
|
||||
{
|
||||
string id = DecryptId(mod.UserId.Span, gsp);
|
||||
for (int i = 0; i < members.Count; i++)
|
||||
if (members[i].ServiceId == id)
|
||||
members[i] = members[i] with { Role = (GroupMemberRole)(int)mod.Role };
|
||||
}
|
||||
|
||||
// Promoting a pending/requesting member adds them (their userId comes from the presentation/userId field).
|
||||
foreach (var promo in actions.PromoteMembersPendingProfileKey)
|
||||
{
|
||||
if (promo.UserId.IsEmpty) continue; // userId is set in newer change epochs
|
||||
string id = DecryptId(promo.UserId.Span, gsp);
|
||||
if (members.All(x => x.ServiceId != id))
|
||||
members.Add(new DecryptedGroupMember(id, IsPni(id), GroupMemberRole.Default, revision));
|
||||
}
|
||||
|
||||
if (actions.ModifyTitle is { } mt && !mt.Title.IsEmpty)
|
||||
title = GroupStateCodec.DecryptBlobTitle(mt.Title.ToByteArray(), gsp);
|
||||
if (actions.ModifyDescription is { } md && !md.Description.IsEmpty)
|
||||
description = GroupStateCodec.DecryptBlobDescription(md.Description.ToByteArray(), gsp);
|
||||
|
||||
return current with { Title = title, Description = description, Revision = revision, Members = members };
|
||||
}
|
||||
|
||||
private static string DecryptId(ReadOnlySpan<byte> uuidCiphertext, GroupSecretParams gsp) =>
|
||||
GroupStateCodec.ServiceIdString(gsp.DecryptServiceId(UuidCiphertext.Deserialize(uuidCiphertext)));
|
||||
|
||||
private static bool IsPni(string serviceId) => serviceId.StartsWith("PNI:", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Wingnal.Protocol.ZkGroup.Curve;
|
||||
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||
using Wingnal.Service.Protos.Groups;
|
||||
|
||||
namespace Wingnal.Service.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the server's signature on a <c>GroupChange</c> (the storage service signs the serialized
|
||||
/// <c>actions</c> with its sig key so a client can trust a change it didn't author). Callers must verify
|
||||
/// BEFORE applying a change (<see cref="GroupChangeApplier"/>).
|
||||
///
|
||||
/// The server's sig public key is the <c>sig_public_key</c> field of Signal's published
|
||||
/// <c>ServerPublicParams</c>; obtaining/parsing that production constant is the remaining live-flow step
|
||||
/// (see SHORTCUTS.md). This method takes the already-parsed key so the verification logic itself is testable.
|
||||
/// </summary>
|
||||
public static class GroupSignatureVerifier
|
||||
{
|
||||
public static bool Verify(Ristretto255 serverSigPublicKey, GroupChange change) =>
|
||||
!change.ServerSignature.IsEmpty &&
|
||||
PokshoSignature.Verify(change.ServerSignature.ToByteArray(), serverSigPublicKey, change.Actions.ToByteArray());
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Wingnal.Protocol.ZkGroup;
|
||||
using Wingnal.Service.Protos.Groups;
|
||||
|
||||
namespace Wingnal.Service.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a storage-service <c>Group</c> proto into a plaintext <see cref="DecryptedGroup"/> using the
|
||||
/// group's <see cref="GroupSecretParams"/>: member service ids via the UID ciphertext (Phase D2), the title
|
||||
/// via the AES-256-GCM-SIV attribute blob (Phase D1). All member identity fields on the wire are zkgroup
|
||||
/// ciphertexts, so this is the read half of Phase E. Pure/offline (no network).
|
||||
/// </summary>
|
||||
public static class GroupStateCodec
|
||||
{
|
||||
public static DecryptedGroup Decode(Group group, GroupSecretParams gsp)
|
||||
{
|
||||
string title = DecryptBlobTitle(group.Title.ToByteArray(), gsp);
|
||||
string? description = group.Description.IsEmpty
|
||||
? null : DecryptBlobDescription(group.Description.ToByteArray(), gsp);
|
||||
|
||||
var members = new List<DecryptedGroupMember>(group.Members.Count);
|
||||
foreach (Member m in group.Members)
|
||||
{
|
||||
UuidCiphertext ct = UuidCiphertext.Deserialize(m.UserId.Span);
|
||||
ServiceId sid = gsp.DecryptServiceId(ct);
|
||||
members.Add(new DecryptedGroupMember(
|
||||
ServiceIdString(sid), sid.IsPni, (GroupMemberRole)(int)m.Role, m.JoinedAtVersion));
|
||||
}
|
||||
|
||||
return new DecryptedGroup(title, description, group.Version, members);
|
||||
}
|
||||
|
||||
internal static string DecryptBlobTitle(byte[] encrypted, GroupSecretParams gsp)
|
||||
{
|
||||
if (encrypted.Length == 0) return string.Empty;
|
||||
var blob = GroupAttributeBlob.Parser.ParseFrom(gsp.DecryptBlobWithPadding(encrypted));
|
||||
return blob.ContentCase == GroupAttributeBlob.ContentOneofCase.Title ? blob.Title : string.Empty;
|
||||
}
|
||||
|
||||
internal static string? DecryptBlobDescription(byte[] encrypted, GroupSecretParams gsp)
|
||||
{
|
||||
if (encrypted.Length == 0) return null;
|
||||
var blob = GroupAttributeBlob.Parser.ParseFrom(gsp.DecryptBlobWithPadding(encrypted));
|
||||
return blob.ContentCase == GroupAttributeBlob.ContentOneofCase.DescriptionText ? blob.DescriptionText : null;
|
||||
}
|
||||
|
||||
/// <summary>A zkgroup service id → canonical lowercase UUID string (PNI prefixed with "PNI:").</summary>
|
||||
internal static string ServiceIdString(ServiceId sid)
|
||||
{
|
||||
string uuid = new Guid(sid.RawUuid, bigEndian: true).ToString("D").ToLowerInvariant();
|
||||
return sid.IsPni ? $"PNI:{uuid}" : uuid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Service.Account;
|
||||
|
||||
namespace Wingnal.Service.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the decrypted state of groups the user is in (%LOCALAPPDATA%\Wingnal\groups.db), keyed by the
|
||||
/// lowercase-hex group id. Stores the 32-byte master key (so the group can be re-fetched / re-derived) plus
|
||||
/// the current revision, title, and roster. The master key, title, and roster JSON are encrypted at rest
|
||||
/// with <see cref="LocalCipher"/> (the group id stays plaintext so it can key/route conversations).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class GroupStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public GroupStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "groups.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
group_id TEXT PRIMARY KEY,
|
||||
master_key TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
roster TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Inserts or updates the stored state for a group.</summary>
|
||||
public void Save(string groupId, byte[] masterKey, DecryptedGroup group)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO groups (group_id, master_key, revision, title, roster)
|
||||
VALUES ($id, $mk, $rev, $title, $roster)
|
||||
ON CONFLICT(group_id) DO UPDATE SET master_key = $mk, revision = $rev, title = $title, roster = $roster;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", groupId);
|
||||
cmd.Parameters.AddWithValue("$mk", _cipher.Protect(Convert.ToHexString(masterKey)));
|
||||
cmd.Parameters.AddWithValue("$rev", group.Revision);
|
||||
cmd.Parameters.AddWithValue("$title", _cipher.Protect(group.Title));
|
||||
cmd.Parameters.AddWithValue("$roster", _cipher.Protect(JsonSerializer.Serialize(group.Members)));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Loads a group's state, or null if not present.</summary>
|
||||
public StoredGroup? Load(string groupId)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT master_key, revision, title, roster FROM groups WHERE group_id = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", groupId);
|
||||
using SqliteDataReader r = cmd.ExecuteReader();
|
||||
if (!r.Read()) return null;
|
||||
byte[] masterKey = Convert.FromHexString(_cipher.Unprotect(r.GetString(0)));
|
||||
uint revision = (uint)r.GetInt64(1);
|
||||
string title = _cipher.Unprotect(r.GetString(2));
|
||||
var members = JsonSerializer.Deserialize<List<DecryptedGroupMember>>(_cipher.Unprotect(r.GetString(3)))
|
||||
?? new List<DecryptedGroupMember>();
|
||||
return new StoredGroup(groupId, masterKey, new DecryptedGroup(title, null, revision, members));
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> AllGroupIds()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT group_id FROM groups;";
|
||||
var ids = new List<string>();
|
||||
using SqliteDataReader r = cmd.ExecuteReader();
|
||||
while (r.Read()) ids.Add(r.GetString(0));
|
||||
return ids;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM groups;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A group's persisted state: id, master key, and decrypted roster/title/revision.</summary>
|
||||
public sealed record StoredGroup(string GroupId, byte[] MasterKey, DecryptedGroup Group);
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos.Groups;
|
||||
|
||||
namespace Wingnal.Service.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Signal's group storage service (<c>storage.signal.org</c>, GroupsV2). All requests authenticate
|
||||
/// with a per-call Basic header whose username is hex(GroupPublicParams) and password is
|
||||
/// hex(AuthCredentialWithPni presentation) — the server matches the encrypted member identities without
|
||||
/// learning the caller's ACI. TLS pins to the bundled Signal CA via <see cref="SignalTrust"/>.
|
||||
///
|
||||
/// LIVE-UNTESTED (headless): the actual storage-service round-trip. The request/response shapes and auth
|
||||
/// header follow Signal-Android's <c>PushServiceSocket</c> / <c>GroupsV2AuthorizationString</c>.
|
||||
/// </summary>
|
||||
public sealed class GroupsApiClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public GroupsApiClient(HttpClient? http = null) => _http = http ?? CreatePinnedClient();
|
||||
|
||||
private static HttpClient CreatePinnedClient()
|
||||
{
|
||||
var handler = new SocketsHttpHandler();
|
||||
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||
(sender, cert, chain, errors) => SignalTrust.Validate(sender, cert, chain, errors);
|
||||
return new HttpClient(handler) { BaseAddress = new Uri(SignalServiceConfig.StorageUrl) };
|
||||
}
|
||||
|
||||
/// <summary>The Basic authorization value for a group call: base64(hex(publicParams):hex(presentation)).</summary>
|
||||
public static string AuthHeader(byte[] groupPublicParams, byte[] authPresentation)
|
||||
{
|
||||
string user = Convert.ToHexString(groupPublicParams).ToLowerInvariant();
|
||||
string pass = Convert.ToHexString(authPresentation).ToLowerInvariant();
|
||||
return Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pass}"));
|
||||
}
|
||||
|
||||
/// <summary>GET /v2/groups/ — the current encrypted group state (decode with <see cref="GroupStateCodec"/>).</summary>
|
||||
public async Task<Group> GetGroupAsync(byte[] groupPublicParams, byte[] authPresentation, CancellationToken ct = default)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/v2/groups/");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", AuthHeader(groupPublicParams, authPresentation));
|
||||
using HttpResponseMessage resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
await EnsureOkAsync(resp).ConfigureAwait(false);
|
||||
byte[] body = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
return GroupResponse.Parser.ParseFrom(body).Group;
|
||||
}
|
||||
|
||||
/// <summary>GET /v2/groups/logs/{fromRevision} — incremental changes from a known revision.</summary>
|
||||
public async Task<GroupChanges> GetGroupLogsAsync(byte[] groupPublicParams, byte[] authPresentation,
|
||||
uint fromRevision, uint maxSupportedChangeEpoch = 6, bool includeFirstState = true, CancellationToken ct = default)
|
||||
{
|
||||
string path = $"/v2/groups/logs/{fromRevision}?maxSupportedChangeEpoch={maxSupportedChangeEpoch}" +
|
||||
$"&includeFirstState={includeFirstState.ToString().ToLowerInvariant()}&includeLastState=false";
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", AuthHeader(groupPublicParams, authPresentation));
|
||||
using HttpResponseMessage resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
await EnsureOkAsync(resp).ConfigureAwait(false);
|
||||
byte[] body = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
return GroupChanges.Parser.ParseFrom(body);
|
||||
}
|
||||
|
||||
/// <summary>PATCH /v2/groups/ — apply a group change; returns the server's signed change + new state.</summary>
|
||||
public async Task<GroupChangeResponse> PatchGroupAsync(byte[] groupPublicParams, byte[] authPresentation,
|
||||
GroupChange.Types.Actions actions, CancellationToken ct = default)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Patch, "/v2/groups/")
|
||||
{
|
||||
Content = new ByteArrayContent(actions.ToByteArray())
|
||||
{ Headers = { ContentType = new MediaTypeHeaderValue("application/x-protobuf") } },
|
||||
};
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", AuthHeader(groupPublicParams, authPresentation));
|
||||
using HttpResponseMessage resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
await EnsureOkAsync(resp).ConfigureAwait(false);
|
||||
byte[] body = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
return GroupChangeResponse.Parser.ParseFrom(body);
|
||||
}
|
||||
|
||||
private static async Task EnsureOkAsync(HttpResponseMessage resp)
|
||||
{
|
||||
if (resp.IsSuccessStatusCode) return;
|
||||
string reason = resp.StatusCode == HttpStatusCode.Forbidden
|
||||
? " (403 — credential presentation rejected or not a member)" : "";
|
||||
string body = "";
|
||||
try { body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); } catch { /* ignore */ }
|
||||
throw new GroupsApiException((int)resp.StatusCode, $"group storage request failed: {(int)resp.StatusCode}{reason} {body}");
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A non-success response from the group storage service (403 = rejected presentation / not a member).</summary>
|
||||
public sealed class GroupsApiException : Exception
|
||||
{
|
||||
public int StatusCode { get; }
|
||||
public GroupsApiException(int statusCode, string message) : base(message) => StatusCode = statusCode;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Security.Cryptography;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Keys;
|
||||
|
||||
/// <summary>Generates the prekeys a freshly linked device must register with the server.</summary>
|
||||
public static class PreKeyHelper
|
||||
{
|
||||
/// <summary>A 14-bit Signal registration id (1..16383).</summary>
|
||||
public static uint GenerateRegistrationId() => (uint)RandomNumberGenerator.GetInt32(1, 16384);
|
||||
|
||||
/// <summary>A signed prekey whose public key is signed with the given identity private key.</summary>
|
||||
public static SignedPreKeyRecord GenerateSignedPreKey(byte[] identityPrivateKey, uint id)
|
||||
{
|
||||
ECKeyPair keyPair = Curve25519.GenerateKeyPair();
|
||||
byte[] signature = XEd25519.CalculateSignature(
|
||||
identityPrivateKey, Curve25519.EncodePoint(keyPair.PublicKey), RandomNumberGenerator.GetBytes(64));
|
||||
return new SignedPreKeyRecord(id, keyPair, signature, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
/// <summary>A last-resort ML-KEM prekey signed with the given identity private key.</summary>
|
||||
public static KyberPreKeyRecord GenerateKyberPreKey(byte[] identityPrivateKey, uint id)
|
||||
{
|
||||
KyberKeyPair keyPair = Kyber.GenerateKeyPair();
|
||||
byte[] signature = XEd25519.CalculateSignature(
|
||||
identityPrivateKey, KemKeySerialization.Serialize(keyPair.PublicKey), RandomNumberGenerator.GetBytes(64));
|
||||
return new KyberPreKeyRecord(id, keyPair, signature, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
/// <summary>A batch of one-time prekeys, ids starting at <paramref name="startId"/>.</summary>
|
||||
public static List<PreKeyRecord> GenerateOneTimePreKeys(uint startId, int count)
|
||||
{
|
||||
var result = new List<PreKeyRecord>(count);
|
||||
for (uint i = 0; i < count; i++)
|
||||
result.Add(PreKeyRecord.Generate(startId + i));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Crypto;
|
||||
using Wingnal.Service.Keys;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
using Wingnal.Service.Provisioning;
|
||||
|
||||
namespace Wingnal.Service.Linking;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the full secondary-device link: drive the provisioning handshake, generate this
|
||||
/// device's credentials + prekeys, register with the service, and persist the resulting account.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class LinkingManager
|
||||
{
|
||||
private const uint SignedPreKeyId = 1;
|
||||
private const uint KyberPreKeyId = 1;
|
||||
|
||||
private readonly AccountStore _accountStore;
|
||||
private readonly SignalRestClient _restClient;
|
||||
private readonly string _deviceName;
|
||||
|
||||
public LinkingManager(AccountStore accountStore, SignalRestClient restClient, string deviceName = "Wingnal")
|
||||
{
|
||||
_accountStore = accountStore;
|
||||
_restClient = restClient;
|
||||
_deviceName = deviceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the link flow. <paramref name="onQrReady"/> is invoked with the QR URI to display; the
|
||||
/// returned account is also persisted to the <see cref="AccountStore"/>.
|
||||
/// </summary>
|
||||
public async Task<SignalAccount> LinkAsync(Func<string, Task> onQrReady, CancellationToken ct)
|
||||
{
|
||||
var provisioning = new ProvisioningManager();
|
||||
// Advertise link+sync so the primary offers a message-history transfer archive (docs/SYNC.md).
|
||||
ProvisionMessage message = await provisioning
|
||||
.LinkAsync(onQrReady, ct, new[] { ProvisioningManager.LinkAndSyncCapability })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||
uint aciRegistrationId = PreKeyHelper.GenerateRegistrationId();
|
||||
uint pniRegistrationId = PreKeyHelper.GenerateRegistrationId();
|
||||
|
||||
byte[] aciIdentityPrivate = message.AciIdentityKeyPrivate.ToByteArray();
|
||||
byte[] aciIdentityPublic = message.AciIdentityKeyPublic.ToByteArray();
|
||||
byte[] pniIdentityPrivate = message.PniIdentityKeyPrivate.ToByteArray();
|
||||
byte[] pniIdentityPublic = message.PniIdentityKeyPublic.ToByteArray();
|
||||
|
||||
SignedPreKeyRecord aciSigned = PreKeyHelper.GenerateSignedPreKey(aciIdentityPrivate, SignedPreKeyId);
|
||||
KyberPreKeyRecord aciKyber = PreKeyHelper.GenerateKyberPreKey(aciIdentityPrivate, KyberPreKeyId);
|
||||
SignedPreKeyRecord pniSigned = PreKeyHelper.GenerateSignedPreKey(pniIdentityPrivate, SignedPreKeyId);
|
||||
KyberPreKeyRecord pniKyber = PreKeyHelper.GenerateKyberPreKey(pniIdentityPrivate, KyberPreKeyId);
|
||||
|
||||
byte[] encryptedName = DeviceNameCipher.EncryptDeviceName(
|
||||
_deviceName, new IdentityKeyPair(IdentityKey.Decode(aciIdentityPublic), aciIdentityPrivate));
|
||||
|
||||
var request = new LinkDeviceRequest(
|
||||
VerificationCode: message.ProvisioningCode,
|
||||
AccountAttributes: new AccountAttributes(
|
||||
FetchesMessages: true,
|
||||
RegistrationId: aciRegistrationId,
|
||||
PniRegistrationId: pniRegistrationId,
|
||||
Name: Convert.ToBase64String(encryptedName),
|
||||
Capabilities: new AccountCapabilities()),
|
||||
AciSignedPreKey: ToSignedEntity(aciSigned),
|
||||
PniSignedPreKey: ToSignedEntity(pniSigned),
|
||||
AciPqLastResortPreKey: ToKyberEntity(aciKyber),
|
||||
PniPqLastResortPreKey: ToKyberEntity(pniKyber));
|
||||
|
||||
LinkDeviceResponse response = await _restClient
|
||||
.LinkDeviceAsync(message.Number, password, request, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var account = new SignalAccount
|
||||
{
|
||||
Aci = response.Uuid,
|
||||
Pni = response.Pni,
|
||||
Number = message.Number,
|
||||
DeviceId = response.DeviceId,
|
||||
Password = password,
|
||||
AciRegistrationId = aciRegistrationId,
|
||||
PniRegistrationId = pniRegistrationId,
|
||||
AciIdentityPublic = aciIdentityPublic,
|
||||
AciIdentityPrivate = aciIdentityPrivate,
|
||||
PniIdentityPublic = pniIdentityPublic,
|
||||
PniIdentityPrivate = pniIdentityPrivate,
|
||||
ProfileKey = message.ProfileKey.ToByteArray(),
|
||||
AciPreKeys = ToMaterial(aciSigned, aciKyber),
|
||||
PniPreKeys = ToMaterial(pniSigned, pniKyber),
|
||||
// Present only if the primary accepted link+sync; used once to import message history.
|
||||
EphemeralBackupKey = message.HasEphemeralBackupKey && message.EphemeralBackupKey.Length == 32
|
||||
? message.EphemeralBackupKey.ToByteArray()
|
||||
: null,
|
||||
};
|
||||
|
||||
// Register a batch of one-time prekeys so inbound sessions get per-message forward secrecy
|
||||
// instead of always falling back to the signed prekey. Best-effort: the link already succeeded,
|
||||
// so a failure here just leaves us on the last-resort prekeys (prior behavior).
|
||||
try
|
||||
{
|
||||
List<PreKeyRecord> oneTime = PreKeyHelper.GenerateOneTimePreKeys(startId: 1, count: 100);
|
||||
await _restClient.UploadPreKeysAsync("aci", new SetKeysRequest
|
||||
{
|
||||
PreKeys = oneTime.Select(p => new PreKeyEntity
|
||||
{
|
||||
KeyId = p.Id,
|
||||
PublicKey = Convert.ToBase64String(Curve25519.EncodePoint(p.KeyPair.PublicKey)),
|
||||
}).ToArray(),
|
||||
}, account.BasicAuthToken(), ct).ConfigureAwait(false);
|
||||
|
||||
account.AciOneTimePreKeys = oneTime
|
||||
.Select(p => new OneTimePreKey { Id = p.Id, Public = p.KeyPair.PublicKey, Private = p.KeyPair.PrivateKey })
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-fatal — fall back to last-resort signed/kyber prekeys.
|
||||
}
|
||||
|
||||
_accountStore.Save(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
private static SignedPreKeyEntity ToSignedEntity(SignedPreKeyRecord record) => new(
|
||||
record.Id,
|
||||
Convert.ToBase64String(Curve25519.EncodePoint(record.KeyPair.PublicKey)),
|
||||
Convert.ToBase64String(record.Signature));
|
||||
|
||||
private static KyberPreKeyEntity ToKyberEntity(KyberPreKeyRecord record) => new(
|
||||
record.Id,
|
||||
Convert.ToBase64String(KemKeySerialization.Serialize(record.KeyPair.PublicKey)),
|
||||
Convert.ToBase64String(record.Signature));
|
||||
|
||||
private static RegisteredPreKeys ToMaterial(SignedPreKeyRecord signed, KyberPreKeyRecord kyber) => new()
|
||||
{
|
||||
SignedPreKeyId = signed.Id,
|
||||
SignedPreKeyPublic = signed.KeyPair.PublicKey,
|
||||
SignedPreKeyPrivate = signed.KeyPair.PrivateKey,
|
||||
SignedPreKeySignature = signed.Signature,
|
||||
KyberPreKeyId = kyber.Id,
|
||||
KyberPreKeyPublic = kyber.KeyPair.PublicKey,
|
||||
KyberPreKeyPrivate = kyber.KeyPair.PrivateKey,
|
||||
KyberPreKeySignature = kyber.Signature,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Connects the authenticated chat WebSocket and delivers incoming 1:1 texts. The server pushes
|
||||
/// queued messages as PUT /api/v1/message (Envelope body); we ack each with 200 and answer
|
||||
/// keepalives. Decryption failures are surfaced via <paramref name="onError"/> but still acked so the
|
||||
/// queue drains (see SHORTCUTS.md).
|
||||
/// </summary>
|
||||
public sealed class ChatReceiver
|
||||
{
|
||||
private readonly SignalAccount _account;
|
||||
private readonly MessageDecryptor _decryptor;
|
||||
|
||||
public ChatReceiver(SignalAccount account, ISignalProtocolStore store,
|
||||
Account.ProfileKeyStore? profileKeys = null, ISenderKeyStore? senderKeys = null)
|
||||
{
|
||||
_account = account;
|
||||
_decryptor = new MessageDecryptor(store, profileKeys, senderKeys);
|
||||
}
|
||||
|
||||
public async Task ReceiveAsync(
|
||||
Func<DecryptedMessage, Task> onMessage,
|
||||
Action<Envelope, Exception>? onError,
|
||||
CancellationToken ct,
|
||||
Func<SyncMessage, Task>? onSync = null,
|
||||
Func<string, ReceiptMessage, Task>? onReceipt = null,
|
||||
Func<string, TypingMessage, Task>? onTyping = null)
|
||||
{
|
||||
using var socket = new SignalWebSocket();
|
||||
var uri = new Uri($"{SignalServiceConfig.WebSocketUrl}{SignalServiceConfig.ChatWebSocketPath}");
|
||||
var headers = new Dictionary<string, string> { ["Authorization"] = $"Basic {_account.BasicAuthToken()}" };
|
||||
|
||||
FileLog.Write($"chat: connecting login={_account.Aci}.{_account.DeviceId} (Authorization header)");
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(uri, headers, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"chat: CONNECT FAILED {ex.GetType().Name}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
FileLog.Write("chat: connected (websocket upgrade OK)");
|
||||
|
||||
using var loopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task keepAlive = KeepAliveLoopAsync(socket, loopCts.Token);
|
||||
|
||||
int frames = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
WebSocketRequestMessage? request = await socket.ReadRequestAsync(ct).ConfigureAwait(false);
|
||||
if (request is null)
|
||||
{
|
||||
FileLog.Write($"chat: socket closed after {frames} frame(s). {socket.CloseReason}");
|
||||
break;
|
||||
}
|
||||
|
||||
frames++;
|
||||
bool isMessage = request.Verb == "PUT" && request.Path == "/api/v1/message";
|
||||
FileLog.Write($"chat: frame #{frames} verb={request.Verb} path={request.Path} bodyLen={request.Body?.Length ?? 0}");
|
||||
|
||||
if (!isMessage)
|
||||
{
|
||||
// keepalive, queue-empty, etc. — ack immediately.
|
||||
await socket.SendResponseAsync(request.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
Envelope envelope = Envelope.Parser.ParseFrom(request.Body);
|
||||
FileLog.Write($"chat: envelope type={envelope.Type} from={envelope.SourceServiceId}.{envelope.SourceDeviceId} contentLen={envelope.Content?.Length ?? 0}");
|
||||
try
|
||||
{
|
||||
MessageDecryptor.Result result = _decryptor.DecryptEnvelope(envelope);
|
||||
// Ack only after a successful decrypt, so a message we can't yet handle is redelivered.
|
||||
await socket.SendResponseAsync(request.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Message is { } message)
|
||||
{
|
||||
FileLog.Write($"chat: decrypted text from {message.PeerServiceId} outgoing={message.Outgoing}");
|
||||
await onMessage(message).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (onSync is not null && result.Content?.SyncMessage is { } sync)
|
||||
{
|
||||
FileLog.Write("chat: handling sync message");
|
||||
await onSync(sync).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (onReceipt is not null && result.Content?.ReceiptMessage is { } receipt)
|
||||
{
|
||||
FileLog.Write($"chat: {receipt.Type} receipt from {result.Sender} for {receipt.Timestamp.Count} message(s)");
|
||||
await onReceipt(result.Sender, receipt).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (onTyping is not null && result.Content?.TypingMessage is { } typing)
|
||||
{
|
||||
FileLog.Write($"chat: typing {typing.Action} from {result.Sender}");
|
||||
await onTyping(result.Sender, typing).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.Message is null && result.Content is null)
|
||||
FileLog.Write($"chat: decrypted, nothing surfaced (type={envelope.Type})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Dump($"failed-envelope-{frames}.bin", request.Body!.ToByteArray());
|
||||
FileLog.Write($"chat: DECRYPT FAILED type={envelope.Type} (not acked, will redeliver):{Environment.NewLine}{ex}");
|
||||
onError?.Invoke(envelope, ex);
|
||||
}
|
||||
}
|
||||
|
||||
loopCts.Cancel();
|
||||
try { await keepAlive.ConfigureAwait(false); } catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private static async Task KeepAliveLoopAsync(SignalWebSocket socket, CancellationToken ct)
|
||||
{
|
||||
ulong id = 1;
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
|
||||
await socket.SendKeepAliveAsync(id++, ct).ConfigureAwait(false);
|
||||
FileLog.Write("chat: sent keepalive");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex) { FileLog.Write($"chat: keepalive stopped: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>One parsed contact from a contacts-sync blob: the record plus its inline avatar bytes (if
|
||||
/// any).</summary>
|
||||
public sealed record ContactRecord(ContactDetails Details, byte[]? Avatar);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the decrypted contacts-sync blob. The blob is a flat stream of records, each:
|
||||
/// <c>[varint length][ContactDetails protobuf]</c>, immediately followed by <c>[avatar.length bytes]</c>
|
||||
/// when the record declares an avatar. Mirrors Signal's DeviceContactsInputStream.
|
||||
/// </summary>
|
||||
public static class ContactRecordStream
|
||||
{
|
||||
public static IReadOnlyList<ContactRecord> Parse(byte[] blob)
|
||||
{
|
||||
var result = new List<ContactRecord>();
|
||||
using var stream = new MemoryStream(blob, writable: false);
|
||||
|
||||
while (stream.Position < stream.Length)
|
||||
{
|
||||
ContactDetails details = ContactDetails.Parser.ParseDelimitedFrom(stream);
|
||||
|
||||
byte[]? avatar = null;
|
||||
if (details.Avatar is { } a && a.Length > 0)
|
||||
{
|
||||
avatar = new byte[a.Length];
|
||||
int read = stream.Read(avatar, 0, avatar.Length);
|
||||
if (read != avatar.Length)
|
||||
throw new InvalidDataException("truncated contact avatar in sync blob");
|
||||
}
|
||||
|
||||
result.Add(new ContactRecord(details, avatar));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>A decrypted 1:1 text message (incoming, or a synced transcript of one we sent). When the
|
||||
/// message carried media, <see cref="Attachment"/> is the first attachment pointer (download separately).</summary>
|
||||
public sealed record DecryptedMessage(
|
||||
string PeerServiceId,
|
||||
uint SenderDeviceId,
|
||||
string Body,
|
||||
long Timestamp,
|
||||
bool Outgoing)
|
||||
{
|
||||
public Protos.AttachmentPointer? Attachment { get; init; }
|
||||
|
||||
/// <summary>For a group (GroupsV2) message, the lowercase-hex group identifier this belongs to; null for
|
||||
/// a 1:1 message. When set, the conversation is keyed by the group rather than by <see cref="PeerServiceId"/>.</summary>
|
||||
public string? GroupId { get; init; }
|
||||
|
||||
/// <summary>For a group message, the 32-byte group master key (from GroupContextV2) — persisted so the
|
||||
/// group can later be fetched/decrypted from the storage service.</summary>
|
||||
public byte[]? GroupMasterKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Protocol.ZkGroup;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Phase G1 (receive-only groups): wires the Sender Key messaging primitive into the receive path. Processes
|
||||
/// an inbound Sender Key Distribution Message (so we can decrypt that sender's future group messages),
|
||||
/// decrypts an inbound group Sender Key message to its plaintext <c>Content</c>, and derives the 32-byte
|
||||
/// group identifier from a group master key (to route the message into the right group thread). No zkgroup
|
||||
/// credential machinery is needed to *receive* — only the group-id derivation (<see cref="GroupSecretParams"/>).
|
||||
/// </summary>
|
||||
public sealed class GroupMessageProcessor
|
||||
{
|
||||
private readonly GroupSessionBuilder _builder;
|
||||
private readonly GroupSessionCipher _cipher;
|
||||
|
||||
public GroupMessageProcessor(ISenderKeyStore store)
|
||||
{
|
||||
_builder = new GroupSessionBuilder(store);
|
||||
_cipher = new GroupSessionCipher(store);
|
||||
}
|
||||
|
||||
/// <summary>Installs a sender's distribution (their sender key) so their group messages can be decrypted.</summary>
|
||||
public void ProcessDistribution(SignalProtocolAddress sender, byte[] skdmBytes) =>
|
||||
_builder.Process(sender, SenderKeyDistributionMessage.Parse(skdmBytes));
|
||||
|
||||
/// <summary>Decrypts a received group Sender Key message to its (still padding-wrapped) plaintext.</summary>
|
||||
public byte[] DecryptGroupMessage(SignalProtocolAddress sender, byte[] senderKeyMessageBytes) =>
|
||||
_cipher.Decrypt(sender, SenderKeyMessage.Parse(senderKeyMessageBytes));
|
||||
|
||||
/// <summary>The lowercase-hex group identifier derived from a 32-byte group master key
|
||||
/// (<c>GroupContextV2.masterKey</c>), used to key a group conversation.</summary>
|
||||
public static string GroupIdHex(byte[] masterKey) =>
|
||||
Convert.ToHexString(GroupSecretParams.DeriveFromMasterKey(masterKey).GroupIdentifier).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Phase G2 (send) crypto assembly: builds the one group ciphertext that every member decrypts. The body is
|
||||
/// encrypted ONCE with our group Sender Key (<see cref="GroupSessionCipher"/>); the caller then fans the
|
||||
/// resulting <c>SenderKeyMessage</c> out to each member device wrapped as sealed-sender (type 7), after
|
||||
/// distributing our <c>SenderKeyDistributionMessage</c> (1:1, sealed) to members who don't have our key yet.
|
||||
/// The fan-out/transport itself lives in the live send path; this type is the offline-testable core.
|
||||
/// </summary>
|
||||
public sealed class GroupSendBuilder
|
||||
{
|
||||
private readonly SignalProtocolAddress _self;
|
||||
private readonly Guid _distributionId;
|
||||
private readonly GroupSessionBuilder _builder;
|
||||
private readonly GroupSessionCipher _cipher;
|
||||
|
||||
public GroupSendBuilder(ISenderKeyStore store, SignalProtocolAddress self, Guid distributionId)
|
||||
{
|
||||
_self = self;
|
||||
_distributionId = distributionId;
|
||||
_builder = new GroupSessionBuilder(store);
|
||||
_cipher = new GroupSessionCipher(store);
|
||||
}
|
||||
|
||||
/// <summary>Creates (or recreates) our sender key and returns the distribution message to send 1:1 to
|
||||
/// members so they can decrypt our group messages.</summary>
|
||||
public SenderKeyDistributionMessage CreateDistribution() => _builder.Create(_self, _distributionId);
|
||||
|
||||
/// <summary>Encrypts a group <see cref="Content"/> into the wire <c>SenderKeyMessage</c> bytes that go,
|
||||
/// once, to every member device (the caller attaches the GroupContextV2 to the Content beforehand).</summary>
|
||||
public byte[] EncryptMessage(Content content)
|
||||
{
|
||||
byte[] padded = MessagePadding.Add(content.ToByteArray());
|
||||
return _cipher.Encrypt(_self, _distributionId, padded).Serialize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.Ratchet;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts an unsealed (authenticated) <see cref="Envelope"/> into a 1:1 text. Handles
|
||||
/// PREKEY_MESSAGE (establishes the session) and DOUBLE_RATCHET envelopes; returns null for envelope
|
||||
/// types or content kinds we don't surface yet (receipts, sealed sender, non-text sync, etc.).
|
||||
/// </summary>
|
||||
public sealed class MessageDecryptor
|
||||
{
|
||||
private readonly ISignalProtocolStore _store;
|
||||
private readonly Account.ProfileKeyStore? _profileKeys;
|
||||
private readonly ISenderKeyStore? _senderKeys;
|
||||
|
||||
public MessageDecryptor(ISignalProtocolStore store, Account.ProfileKeyStore? profileKeys = null,
|
||||
ISenderKeyStore? senderKeys = null)
|
||||
{
|
||||
_store = store;
|
||||
_profileKeys = profileKeys;
|
||||
_senderKeys = senderKeys;
|
||||
}
|
||||
|
||||
/// <summary>The full result of decrypting an envelope: the sender, the parsed <see cref="Content"/>
|
||||
/// (null for envelope types we don't session-decrypt), and the surfaced 1:1 text (if any).</summary>
|
||||
public sealed record Result(string Sender, uint SenderDevice, Content? Content, DecryptedMessage? Message);
|
||||
|
||||
/// <summary>Surfaced-text-only view, kept for callers/tests that just want the chat bubble.</summary>
|
||||
public DecryptedMessage? Decrypt(Envelope envelope) => DecryptEnvelope(envelope).Message;
|
||||
|
||||
public Result DecryptEnvelope(Envelope envelope)
|
||||
{
|
||||
string sender = ResolveServiceId(envelope.SourceServiceId, envelope.SourceServiceIdBinary);
|
||||
uint senderDevice = envelope.SourceDeviceId;
|
||||
byte[] ciphertext = envelope.Content.ToByteArray();
|
||||
bool isPreKey;
|
||||
|
||||
switch (envelope.Type)
|
||||
{
|
||||
case Envelope.Types.Type.PrekeyMessage:
|
||||
isPreKey = true;
|
||||
break;
|
||||
case Envelope.Types.Type.DoubleRatchet:
|
||||
isPreKey = false;
|
||||
break;
|
||||
case Envelope.Types.Type.UnidentifiedSender:
|
||||
// Sealed sender: unwrap to the real sender + inner ciphertext, then decrypt as usual.
|
||||
SealedSenderDecryptor.Unsealed u = SealedSenderDecryptor.Decrypt(
|
||||
ciphertext, _store.GetIdentityKeyPair(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
sender = u.SenderUuid;
|
||||
senderDevice = u.SenderDevice;
|
||||
ciphertext = u.Content;
|
||||
if (u.CiphertextType == 7) // 7 = group sender-key (GroupsV2 G1 receive)
|
||||
return DecryptGroupSenderKey(sender, senderDevice, ciphertext, envelope);
|
||||
isPreKey = u.CiphertextType == 1; // PREKEY_MESSAGE
|
||||
if (u.CiphertextType is not (1 or 2)) // 2 = MESSAGE; 8 = plaintext
|
||||
return new Result(sender, senderDevice, null, null);
|
||||
break;
|
||||
default:
|
||||
return new Result(sender, senderDevice, null, null); // receipts, etc.
|
||||
}
|
||||
|
||||
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||
var cipher = new SessionCipher(_store, _store, _store, _store, _store, address);
|
||||
byte[] padded = isPreKey
|
||||
? cipher.DecryptPreKeyMessage(PreKeySignalMessage.Parse(ciphertext))
|
||||
: cipher.DecryptSignalMessage(SignalMessage.Parse(ciphertext));
|
||||
|
||||
byte[] plaintext = StripPadding(padded);
|
||||
var content = Content.Parser.ParseFrom(plaintext);
|
||||
|
||||
// Capture the sender's profile key so we can later send THEM sealed-sender messages.
|
||||
if (_profileKeys is not null && content.DataMessage?.ProfileKey is { Length: 32 } pk)
|
||||
_profileKeys.Store(sender, pk.ToByteArray());
|
||||
|
||||
// Install any group sender key the peer distributed (so their future group messages decrypt).
|
||||
ProcessDistributionIfPresent(content, sender, senderDevice);
|
||||
|
||||
DecryptedMessage? message = SurfaceText(content, sender, senderDevice, envelope);
|
||||
return new Result(sender, senderDevice, content, message);
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a group (Sender Key) message that arrived as a sealed-sender SENDERKEY envelope,
|
||||
/// and surfaces it routed to its group thread. Requires a sender-key store; otherwise the message is
|
||||
/// dropped (we couldn't have its sender key without one).</summary>
|
||||
private Result DecryptGroupSenderKey(string sender, uint senderDevice, byte[] senderKeyMessage, Envelope envelope)
|
||||
{
|
||||
if (_senderKeys is null) return new Result(sender, senderDevice, null, null);
|
||||
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||
byte[] padded = new GroupMessageProcessor(_senderKeys).DecryptGroupMessage(address, senderKeyMessage);
|
||||
var content = Content.Parser.ParseFrom(StripPadding(padded));
|
||||
|
||||
if (_profileKeys is not null && content.DataMessage?.ProfileKey is { Length: 32 } pk)
|
||||
_profileKeys.Store(sender, pk.ToByteArray());
|
||||
|
||||
DecryptedMessage? message = SurfaceText(content, sender, senderDevice, envelope);
|
||||
return new Result(sender, senderDevice, content, message);
|
||||
}
|
||||
|
||||
private void ProcessDistributionIfPresent(Content content, string sender, uint senderDevice)
|
||||
{
|
||||
if (_senderKeys is null || content.SenderKeyDistributionMessage.IsEmpty) return;
|
||||
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||
new GroupMessageProcessor(_senderKeys)
|
||||
.ProcessDistribution(address, content.SenderKeyDistributionMessage.ToByteArray());
|
||||
}
|
||||
|
||||
private static DecryptedMessage? SurfaceText(Content content, string sender, uint senderDevice, Envelope envelope)
|
||||
{
|
||||
// Direct incoming 1:1 message (text, or a placeholder for media/reactions so it still appears).
|
||||
if (content.DataMessage is { } dm)
|
||||
{
|
||||
string? text = Describe(dm);
|
||||
if (text is not null)
|
||||
{
|
||||
long ts = dm.Timestamp != 0 ? (long)dm.Timestamp : (long)envelope.ServerTimestamp;
|
||||
(string? groupId, byte[]? masterKey) = GroupContext(dm.GroupV2);
|
||||
return new DecryptedMessage(sender, senderDevice, text, ts, Outgoing: false)
|
||||
{
|
||||
Attachment = dm.Attachments.Count > 0 ? dm.Attachments[0] : null,
|
||||
GroupId = groupId, GroupMasterKey = masterKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript of a message we sent from another device (synced to us).
|
||||
if (content.SyncMessage?.Sent is { } sent && sent.Message is { } sentMsg && Describe(sentMsg) is { } sentText)
|
||||
{
|
||||
string peer = sent.DestinationServiceId ?? sender;
|
||||
long ts = sent.Timestamp != 0 ? (long)sent.Timestamp : (long)envelope.ServerTimestamp;
|
||||
(string? groupId, byte[]? masterKey) = GroupContext(sentMsg.GroupV2);
|
||||
return new DecryptedMessage(peer, senderDevice, sentText, ts, Outgoing: true)
|
||||
{
|
||||
Attachment = sentMsg.Attachments.Count > 0 ? sentMsg.Attachments[0] : null,
|
||||
GroupId = groupId, GroupMasterKey = masterKey,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Derives the (groupId, masterKey) from a GroupContextV2, or (null, null) for a 1:1 message.</summary>
|
||||
private static (string? groupId, byte[]? masterKey) GroupContext(GroupContextV2? groupV2)
|
||||
{
|
||||
if (groupV2?.MasterKey is { Length: 32 } mk)
|
||||
{
|
||||
byte[] key = mk.ToByteArray();
|
||||
return (GroupMessageProcessor.GroupIdHex(key), key);
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
/// <summary>The display text for a DataMessage: the body, else a placeholder for an attachment or
|
||||
/// reaction (so media/reactions show up instead of being silently dropped). Null = nothing to show
|
||||
/// (receipts, typing, empty).</summary>
|
||||
private static string? Describe(DataMessage dm)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(dm.Body)) return dm.Body;
|
||||
|
||||
if (dm.Reaction is { } r && !string.IsNullOrEmpty(r.Emoji))
|
||||
return r.Remove ? "removed a reaction" : $"reacted {r.Emoji}";
|
||||
|
||||
if (dm.Attachments.Count > 0)
|
||||
return DescribeAttachment(dm.Attachments[0], dm.Attachments.Count);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeAttachment(AttachmentPointer a, int count)
|
||||
{
|
||||
string label =
|
||||
(a.Flags & (uint)AttachmentPointer.Types.Flags.VoiceMessage) != 0 ? "🎙 Voice message" :
|
||||
!string.IsNullOrEmpty(a.ContentType) && a.ContentType.StartsWith("image/") ? "📷 Photo" :
|
||||
!string.IsNullOrEmpty(a.ContentType) && a.ContentType.StartsWith("video/") ? "🎥 Video" :
|
||||
!string.IsNullOrEmpty(a.FileName) ? $"📎 {a.FileName}" : "📎 Attachment";
|
||||
return count > 1 ? $"{label} (+{count - 1} more)" : label;
|
||||
}
|
||||
|
||||
/// <summary>Resolves a service id, preferring the string form and falling back to the binary form
|
||||
/// (16-byte ACI UUID, or 1-byte prefix + 16-byte UUID for PNI).</summary>
|
||||
private static string ResolveServiceId(string asString, Google.Protobuf.ByteString binary) =>
|
||||
!string.IsNullOrEmpty(asString) ? asString : ServiceIds.StringFromBinary(binary.Span) ?? string.Empty;
|
||||
|
||||
/// <summary>Removes Signal's PushTransportDetails padding (0x80 terminator + trailing zeros).</summary>
|
||||
private static byte[] StripPadding(byte[] message) => MessagePadding.Strip(message);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>Signal's PushTransportDetails padding: append a 0x80 terminator, then zero-pad to a 160-byte
|
||||
/// multiple. Applied to a serialized <c>Content</c> before encryption (1:1 and group) and removed after
|
||||
/// decryption. Shared by the 1:1 send/receive path and the group (Sender Key) path.</summary>
|
||||
public static class MessagePadding
|
||||
{
|
||||
public static byte[] Add(byte[] message)
|
||||
{
|
||||
var padded = new byte[PaddedLength(message.Length + 1) - 1];
|
||||
Array.Copy(message, padded, message.Length);
|
||||
padded[message.Length] = 0x80;
|
||||
return padded;
|
||||
}
|
||||
|
||||
public static byte[] Strip(byte[] message)
|
||||
{
|
||||
int paddingStart = 0;
|
||||
for (int i = message.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (message[i] == 0x80) { paddingStart = i; break; }
|
||||
if (message[i] != 0x00) { paddingStart = message.Length; break; }
|
||||
}
|
||||
return message[..paddingStart];
|
||||
}
|
||||
|
||||
private static int PaddedLength(int messageLength)
|
||||
{
|
||||
int withTerminator = messageLength + 1;
|
||||
int parts = withTerminator / 160;
|
||||
if (withTerminator % 160 != 0) parts++;
|
||||
return parts * 160;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.Ratchet;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Sends an unsealed (authenticated) 1:1 text. Fetches the recipient's prekey bundles, establishes a
|
||||
/// PQXDH session per device (initiator/Alice — SPQR runs automatically), encrypts a padded
|
||||
/// DataMessage, and PUTs the per-device ciphertexts to /v1/messages. To message your own account
|
||||
/// ("Note to Self"), pass your own ACI as the destination; your own device is skipped.
|
||||
/// </summary>
|
||||
public sealed class MessageSender
|
||||
{
|
||||
private readonly SignalAccount _account;
|
||||
private readonly ISignalProtocolStore _store;
|
||||
private readonly SignalRestClient _rest;
|
||||
private readonly Account.ProfileKeyStore? _profileKeys;
|
||||
private byte[]? _senderCertificate; // cached delivery certificate (~24h)
|
||||
|
||||
public MessageSender(SignalAccount account, ISignalProtocolStore store, SignalRestClient rest,
|
||||
Account.ProfileKeyStore? profileKeys = null)
|
||||
{
|
||||
_account = account;
|
||||
_store = store;
|
||||
_rest = rest;
|
||||
_profileKeys = profileKeys;
|
||||
}
|
||||
|
||||
public sealed record SendResult(bool Ok, int DeviceCount, string Detail);
|
||||
|
||||
// Sesame §3.3: if the recipient's device set changed (server 409 mismatched / 410 stale), re-fetch
|
||||
// the authoritative device list and retry, bounded to avoid looping on a malicious/buggy server.
|
||||
private const int MaxSendAttempts = 3;
|
||||
|
||||
public async Task<SendResult> SendTextAsync(string destinationServiceId, string text, CancellationToken ct = default)
|
||||
{
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var dataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp };
|
||||
var content = new Content { DataMessage = dataMessage };
|
||||
SendResult result = await SendContentAsync(destinationServiceId, content, timestamp, ct).ConfigureAwait(false);
|
||||
|
||||
// Multi-device: after messaging someone else, sync a "Sent" transcript to our OWN account so the
|
||||
// user's other linked devices show the outgoing message. (Note-to-Self already reaches them, since
|
||||
// that send targets our other devices directly.) Best-effort — never fails the real send.
|
||||
if (result.Ok && !IsSelf(destinationServiceId))
|
||||
await TrySyncSentTranscriptAsync(destinationServiceId, dataMessage, timestamp, ct).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Builds the <see cref="Content"/> that syncs an outgoing message to our other devices: a
|
||||
/// <c>SyncMessage.Sent</c> carrying the destination, timestamp, and the exact DataMessage we sent.</summary>
|
||||
public static Content BuildSentTranscript(string destinationServiceId, DataMessage message, long timestamp)
|
||||
{
|
||||
var sent = new SyncMessage.Types.Sent
|
||||
{
|
||||
DestinationServiceId = destinationServiceId,
|
||||
Timestamp = (ulong)timestamp,
|
||||
Message = message,
|
||||
};
|
||||
sent.UnidentifiedStatus.Add(new SyncMessage.Types.Sent.Types.UnidentifiedDeliveryStatus
|
||||
{
|
||||
DestinationServiceId = destinationServiceId,
|
||||
Unidentified = false,
|
||||
});
|
||||
return new Content { SyncMessage = new SyncMessage { Sent = sent } };
|
||||
}
|
||||
|
||||
private async Task TrySyncSentTranscriptAsync(string destinationServiceId, DataMessage message, long timestamp, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
Content transcript = BuildSentTranscript(destinationServiceId, message, timestamp);
|
||||
SendResult r = await SendContentAsync(_account.Aci, transcript, timestamp, ct).ConfigureAwait(false);
|
||||
FileLog.Write($"send: synced Sent transcript to self ok={r.Ok} detail={r.Detail}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"send: Sent transcript sync FAILED {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSelf(string serviceId) =>
|
||||
string.Equals(serviceId, _account.Aci, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort sealed-sender (metadata-minimized) delivery: if we know the recipient's profile key
|
||||
/// and can get a delivery certificate, re-wrap the already-built per-device ciphertexts as sealed
|
||||
/// envelopes and send them WITHOUT our auth credentials, using the recipient's unidentified-access
|
||||
/// key. Returns true only on success; ANY missing prerequisite or failure returns false so the
|
||||
/// caller falls back to the (working) authenticated send. The inner ciphertext is reused, so the
|
||||
/// ratchet is not advanced twice.
|
||||
/// </summary>
|
||||
private async Task<bool> TrySealedAsync(string destinationServiceId, OutgoingMessageList authList, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_profileKeys is null || IsSelf(destinationServiceId)) return false;
|
||||
byte[]? profileKey = _profileKeys.Get(destinationServiceId);
|
||||
if (profileKey is null) return false;
|
||||
|
||||
_senderCertificate ??= await _rest.GetSenderCertificateAsync(_account.BasicAuthToken(), ct).ConfigureAwait(false);
|
||||
byte[] accessKey = Wingnal.Service.Crypto.UnidentifiedAccess.DeriveAccessKey(profileKey);
|
||||
IdentityKeyPair ourIdentity = _account.AciIdentityKeyPair;
|
||||
|
||||
var sealedMessages = new List<OutgoingMessage>(authList.Messages.Length);
|
||||
foreach (OutgoingMessage m in authList.Messages)
|
||||
{
|
||||
IdentityKey? theirIdentity = _store.GetIdentity(new SignalProtocolAddress(destinationServiceId, m.DestinationDeviceId));
|
||||
if (theirIdentity is null) return false; // need their identity to seal
|
||||
|
||||
byte[] inner = DecodeBase64(m.Content);
|
||||
int innerType = m.Type == 3 ? 1 : 2; // PREKEY_MESSAGE / MESSAGE (sealed inner type)
|
||||
byte[] sealedBytes = SealedSenderDecryptor.EncryptWithCertificate(ourIdentity, theirIdentity, _senderCertificate, innerType, inner);
|
||||
sealedMessages.Add(new OutgoingMessage
|
||||
{
|
||||
Type = 6, // UNIDENTIFIED_SENDER
|
||||
DestinationDeviceId = m.DestinationDeviceId,
|
||||
DestinationRegistrationId = m.DestinationRegistrationId,
|
||||
Content = Convert.ToBase64String(sealedBytes),
|
||||
});
|
||||
}
|
||||
|
||||
var sealedList = new OutgoingMessageList
|
||||
{
|
||||
Messages = sealedMessages.ToArray(),
|
||||
Timestamp = authList.Timestamp,
|
||||
Online = authList.Online,
|
||||
Urgent = authList.Urgent,
|
||||
};
|
||||
(bool ok, _, _) = await _rest.SendSealedMessagesAsync(destinationServiceId, sealedList, accessKey, ct).ConfigureAwait(false);
|
||||
return ok;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // any problem → fall back to authenticated send (never regress)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sends our other devices a SyncMessage.Request for each of <paramref name="types"/> (a
|
||||
/// sync Content to our own ACI), asking the primary to push account state (contacts, blocked,
|
||||
/// configuration). GROUPS is intentionally omitted — it was removed from the sync protocol (groups
|
||||
/// now live in the storage service; see docs/GROUPS.md). Best-effort: returns the first failure.</summary>
|
||||
public async Task<SendResult> SendSyncRequestsAsync(IEnumerable<SyncMessage.Types.Request.Types.Type> types,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
SendResult last = new(true, 0, "no requests");
|
||||
foreach (SyncMessage.Types.Request.Types.Type type in types)
|
||||
{
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var content = new Content
|
||||
{
|
||||
SyncMessage = new SyncMessage { Request = new SyncMessage.Types.Request { Type = type } },
|
||||
};
|
||||
last = await SendContentAsync(_account.Aci, content, timestamp, ct).ConfigureAwait(false);
|
||||
if (!last.Ok) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>Encrypts a padded <see cref="Content"/> and sends it per-device (reuse-first, then
|
||||
/// fetch+establish on 409/410). The 1:1 text and sync-request paths both funnel through here.</summary>
|
||||
public async Task<SendResult> SendContentAsync(string destinationServiceId, Content content, long timestamp,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
byte[] padded = AddPadding(content.ToByteArray());
|
||||
string auth = _account.BasicAuthToken();
|
||||
|
||||
// Reuse path (Sesame): if we already have sessions for this recipient's devices, encrypt with
|
||||
// them and send WITHOUT a /v2/keys fetch. Only fall back to fetching when there's no session
|
||||
// (first message) or the server reports the device set changed (409/410).
|
||||
OutgoingMessageList? reuse = BuildFromExistingSessions(destinationServiceId, padded, timestamp);
|
||||
if (reuse is { Messages.Length: > 0 })
|
||||
{
|
||||
if (await TrySealedAsync(destinationServiceId, reuse, ct).ConfigureAwait(false))
|
||||
return new SendResult(true, reuse.Messages.Length, $"sent sealed to {reuse.Messages.Length} device(s) (reused sessions)");
|
||||
(bool ok, var status, string body) = await _rest.SendMessagesAsync(destinationServiceId, reuse, auth, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
return new SendResult(true, reuse.Messages.Length, $"sent to {reuse.Messages.Length} device(s) (reused sessions)");
|
||||
if (status is not (System.Net.HttpStatusCode.Conflict or System.Net.HttpStatusCode.Gone))
|
||||
return new SendResult(false, reuse.Messages.Length, $"{(int)status}: {body}");
|
||||
// device set changed — fall through to the fetch+establish path below.
|
||||
}
|
||||
|
||||
for (int attempt = 1; attempt <= MaxSendAttempts; attempt++)
|
||||
{
|
||||
// "*" returns the recipient's current device set, so a re-fetch reconciles both missing
|
||||
// (added) and stale (removed/rotated) devices.
|
||||
PreKeyResponse bundles = await _rest.GetPreKeysAsync(destinationServiceId, "*", auth, ct).ConfigureAwait(false);
|
||||
|
||||
OutgoingMessageList list = BuildOutgoingList(destinationServiceId, padded, bundles, timestamp);
|
||||
if (list.Messages.Length == 0)
|
||||
return new SendResult(false, 0, "no target devices (only our own device present)");
|
||||
|
||||
if (await TrySealedAsync(destinationServiceId, list, ct).ConfigureAwait(false))
|
||||
return new SendResult(true, list.Messages.Length, $"sent sealed to {list.Messages.Length} device(s)");
|
||||
|
||||
(bool ok, var status, string body) = await _rest.SendMessagesAsync(destinationServiceId, list, auth, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
return new SendResult(true, list.Messages.Length, $"sent to {list.Messages.Length} device(s)");
|
||||
|
||||
bool deviceSetChanged = status is System.Net.HttpStatusCode.Conflict or System.Net.HttpStatusCode.Gone;
|
||||
if (!deviceSetChanged || attempt == MaxSendAttempts)
|
||||
return new SendResult(false, list.Messages.Length, $"{(int)status}: {body}");
|
||||
// else: device set changed — loop to re-fetch and retry.
|
||||
}
|
||||
return new SendResult(false, 0, "device set kept changing after retries");
|
||||
}
|
||||
|
||||
/// <summary>Pure (no network): encrypt a padded text DataMessage to every device in the bundle and
|
||||
/// build the per-device outgoing list. Exposed for offline testing.</summary>
|
||||
public OutgoingMessageList BuildOutgoingList(string destinationServiceId, string text, PreKeyResponse bundles, long timestamp)
|
||||
{
|
||||
var content = new Content { DataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp } };
|
||||
return BuildOutgoingList(destinationServiceId, AddPadding(content.ToByteArray()), bundles, timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Encrypt already-padded Content bytes to every device in the bundle.</summary>
|
||||
public OutgoingMessageList BuildOutgoingList(string destinationServiceId, byte[] padded, PreKeyResponse bundles, long timestamp)
|
||||
{
|
||||
IdentityKey theirIdentity = IdentityKey.Decode(DecodeBase64(bundles.IdentityKey));
|
||||
var messages = new List<OutgoingMessage>();
|
||||
foreach (PreKeyResponseDevice dev in bundles.Devices)
|
||||
{
|
||||
// Skip our own device when messaging our own account (Note to Self).
|
||||
if (string.Equals(destinationServiceId, _account.Aci, StringComparison.OrdinalIgnoreCase)
|
||||
&& dev.DeviceId == (uint)_account.DeviceId)
|
||||
continue;
|
||||
|
||||
var address = new SignalProtocolAddress(destinationServiceId, dev.DeviceId);
|
||||
PreKeyBundle bundle = BuildBundle(theirIdentity, dev);
|
||||
|
||||
new SessionBuilder(_store, _store, _store, _store, _store, address).Process(bundle);
|
||||
ICiphertextMessage cipher = new SessionCipher(_store, _store, _store, _store, _store, address).Encrypt(padded);
|
||||
|
||||
int wireType = cipher.Type == CiphertextMessageType.PreKey ? 3 : 1; // PREKEY_MESSAGE / DOUBLE_RATCHET
|
||||
messages.Add(new OutgoingMessage
|
||||
{
|
||||
Type = wireType,
|
||||
DestinationDeviceId = dev.DeviceId,
|
||||
DestinationRegistrationId = dev.RegistrationId,
|
||||
Content = Convert.ToBase64String(cipher.Serialize()),
|
||||
});
|
||||
}
|
||||
|
||||
return new OutgoingMessageList
|
||||
{
|
||||
Messages = messages.ToArray(),
|
||||
Timestamp = timestamp,
|
||||
Online = false,
|
||||
Urgent = true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Encrypt to every device of <paramref name="destinationServiceId"/> that already has a
|
||||
/// session, reusing it (no prekey fetch). Returns null if there are no existing sessions. Public for
|
||||
/// offline testing.</summary>
|
||||
public OutgoingMessageList? BuildFromExistingSessions(string destinationServiceId, string text, long timestamp)
|
||||
{
|
||||
var content = new Content { DataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp } };
|
||||
return BuildFromExistingSessions(destinationServiceId, AddPadding(content.ToByteArray()), timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Reuse existing sessions to encrypt already-padded Content bytes.</summary>
|
||||
public OutgoingMessageList? BuildFromExistingSessions(string destinationServiceId, byte[] padded, long timestamp)
|
||||
{
|
||||
IReadOnlyList<uint> deviceIds = _store.GetSubDeviceSessions(destinationServiceId);
|
||||
if (deviceIds.Count == 0) return null;
|
||||
|
||||
var messages = new List<OutgoingMessage>();
|
||||
foreach (uint deviceId in deviceIds)
|
||||
{
|
||||
if (string.Equals(destinationServiceId, _account.Aci, StringComparison.OrdinalIgnoreCase)
|
||||
&& deviceId == (uint)_account.DeviceId)
|
||||
continue;
|
||||
|
||||
var address = new SignalProtocolAddress(destinationServiceId, deviceId);
|
||||
uint registrationId = _store.LoadSession(address).State.RemoteRegistrationId;
|
||||
ICiphertextMessage cipher = new SessionCipher(_store, _store, _store, _store, _store, address).Encrypt(padded);
|
||||
messages.Add(new OutgoingMessage
|
||||
{
|
||||
Type = cipher.Type == CiphertextMessageType.PreKey ? 3 : 1,
|
||||
DestinationDeviceId = deviceId,
|
||||
DestinationRegistrationId = registrationId,
|
||||
Content = Convert.ToBase64String(cipher.Serialize()),
|
||||
});
|
||||
}
|
||||
|
||||
return new OutgoingMessageList
|
||||
{
|
||||
Messages = messages.ToArray(),
|
||||
Timestamp = timestamp,
|
||||
Online = false,
|
||||
Urgent = true,
|
||||
};
|
||||
}
|
||||
|
||||
private static PreKeyBundle BuildBundle(IdentityKey theirIdentity, PreKeyResponseDevice dev)
|
||||
{
|
||||
byte[] signedPreKey = Curve25519.DecodePoint(DecodeBase64(dev.SignedPreKey.PublicKey));
|
||||
byte[]? preKey = dev.PreKey is { } pk ? Curve25519.DecodePoint(DecodeBase64(pk.PublicKey)) : null;
|
||||
byte[]? kyber = dev.PqPreKey is { } qk ? KemKeySerialization.Deserialize(DecodeBase64(qk.PublicKey)) : null;
|
||||
|
||||
return new PreKeyBundle(
|
||||
registrationId: dev.RegistrationId,
|
||||
deviceId: dev.DeviceId,
|
||||
preKeyId: dev.PreKey?.KeyId,
|
||||
preKeyPublic: preKey,
|
||||
signedPreKeyId: dev.SignedPreKey.KeyId,
|
||||
signedPreKeyPublic: signedPreKey,
|
||||
signedPreKeySignature: DecodeBase64(dev.SignedPreKey.Signature),
|
||||
identityKey: theirIdentity,
|
||||
kyberPreKeyId: dev.PqPreKey?.KeyId,
|
||||
kyberPreKeyPublic: kyber,
|
||||
kyberPreKeySignature: dev.PqPreKey is { } q ? DecodeBase64(q.Signature) : null);
|
||||
}
|
||||
|
||||
// Signal serializes keys/signatures as base64 (sometimes URL-safe and/or without padding).
|
||||
private static byte[] DecodeBase64(string s)
|
||||
{
|
||||
string t = s.Replace('-', '+').Replace('_', '/');
|
||||
switch (t.Length % 4) { case 2: t += "=="; break; case 3: t += "="; break; }
|
||||
return Convert.FromBase64String(t);
|
||||
}
|
||||
|
||||
// Signal PushTransportDetails padding (shared with the group path + the receive-side strip).
|
||||
private static byte[] AddPadding(byte[] message) => MessagePadding.Add(message);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Service.Account;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>A stored 1:1 message. <see cref="MediaPath"/> is the local file of a downloaded attachment
|
||||
/// (null for plain text).</summary>
|
||||
public sealed record StoredMessage(string Peer, string Body, long Timestamp, bool Outgoing)
|
||||
{
|
||||
public string? MediaPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One conversation thread, keyed by peer service id, with its most recent message.</summary>
|
||||
public sealed record Conversation(string Peer, string LastBody, long LastTimestamp, bool LastOutgoing);
|
||||
|
||||
/// <summary>
|
||||
/// SQLite store for received/sent 1:1 texts (%LOCALAPPDATA%\Wingnal\messages.db). Message BODIES are
|
||||
/// encrypted at rest with <see cref="LocalCipher"/> (peer/timestamp metadata stays plaintext so the
|
||||
/// list/threads can still be queried + sorted). Legacy plaintext rows decrypt-through unchanged.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class MessageStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public MessageStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "messages.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
peer TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
outgoing INTEGER NOT NULL,
|
||||
media TEXT
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
// Idempotent migration for DBs created before the media column existed.
|
||||
try
|
||||
{
|
||||
using SqliteCommand alter = conn.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE messages ADD COLUMN media TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
catch (SqliteException) { /* column already exists */ }
|
||||
}
|
||||
|
||||
public void Add(StoredMessage message)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT INTO messages (peer, body, timestamp, outgoing, media) VALUES ($peer, $body, $ts, $out, $media);";
|
||||
cmd.Parameters.AddWithValue("$peer", message.Peer);
|
||||
cmd.Parameters.AddWithValue("$body", _cipher.Protect(message.Body)); // encrypted at rest
|
||||
cmd.Parameters.AddWithValue("$ts", message.Timestamp);
|
||||
cmd.Parameters.AddWithValue("$out", message.Outgoing ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$media", message.MediaPath is null ? DBNull.Value : _cipher.Protect(message.MediaPath));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The most recent <paramref name="limit"/> messages for one peer's thread, returned
|
||||
/// oldest-first for display. (Selects the NEWEST N by timestamp DESC then reverses — selecting ASC
|
||||
/// would return the OLDEST N and hide recent history in a long thread.)</summary>
|
||||
public IReadOnlyList<StoredMessage> Recent(string peer, int limit = 500)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT peer, body, timestamp, outgoing, media FROM messages WHERE peer = $peer ORDER BY timestamp DESC LIMIT $limit;";
|
||||
cmd.Parameters.AddWithValue("$peer", peer);
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var result = new List<StoredMessage>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
result.Add(new StoredMessage(reader.GetString(0), _cipher.Unprotect(reader.GetString(1)),
|
||||
reader.GetInt64(2), reader.GetInt64(3) != 0)
|
||||
{
|
||||
MediaPath = reader.IsDBNull(4) ? null : _cipher.Unprotect(reader.GetString(4)),
|
||||
});
|
||||
result.Reverse(); // chronological (oldest → newest) for the thread view
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>One row per peer (the conversation list), most-recently-active first.</summary>
|
||||
public IReadOnlyList<Conversation> Conversations()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
// Pick each peer's CHRONOLOGICALLY newest message (max TIMESTAMP, not max id — rows are inserted
|
||||
// in import order, not time order). SQLite fills the bare columns from the MAX(timestamp) row.
|
||||
cmd.CommandText =
|
||||
"""
|
||||
SELECT peer, body, MAX(timestamp) AS timestamp, outgoing
|
||||
FROM messages
|
||||
GROUP BY peer
|
||||
ORDER BY timestamp DESC;
|
||||
""";
|
||||
|
||||
var result = new List<Conversation>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
result.Add(new Conversation(reader.GetString(0), _cipher.Unprotect(reader.GetString(1)),
|
||||
reader.GetInt64(2), reader.GetInt64(3) != 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>A set of identity keys for the messages already stored (peer‖ts‖outgoing‖body), so a
|
||||
/// bulk import can skip ones it already added (idempotent re-import).</summary>
|
||||
public HashSet<string> ExistingKeys()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT peer, timestamp, outgoing, body FROM messages;";
|
||||
var set = new HashSet<string>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
set.Add(KeyOf(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2) != 0,
|
||||
_cipher.Unprotect(reader.GetString(3))));
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>The de-dup identity of a message.</summary>
|
||||
public static string KeyOf(string peer, long timestamp, bool outgoing, string body) =>
|
||||
string.Join('|', peer, timestamp, outgoing ? 1 : 0, body);
|
||||
|
||||
/// <summary>Removes exact-duplicate rows (same peer+timestamp+outgoing+body), keeping the earliest.
|
||||
/// Done in code because the body column is now encrypted (non-deterministic), so SQL GROUP BY can't
|
||||
/// see content equality. Returns rows deleted.</summary>
|
||||
public int Deduplicate()
|
||||
{
|
||||
using var conn = Open();
|
||||
var seen = new HashSet<string>();
|
||||
var toDelete = new List<long>();
|
||||
using (SqliteCommand read = conn.CreateCommand())
|
||||
{
|
||||
read.CommandText = "SELECT id, peer, timestamp, outgoing, body FROM messages ORDER BY id;";
|
||||
using SqliteDataReader reader = read.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
string key = KeyOf(reader.GetString(1), reader.GetInt64(2), reader.GetInt64(3) != 0,
|
||||
_cipher.Unprotect(reader.GetString(4)));
|
||||
if (!seen.Add(key)) toDelete.Add(reader.GetInt64(0));
|
||||
}
|
||||
}
|
||||
foreach (long id in toDelete)
|
||||
{
|
||||
using SqliteCommand del = conn.CreateCommand();
|
||||
del.CommandText = "DELETE FROM messages WHERE id = $id;";
|
||||
del.Parameters.AddWithValue("$id", id);
|
||||
del.ExecuteNonQuery();
|
||||
}
|
||||
return toDelete.Count;
|
||||
}
|
||||
|
||||
/// <summary>Removes all stored messages (used when unlinking).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM messages;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Deletes every message in one peer's thread (peer is stored in the clear).</summary>
|
||||
public void DeleteConversation(string peer)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM messages WHERE peer = $peer;";
|
||||
cmd.Parameters.AddWithValue("$peer", peer);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Deletes one message identified by (peer, timestamp, direction). Body isn't used since it's
|
||||
/// encrypted; the timestamp is effectively unique within a peer + direction.</summary>
|
||||
public void DeleteMessage(string peer, long timestamp, bool outgoing)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM messages WHERE peer = $peer AND timestamp = $ts AND outgoing = $out;";
|
||||
cmd.Parameters.AddWithValue("$peer", peer);
|
||||
cmd.Parameters.AddWithValue("$ts", timestamp);
|
||||
cmd.Parameters.AddWithValue("$out", outgoing ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the small "sideband" <see cref="Content"/> messages that ride alongside chat: delivery/read
|
||||
/// receipts (the ✓✓ that tells the sender we got/read their message) and typing notifications. Pure
|
||||
/// (no network), so the wire shape is offline-testable.
|
||||
/// </summary>
|
||||
public static class Receipts
|
||||
{
|
||||
/// <summary>A DELIVERY receipt acknowledging the message(s) with these sent-timestamps arrived.</summary>
|
||||
public static Content Delivery(params long[] sentTimestamps) =>
|
||||
Build(ReceiptMessage.Types.Type.Delivery, sentTimestamps);
|
||||
|
||||
/// <summary>A READ receipt for the message(s) the user has now seen.</summary>
|
||||
public static Content Read(params long[] sentTimestamps) =>
|
||||
Build(ReceiptMessage.Types.Type.Read, sentTimestamps);
|
||||
|
||||
/// <summary>A typing START/STOP notification for a 1:1 thread.</summary>
|
||||
public static Content Typing(bool started, long timestamp) => new()
|
||||
{
|
||||
TypingMessage = new TypingMessage
|
||||
{
|
||||
Action = started ? TypingMessage.Types.Action.Started : TypingMessage.Types.Action.Stopped,
|
||||
Timestamp = (ulong)timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
private static Content Build(ReceiptMessage.Types.Type type, IEnumerable<long> timestamps)
|
||||
{
|
||||
var receipt = new ReceiptMessage { Type = type };
|
||||
foreach (long t in timestamps) receipt.Timestamp.Add((ulong)t);
|
||||
return new Content { ReceiptMessage = receipt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves user-entered recipient text into a destination service id (ACI UUID). An ACI is accepted
|
||||
/// directly (normalized to lowercase canonical form). A phone number (e164) currently can't be resolved
|
||||
/// client-side: Signal removed the unauthenticated number→ACI lookup, so it requires the Contact
|
||||
/// Discovery Service (CDSI, an SGX-attested enclave) which Wingnal doesn't implement yet — see
|
||||
/// SHORTCUTS.md. Until then, paste the contact's ACI UUID.
|
||||
/// </summary>
|
||||
public static partial class RecipientResolver
|
||||
{
|
||||
public sealed record Result(bool Ok, string? ServiceId, string? Error);
|
||||
|
||||
[GeneratedRegex(@"^\+[1-9]\d{6,14}$")]
|
||||
private static partial Regex E164();
|
||||
|
||||
public static Result Resolve(string? input)
|
||||
{
|
||||
string text = (input ?? string.Empty).Trim();
|
||||
if (text.Length == 0)
|
||||
return new Result(false, null, "Enter a recipient.");
|
||||
|
||||
if (Guid.TryParse(text, out Guid aci))
|
||||
return new Result(true, aci.ToString("D").ToLowerInvariant(), null);
|
||||
|
||||
if (E164().IsMatch(text))
|
||||
return new Result(false, null,
|
||||
"Phone-number lookup needs Signal's Contact Discovery (CDSI), which Wingnal doesn't " +
|
||||
"support yet. Paste the contact's ACI UUID instead.");
|
||||
|
||||
return new Result(false, null, "Not a valid ACI UUID (or +e164 number).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Security.Cryptography;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Protos.SealedSender;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a Sealed Sender v1 envelope (UNIDENTIFIED_SENDER) to recover the sender and the inner
|
||||
/// ciphertext, which the normal session pipeline then decrypts. This is how messages other people send
|
||||
/// us (and their replies) arrive — modern Signal clients default to sealed sender. Byte-exact with
|
||||
/// libsignal v0.96.1 sealed_sender.rs (v1). Sealed Sender v2 (multi-recipient) is not handled here.
|
||||
///
|
||||
/// NOTE: the sender certificate's server signature is NOT validated against Signal's trust root yet
|
||||
/// (see SHORTCUTS.md). The message is still cryptographically authenticated by the inner Double Ratchet
|
||||
/// session (its MAC binds to the sender's identity key), so a forged certificate can't forge content;
|
||||
/// trust-root validation only adds server-attested sender identity.
|
||||
/// </summary>
|
||||
public static class SealedSenderDecryptor
|
||||
{
|
||||
private const byte SealedSenderV1MajorVersion = 1;
|
||||
private const byte SealedSenderV2MajorVersion = 2;
|
||||
private static readonly byte[] SaltPrefix = "UnidentifiedDelivery"u8.ToArray();
|
||||
|
||||
/// <summary>The unwrapped sealed-sender payload: who sent it + the inner ciphertext to feed the
|
||||
/// session cipher.</summary>
|
||||
public sealed record Unsealed(string SenderUuid, uint SenderDevice, int CiphertextType, byte[] Content);
|
||||
|
||||
public static Unsealed Decrypt(byte[] serialized, IdentityKeyPair ourIdentity, long nowMs,
|
||||
IReadOnlyList<byte[]>? trustRoots = null)
|
||||
{
|
||||
if (serialized.Length < 1) throw new InvalidMessageException("sealed sender message empty");
|
||||
int version = (serialized[0] >> 4) & 0xF;
|
||||
if (version == SealedSenderV2MajorVersion)
|
||||
throw new InvalidMessageException("sealed sender v2 not supported");
|
||||
if (version is not (0 or SealedSenderV1MajorVersion))
|
||||
throw new InvalidMessageException($"unknown sealed sender version {version}");
|
||||
|
||||
UnidentifiedSenderMessage outer = UnidentifiedSenderMessage.Parser.ParseFrom(serialized.AsSpan(1).ToArray());
|
||||
byte[] ephemeralPublic = Curve25519.DecodePoint(outer.EphemeralPublic.Span);
|
||||
byte[] encryptedStatic = outer.EncryptedStatic.ToByteArray();
|
||||
byte[] encryptedMessage = outer.EncryptedMessage.ToByteArray();
|
||||
|
||||
byte[] ourIdPriv = ourIdentity.PrivateKey;
|
||||
byte[] ourIdPub = ourIdentity.PublicKey.Serialize(); // 33-byte DjbECPublicKey
|
||||
|
||||
// Ephemeral keys: HKDF(salt = "UnidentifiedDelivery" || ourPub || ephPub, ikm = ECDH(eph, ourId)).
|
||||
byte[] ephSalt = Concat(SaltPrefix, ourIdPub, Curve25519.EncodePoint(ephemeralPublic));
|
||||
byte[] ephSecret = Curve25519.CalculateAgreement(ephemeralPublic, ourIdPriv);
|
||||
byte[] ephKeys = CryptoPrimitives.Hkdf(ephSecret, ephSalt, info: null, 96);
|
||||
byte[] chainKey = ephKeys.AsSpan(0, 32).ToArray();
|
||||
byte[] ephCipherKey = ephKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] ephMacKey = ephKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
byte[] senderStaticBytes = DecryptCtrHmac(encryptedStatic, ephCipherKey, ephMacKey);
|
||||
byte[] senderStaticPublic = Curve25519.DecodePoint(senderStaticBytes);
|
||||
|
||||
// Static keys: HKDF(salt = chainKey || encryptedStatic, ikm = ECDH(senderStatic, ourId)); first 32 discarded.
|
||||
byte[] staticSalt = Concat(chainKey, encryptedStatic);
|
||||
byte[] staticSecret = Curve25519.CalculateAgreement(senderStaticPublic, ourIdPriv);
|
||||
byte[] staticKeys = CryptoPrimitives.Hkdf(staticSecret, staticSalt, info: null, 96);
|
||||
byte[] staticCipherKey = staticKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] staticMacKey = staticKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
byte[] innerBytes = DecryptCtrHmac(encryptedMessage, staticCipherKey, staticMacKey);
|
||||
UnidentifiedSenderMessage.Types.Message inner = UnidentifiedSenderMessage.Types.Message.Parser.ParseFrom(innerBytes);
|
||||
|
||||
// Validate the server-attested sender certificate (trust root → server → sender, not expired)
|
||||
// before believing the claimed sender.
|
||||
var senderCert = SenderCertificate.Parser.ParseFrom(inner.SenderCertificate);
|
||||
SenderCertificateValidator.Validate(senderCert, nowMs, trustRoots ?? SenderCertificateValidator.ProductionTrustRoots);
|
||||
|
||||
(string uuid, uint device) = ExtractSender(senderCert);
|
||||
return new Unsealed(uuid, device, (int)inner.Type, inner.Content.ToByteArray());
|
||||
}
|
||||
|
||||
/// <summary>Builds a Sealed Sender v1 envelope using a real (server-issued) sender certificate.
|
||||
/// Mirrors libsignal sealed_sender_encrypt.</summary>
|
||||
public static byte[] EncryptWithCertificate(IdentityKeyPair senderIdentity, IdentityKey recipientIdentity,
|
||||
byte[] senderCertificate, int ciphertextType, byte[] content)
|
||||
{
|
||||
byte[] recipientPubRaw = recipientIdentity.PublicKey;
|
||||
byte[] recipientPubEnc = recipientIdentity.Serialize();
|
||||
byte[] senderIdPubEnc = senderIdentity.PublicKey.Serialize();
|
||||
|
||||
ECKeyPair ephemeral = Curve25519.GenerateKeyPair();
|
||||
byte[] ephPubEnc = Curve25519.EncodePoint(ephemeral.PublicKey);
|
||||
|
||||
byte[] ephSalt = Concat(SaltPrefix, recipientPubEnc, ephPubEnc);
|
||||
byte[] ephSecret = Curve25519.CalculateAgreement(recipientPubRaw, ephemeral.PrivateKey);
|
||||
byte[] ephKeys = CryptoPrimitives.Hkdf(ephSecret, ephSalt, info: null, 96);
|
||||
byte[] chainKey = ephKeys.AsSpan(0, 32).ToArray();
|
||||
byte[] ephCipherKey = ephKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] ephMacKey = ephKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
byte[] encryptedStatic = EncryptCtrHmac(senderIdPubEnc, ephCipherKey, ephMacKey);
|
||||
|
||||
byte[] staticSalt = Concat(chainKey, encryptedStatic);
|
||||
byte[] staticSecret = Curve25519.CalculateAgreement(recipientPubRaw, senderIdentity.PrivateKey);
|
||||
byte[] staticKeys = CryptoPrimitives.Hkdf(staticSecret, staticSalt, info: null, 96);
|
||||
byte[] staticCipherKey = staticKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] staticMacKey = staticKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
var inner = new UnidentifiedSenderMessage.Types.Message
|
||||
{
|
||||
Type = (UnidentifiedSenderMessage.Types.Message.Types.Type)ciphertextType,
|
||||
SenderCertificate = Google.Protobuf.ByteString.CopyFrom(senderCertificate),
|
||||
Content = Google.Protobuf.ByteString.CopyFrom(content),
|
||||
};
|
||||
byte[] encryptedMessage = EncryptCtrHmac(inner.ToByteArray(), staticCipherKey, staticMacKey);
|
||||
|
||||
var outer = new UnidentifiedSenderMessage
|
||||
{
|
||||
EphemeralPublic = Google.Protobuf.ByteString.CopyFrom(ephPubEnc),
|
||||
EncryptedStatic = Google.Protobuf.ByteString.CopyFrom(encryptedStatic),
|
||||
EncryptedMessage = Google.Protobuf.ByteString.CopyFrom(encryptedMessage),
|
||||
};
|
||||
byte[] body = outer.ToByteArray();
|
||||
var result = new byte[1 + body.Length];
|
||||
result[0] = 0x11; // SEALED_SENDER_V1_FULL_VERSION
|
||||
Buffer.BlockCopy(body, 0, result, 1, body.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Test helper: builds a signed cert chain (trustRoot → server → sender) and seals with it.</summary>
|
||||
public static byte[] Encrypt(IdentityKeyPair senderIdentity, IdentityKey recipientIdentity,
|
||||
string senderUuid, uint senderDevice, int ciphertextType, byte[] content, ECKeyPair trustRoot, long expiresMs)
|
||||
{
|
||||
byte[] cert = BuildCertificate(senderIdentity, senderUuid, senderDevice, trustRoot, expiresMs);
|
||||
return EncryptWithCertificate(senderIdentity, recipientIdentity, cert, ciphertextType, content);
|
||||
}
|
||||
|
||||
private static byte[] BuildCertificate(IdentityKeyPair senderIdentity, string senderUuid, uint senderDevice,
|
||||
ECKeyPair trustRoot, long expiresMs)
|
||||
{
|
||||
ECKeyPair serverKey = Curve25519.GenerateKeyPair();
|
||||
var serverInner = new ServerCertificate.Types.Certificate
|
||||
{
|
||||
Id = 1,
|
||||
Key = Google.Protobuf.ByteString.CopyFrom(Curve25519.EncodePoint(serverKey.PublicKey)),
|
||||
};
|
||||
byte[] serverInnerBytes = serverInner.ToByteArray();
|
||||
var serverCert = new ServerCertificate
|
||||
{
|
||||
Certificate = Google.Protobuf.ByteString.CopyFrom(serverInnerBytes),
|
||||
Signature = Google.Protobuf.ByteString.CopyFrom(
|
||||
XEd25519.CalculateSignature(trustRoot.PrivateKey, serverInnerBytes, RandomNumberGenerator.GetBytes(64))),
|
||||
};
|
||||
var certInner = new SenderCertificate.Types.Certificate
|
||||
{
|
||||
UuidString = senderUuid,
|
||||
SenderDevice = senderDevice,
|
||||
Expires = (ulong)expiresMs,
|
||||
IdentityKey = Google.Protobuf.ByteString.CopyFrom(senderIdentity.PublicKey.Serialize()),
|
||||
Certificate_ = serverCert.ToByteString(),
|
||||
};
|
||||
byte[] certInnerBytes = certInner.ToByteArray();
|
||||
return new SenderCertificate
|
||||
{
|
||||
Certificate = Google.Protobuf.ByteString.CopyFrom(certInnerBytes),
|
||||
Signature = Google.Protobuf.ByteString.CopyFrom(
|
||||
XEd25519.CalculateSignature(serverKey.PrivateKey, certInnerBytes, RandomNumberGenerator.GetBytes(64))),
|
||||
}.ToByteArray();
|
||||
}
|
||||
|
||||
private static byte[] EncryptCtrHmac(byte[] plaintext, byte[] cipherKey, byte[] macKey)
|
||||
{
|
||||
byte[] ctext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], plaintext);
|
||||
byte[] mac = CryptoPrimitives.HmacSha256(macKey, ctext).AsSpan(0, 10).ToArray();
|
||||
var result = new byte[ctext.Length + 10];
|
||||
Buffer.BlockCopy(ctext, 0, result, 0, ctext.Length);
|
||||
Buffer.BlockCopy(mac, 0, result, ctext.Length, 10);
|
||||
return result;
|
||||
}
|
||||
|
||||
// aes256_ctr_hmacsha256: data = AES-256-CTR(ct) || HMAC-SHA256(macKey, ct)[..10]. Zero CTR nonce.
|
||||
private static byte[] DecryptCtrHmac(byte[] data, byte[] cipherKey, byte[] macKey)
|
||||
{
|
||||
const int macLen = 10;
|
||||
if (data.Length < macLen) throw new InvalidMessageException("sealed sender ciphertext truncated");
|
||||
int ctLen = data.Length - macLen;
|
||||
byte[] ctext = data.AsSpan(0, ctLen).ToArray();
|
||||
byte[] theirMac = data.AsSpan(ctLen, macLen).ToArray();
|
||||
|
||||
byte[] ourMac = CryptoPrimitives.HmacSha256(macKey, ctext).AsSpan(0, macLen).ToArray();
|
||||
if (!CryptographicOperations.FixedTimeEquals(ourMac, theirMac))
|
||||
throw new InvalidMessageException("sealed sender MAC mismatch");
|
||||
|
||||
return CryptoPrimitives.AesCtr(cipherKey, new byte[16], ctext);
|
||||
}
|
||||
|
||||
private static (string Uuid, uint Device) ExtractSender(SenderCertificate cert)
|
||||
{
|
||||
var inner = SenderCertificate.Types.Certificate.Parser.ParseFrom(cert.Certificate);
|
||||
|
||||
string uuid = inner.SenderUuidCase switch
|
||||
{
|
||||
SenderCertificate.Types.Certificate.SenderUuidOneofCase.UuidString => inner.UuidString,
|
||||
SenderCertificate.Types.Certificate.SenderUuidOneofCase.UuidBytes => ServiceIds.StringFromBinary(inner.UuidBytes.Span) ?? "",
|
||||
_ => string.Empty,
|
||||
};
|
||||
if (string.IsNullOrEmpty(uuid))
|
||||
throw new InvalidMessageException("sealed sender certificate has no sender uuid");
|
||||
return (uuid.ToLowerInvariant(), inner.SenderDevice);
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] parts)
|
||||
{
|
||||
var result = new byte[parts.Sum(p => p.Length)];
|
||||
int o = 0;
|
||||
foreach (byte[] p in parts) { Buffer.BlockCopy(p, 0, result, o, p.Length); o += p.Length; }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Service.Protos.SealedSender;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a sealed-sender <see cref="SenderCertificate"/> against Signal's unidentified-sender trust
|
||||
/// root: the trust root must have signed the embedded <see cref="ServerCertificate"/>, that server key
|
||||
/// must have signed the sender certificate, and the certificate must not have expired. This is what
|
||||
/// proves the claimed sender ACI/device is server-attested (not just self-asserted). Mirrors libsignal
|
||||
/// sealed_sender.rs <c>SenderCertificate::validate</c>.
|
||||
/// </summary>
|
||||
public static class SenderCertificateValidator
|
||||
{
|
||||
/// <summary>Signal's production unidentified-sender trust roots (33-byte DjbECPublicKey).</summary>
|
||||
public static readonly IReadOnlyList<byte[]> ProductionTrustRoots = new[]
|
||||
{
|
||||
Convert.FromBase64String("BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF"),
|
||||
Convert.FromBase64String("BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6"),
|
||||
};
|
||||
|
||||
/// <summary>Server certificates that real sender certificates reference by id (field 8) instead of
|
||||
/// embedding (field 5), to save space — keyed by id. From libsignal's <c>KNOWN_SERVER_CERTIFICATES</c>
|
||||
/// (id 2 = staging, id 3 = production). These are the full serialized <see cref="ServerCertificate"/> protobufs.</summary>
|
||||
private static readonly IReadOnlyDictionary<uint, byte[]> KnownServerCertificates = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = Convert.FromHexString(
|
||||
"0a25080212210539450d63ebd0752c0fd4038b9d07a916f5e174b756d409b5ca79f4c97400631e" +
|
||||
"124064c5a38b1e927497d3d4786b101a623ab34a7da3954fae126b04dba9d7a3604ed88cdc8550950f0d4a9134ceb7e19b94139151d2c3d6e1c81e9d1128aafca806"),
|
||||
[3] = Convert.FromHexString(
|
||||
"0a250803122105bc9d1d290be964810dfa7e94856480a3f7060d004c9762c24c575a1522353a5a" +
|
||||
"1240c11ec3c401eb0107ab38f8600e8720a63169e0e2eb8a3fae24f63099f85ea319c3c1c46d3454706ae2a679d1fee690a488adda98a2290b66c906bb60295ed781"),
|
||||
};
|
||||
|
||||
public static void Validate(SenderCertificate cert, long nowMs, IReadOnlyList<byte[]> trustRoots)
|
||||
{
|
||||
SenderCertificate.Types.Certificate inner =
|
||||
SenderCertificate.Types.Certificate.Parser.ParseFrom(cert.Certificate);
|
||||
|
||||
// The signer is either embedded (field 5) or referenced by id (field 8); real Signal certs use the id.
|
||||
byte[] serverCertBytes = inner.SignerCase switch
|
||||
{
|
||||
SenderCertificate.Types.Certificate.SignerOneofCase.Certificate_ => inner.Certificate_.ToByteArray(),
|
||||
SenderCertificate.Types.Certificate.SignerOneofCase.Id when KnownServerCertificates.TryGetValue(inner.Id, out byte[]? c) => c,
|
||||
SenderCertificate.Types.Certificate.SignerOneofCase.Id =>
|
||||
throw new InvalidMessageException($"unknown sealed-sender server certificate id {inner.Id}"),
|
||||
_ => throw new InvalidMessageException("sender certificate has no server certificate"),
|
||||
};
|
||||
if ((long)inner.Expires < nowMs)
|
||||
throw new InvalidMessageException("sender certificate expired");
|
||||
|
||||
// 1) The trust root must have signed the server certificate.
|
||||
ServerCertificate server = ServerCertificate.Parser.ParseFrom(serverCertBytes);
|
||||
bool serverTrusted = trustRoots.Any(root =>
|
||||
XEd25519.VerifySignature(Curve25519.DecodePoint(root),
|
||||
server.Certificate.Span, server.Signature.Span));
|
||||
if (!serverTrusted)
|
||||
throw new InvalidMessageException("server certificate not signed by a trust root");
|
||||
|
||||
// 2) The server key must have signed the sender certificate.
|
||||
ServerCertificate.Types.Certificate serverInner =
|
||||
ServerCertificate.Types.Certificate.Parser.ParseFrom(server.Certificate);
|
||||
byte[] serverKey = Curve25519.DecodePoint(serverInner.Key.Span);
|
||||
if (!XEd25519.VerifySignature(serverKey, cert.Certificate.Span, cert.Signature.Span))
|
||||
throw new InvalidMessageException("sender certificate not signed by the server");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Attachments;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Applies inbound <see cref="SyncMessage"/>s pushed by the primary device: downloads + imports the
|
||||
/// contacts blob (SyncMessage.Contacts → <see cref="ContactsStore"/>) so the conversation list can show
|
||||
/// names, and records read state (SyncMessage.Read). The contact-import mapping is factored into
|
||||
/// <see cref="ImportContacts"/> so it can be tested offline without the CDN.
|
||||
/// </summary>
|
||||
public sealed class SyncProcessor
|
||||
{
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly AttachmentDownloader _downloader;
|
||||
|
||||
/// <summary>Raised for each read receipt synced from the primary (senderAci, message timestamp).</summary>
|
||||
public event Action<string, long>? ReadReceiptReceived;
|
||||
|
||||
public SyncProcessor(ContactsStore contacts, AttachmentDownloader? downloader = null)
|
||||
{
|
||||
_contacts = contacts;
|
||||
_downloader = downloader ?? new AttachmentDownloader();
|
||||
}
|
||||
|
||||
public async Task ProcessAsync(SyncMessage sync, CancellationToken ct = default)
|
||||
{
|
||||
if (sync.Contacts?.Blob is { } pointer)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] blob = await _downloader.DownloadAsync(pointer, ct).ConfigureAwait(false);
|
||||
int n = ImportContacts(blob);
|
||||
FileLog.Write($"sync: imported {n} contact(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"sync: contacts import failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SyncMessage.Types.Read read in sync.Read)
|
||||
{
|
||||
string? aci = AciOf(read.SenderAci, read.SenderAciBinary);
|
||||
if (aci is not null)
|
||||
ReadReceiptReceived?.Invoke(aci, (long)read.Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted contacts blob and upserts each contact. Returns the count. Pure
|
||||
/// (no network) — the offline-testable core of contacts sync.</summary>
|
||||
public int ImportContacts(byte[] blob)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
||||
{
|
||||
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
|
||||
if (aci is null) continue; // ACI-less contacts (e164-only) can't key a conversation yet
|
||||
|
||||
string? name = string.IsNullOrWhiteSpace(record.Details.Name) ? null : record.Details.Name;
|
||||
string? number = string.IsNullOrWhiteSpace(record.Details.Number) ? null : record.Details.Number;
|
||||
_contacts.Upsert(new Contact(aci, number, name, (int)record.Details.InboxPosition));
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>Prefers the string ACI; falls back to the 16-byte binary form. Returns lowercase
|
||||
/// canonical UUID, or null if neither is present/valid.</summary>
|
||||
private static string? AciOf(string asString, ByteString binary)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(asString) && Guid.TryParse(asString, out Guid g))
|
||||
return g.ToString("D").ToLowerInvariant();
|
||||
return ServiceIds.StringFromBinary(binary.Span);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
// ── GET /v2/keys/{identifier}/{deviceId} response ──
|
||||
|
||||
public sealed class PreKeyResponse
|
||||
{
|
||||
public string IdentityKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||
public PreKeyResponseDevice[] Devices { get; set; } = Array.Empty<PreKeyResponseDevice>();
|
||||
}
|
||||
|
||||
public sealed class PreKeyResponseDevice
|
||||
{
|
||||
public uint DeviceId { get; set; }
|
||||
public uint RegistrationId { get; set; }
|
||||
public PreKeyDto? PreKey { get; set; } // optional one-time EC prekey
|
||||
public SignedPreKeyDto SignedPreKey { get; set; } = new();
|
||||
public SignedPreKeyDto? PqPreKey { get; set; } // ML-KEM (Kyber) signed prekey
|
||||
}
|
||||
|
||||
public sealed class PreKeyDto
|
||||
{
|
||||
public uint KeyId { get; set; }
|
||||
public string PublicKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||
}
|
||||
|
||||
public sealed class SignedPreKeyDto
|
||||
{
|
||||
public uint KeyId { get; set; }
|
||||
public string PublicKey { get; set; } = ""; // base64 (EC: 0x05‖32; Kyber: 0x08‖1568)
|
||||
public string Signature { get; set; } = ""; // base64
|
||||
}
|
||||
|
||||
// ── PUT /v1/messages/{destination} request ──
|
||||
|
||||
public sealed class OutgoingMessageList
|
||||
{
|
||||
public OutgoingMessage[] Messages { get; set; } = Array.Empty<OutgoingMessage>();
|
||||
public long Timestamp { get; set; }
|
||||
public bool Online { get; set; }
|
||||
public bool Urgent { get; set; }
|
||||
}
|
||||
|
||||
// ── PUT /v2/keys?identity={aci|pni} request (upload one-time prekeys) ──
|
||||
|
||||
public sealed class SetKeysRequest
|
||||
{
|
||||
public PreKeyEntity[]? PreKeys { get; set; } // one-time EC prekeys
|
||||
}
|
||||
|
||||
public sealed class PreKeyEntity
|
||||
{
|
||||
public uint KeyId { get; set; }
|
||||
public string PublicKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||
}
|
||||
|
||||
public sealed class OutgoingMessage
|
||||
{
|
||||
public int Type { get; set; } // envelope type: 3=PREKEY_MESSAGE, 1=DOUBLE_RATCHET
|
||||
public uint DestinationDeviceId { get; set; }
|
||||
public uint DestinationRegistrationId { get; set; }
|
||||
public string Content { get; set; } = ""; // base64 of the serialized ciphertext
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
// JSON DTOs for PUT /v1/devices/link. Field names match the Signal service contract.
|
||||
|
||||
public sealed record SignedPreKeyEntity(
|
||||
[property: JsonPropertyName("keyId")] uint KeyId,
|
||||
[property: JsonPropertyName("publicKey")] string PublicKey,
|
||||
[property: JsonPropertyName("signature")] string Signature);
|
||||
|
||||
public sealed record KyberPreKeyEntity(
|
||||
[property: JsonPropertyName("keyId")] uint KeyId,
|
||||
[property: JsonPropertyName("publicKey")] string PublicKey,
|
||||
[property: JsonPropertyName("signature")] string Signature);
|
||||
|
||||
public sealed record AccountAttributes(
|
||||
[property: JsonPropertyName("fetchesMessages")] bool FetchesMessages,
|
||||
[property: JsonPropertyName("registrationId")] uint RegistrationId,
|
||||
[property: JsonPropertyName("pniRegistrationId")] uint PniRegistrationId,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("capabilities")] AccountCapabilities Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Linked-device capability flags, serialized as a {name: bool} map. The server keeps the true
|
||||
/// entries with known names. New devices must declare <c>spqr</c> (required for new devices) and must
|
||||
/// not drop downgrade-protected capabilities the account already has (e.g. usernameChangeSyncMessage).
|
||||
/// </summary>
|
||||
public sealed record AccountCapabilities(
|
||||
[property: JsonPropertyName("storage")] bool Storage = true,
|
||||
[property: JsonPropertyName("spqr")] bool Spqr = true,
|
||||
[property: JsonPropertyName("usernameChangeSyncMessage")] bool UsernameChangeSyncMessage = true);
|
||||
|
||||
public sealed record LinkDeviceRequest(
|
||||
[property: JsonPropertyName("verificationCode")] string VerificationCode,
|
||||
[property: JsonPropertyName("accountAttributes")] AccountAttributes AccountAttributes,
|
||||
[property: JsonPropertyName("aciSignedPreKey")] SignedPreKeyEntity AciSignedPreKey,
|
||||
[property: JsonPropertyName("pniSignedPreKey")] SignedPreKeyEntity PniSignedPreKey,
|
||||
[property: JsonPropertyName("aciPqLastResortPreKey")] KyberPreKeyEntity AciPqLastResortPreKey,
|
||||
[property: JsonPropertyName("pniPqLastResortPreKey")] KyberPreKeyEntity PniPqLastResortPreKey);
|
||||
|
||||
public sealed record LinkDeviceResponse(
|
||||
[property: JsonPropertyName("uuid")] string Uuid,
|
||||
[property: JsonPropertyName("pni")] string Pni,
|
||||
[property: JsonPropertyName("deviceId")] int DeviceId);
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>Thin typed HTTP client for the Signal account/keys/messages REST API.</summary>
|
||||
public sealed class SignalRestClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public SignalRestClient(HttpClient? http = null)
|
||||
{
|
||||
_http = http ?? CreatePinnedClient();
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(SignalServiceConfig.UserAgent);
|
||||
_http.DefaultRequestHeaders.Add("X-Signal-Agent", SignalServiceConfig.UserAgent);
|
||||
}
|
||||
|
||||
private static HttpClient CreatePinnedClient()
|
||||
{
|
||||
var handler = new SocketsHttpHandler();
|
||||
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||
(sender, cert, chain, errors) => SignalTrust.Validate(sender, cert, chain, errors);
|
||||
return new HttpClient(handler) { BaseAddress = new Uri(SignalServiceConfig.ServiceUrl) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers this client as a new secondary device. Authenticates with Basic(number:password)
|
||||
/// using the password this device generated; returns the assigned aci/pni/deviceId.
|
||||
/// </summary>
|
||||
public async Task<LinkDeviceResponse> LinkDeviceAsync(
|
||||
string number, string password, LinkDeviceRequest request, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, "/v1/devices/link")
|
||||
{
|
||||
Content = JsonContent.Create(request),
|
||||
};
|
||||
string token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{number}:{password}"));
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", token);
|
||||
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException(
|
||||
$"device link failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<LinkDeviceResponse>(ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty link response");
|
||||
}
|
||||
|
||||
/// <summary>Fetches prekey bundles for a recipient. <paramref name="deviceId"/> may be "*" for all
|
||||
/// of the recipient's devices. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||
public async Task<PreKeyResponse> GetPreKeysAsync(string serviceId, string deviceId, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v2/keys/{serviceId}/{deviceId}");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"get prekeys failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
return await response.Content.ReadFromJsonAsync<PreKeyResponse>(Json, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty prekey response");
|
||||
}
|
||||
|
||||
/// <summary>Sends a list of per-device encrypted messages to a destination. Returns the raw body on
|
||||
/// a device-mismatch (409) / stale-devices (410) so the caller can react; throws on other failures.</summary>
|
||||
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendMessagesAsync(
|
||||
string serviceId, OutgoingMessageList messages, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v1/messages/{serviceId}")
|
||||
{
|
||||
Content = JsonContent.Create(messages, options: Json),
|
||||
};
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.Gone)
|
||||
return (false, response.StatusCode, body);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"send failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
return (true, response.StatusCode, body);
|
||||
}
|
||||
|
||||
/// <summary>Uploads prekeys for an identity (PUT /v2/keys?identity={aci|pni}). Used to register
|
||||
/// one-time prekeys after linking. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||
public async Task UploadPreKeysAsync(string identity, SetKeysRequest request, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v2/keys?identity={identity}")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: Json),
|
||||
};
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"upload prekeys failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-polls <c>GET /v1/devices/transfer_archive</c> for the link'n'sync message-history archive
|
||||
/// the primary uploads after linking. Returns the descriptor (cdn/key, or an error), or null on a
|
||||
/// 204 timeout (poll again). Auth: Basic {aci.deviceId:password}. SCAFFOLD — see docs/SYNC.md; the
|
||||
/// download+import side isn't built yet.
|
||||
/// </summary>
|
||||
public async Task<TransferArchiveDescriptor?> WaitForTransferArchiveAsync(string authToken, int timeoutSeconds, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v1/devices/transfer_archive?timeout={timeoutSeconds}");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return null; // long-poll elapsed with no archive yet
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"wait transfer archive failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
return await response.Content.ReadFromJsonAsync<TransferArchiveDescriptor>(Json, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Fetches a sealed-sender delivery certificate (GET /v1/certificate/delivery). Returns the
|
||||
/// raw SenderCertificate bytes, valid ~24h; callers should cache it.</summary>
|
||||
public async Task<byte[]> GetSenderCertificateAsync(string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, "/v1/certificate/delivery");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"get delivery certificate failed: {(int)response.StatusCode} {body}");
|
||||
}
|
||||
var dto = await response.Content.ReadFromJsonAsync<DeliveryCertificateDto>(Json, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty certificate response");
|
||||
return Convert.FromBase64String(dto.Certificate ?? throw new InvalidOperationException("no certificate"));
|
||||
}
|
||||
|
||||
/// <summary>Sends sealed-sender messages: NO account auth, just the recipient's unidentified-access
|
||||
/// key header. Metadata-minimized. Returns the same (ok/status/body) shape as the authenticated send
|
||||
/// so callers can fall back on rejection.</summary>
|
||||
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendSealedMessagesAsync(
|
||||
string serviceId, OutgoingMessageList messages, byte[] accessKey, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v1/messages/{serviceId}")
|
||||
{
|
||||
Content = JsonContent.Create(messages, options: Json),
|
||||
};
|
||||
msg.Headers.Add("Unidentified-Access-Key", Convert.ToBase64String(accessKey));
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return (response.IsSuccessStatusCode, response.StatusCode, body);
|
||||
}
|
||||
|
||||
private sealed class DeliveryCertificateDto { public string? Certificate { get; set; } }
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>Endpoints and identifiers for talking to the Signal production service.</summary>
|
||||
public static class SignalServiceConfig
|
||||
{
|
||||
/// <summary>HTTPS base for the chat/account REST API.</summary>
|
||||
public const string ServiceUrl = "https://chat.signal.org";
|
||||
|
||||
/// <summary>WSS base for the authenticated and provisioning WebSockets.</summary>
|
||||
public const string WebSocketUrl = "wss://chat.signal.org";
|
||||
|
||||
/// <summary>HTTPS base for the group storage service (GroupsV2). Separate host from chat, but chains to
|
||||
/// the same bundled Signal CA (<c>SignalTrust</c>), so the existing pin applies.</summary>
|
||||
public const string StorageUrl = "https://storage.signal.org";
|
||||
|
||||
/// <summary>Unauthenticated socket used during secondary-device linking.</summary>
|
||||
public const string ProvisioningWebSocketPath = "/v1/websocket/provisioning/";
|
||||
|
||||
/// <summary>Authenticated chat socket (used post-link to send/receive messages).</summary>
|
||||
public const string ChatWebSocketPath = "/v1/websocket/";
|
||||
|
||||
/// <summary>Sent as the User-Agent / X-Signal-Agent on requests.</summary>
|
||||
public const string UserAgent = "Wingnal";
|
||||
|
||||
/// <summary>CDN base URL for an AttachmentPointer's <c>cdnNumber</c>. 0/absent = legacy cdn0; 2/3 are
|
||||
/// the current attachment/backup CDNs. (cdn1 was retired.)</summary>
|
||||
public static string CdnUrl(uint cdnNumber) => cdnNumber switch
|
||||
{
|
||||
0 => "https://cdn.signal.org",
|
||||
2 => "https://cdn2.signal.org",
|
||||
3 => "https://cdn3.signal.org",
|
||||
_ => "https://cdn2.signal.org",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net.Security;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Certificate pinning for Signal's service. chat.signal.org is served from Signal's own private
|
||||
/// root CA (not a publicly trusted authority), so the OS trust store rejects it. This validates the
|
||||
/// server chain against the bundled Signal root only — and trusts nothing else.
|
||||
/// </summary>
|
||||
public static class SignalTrust
|
||||
{
|
||||
private static readonly X509Certificate2 SignalRootCa = LoadRootCa();
|
||||
|
||||
/// <summary>Validation callback for <see cref="System.Net.Http.SocketsHttpHandler"/> and
|
||||
/// <see cref="System.Net.WebSockets.ClientWebSocket"/>: accept only chains anchored at the
|
||||
/// pinned Signal root with a matching hostname.</summary>
|
||||
public static bool Validate(object sender, X509Certificate? certificate, X509Chain? _, SslPolicyErrors errors)
|
||||
{
|
||||
// The hostname is checked by the TLS stack before this callback; never accept a mismatch.
|
||||
if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||
return false;
|
||||
if (certificate is null)
|
||||
return false;
|
||||
|
||||
using var leaf = new X509Certificate2(certificate);
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(SignalRootCa);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
return chain.Build(leaf);
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadRootCa()
|
||||
{
|
||||
Assembly assembly = typeof(SignalTrust).Assembly;
|
||||
const string resource = "Wingnal.Service.Resources.signal-ca.pem";
|
||||
using Stream stream = assembly.GetManifestResourceStream(resource)
|
||||
?? throw new InvalidOperationException($"embedded resource {resource} not found");
|
||||
using var reader = new StreamReader(stream);
|
||||
return X509Certificate2.CreateFromPem(reader.ReadToEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net.WebSockets;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="ClientWebSocket"/> with Signal's request/response framing: every binary frame
|
||||
/// is a <see cref="WebSocketMessage"/>. Incoming REQUEST frames are surfaced to the caller; the
|
||||
/// caller answers them with <see cref="SendResponseAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class SignalWebSocket : IDisposable
|
||||
{
|
||||
private readonly ClientWebSocket _socket = new();
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
public async Task ConnectAsync(Uri uri, IReadOnlyDictionary<string, string>? headers, CancellationToken ct)
|
||||
{
|
||||
_socket.Options.AddSubProtocol("binary");
|
||||
_socket.Options.RemoteCertificateValidationCallback = SignalTrust.Validate;
|
||||
_socket.Options.SetRequestHeader("X-Signal-Agent", SignalServiceConfig.UserAgent);
|
||||
if (headers is not null)
|
||||
foreach ((string name, string value) in headers)
|
||||
_socket.Options.SetRequestHeader(name, value);
|
||||
|
||||
await _socket.ConnectAsync(uri, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Reads the next inbound REQUEST frame, or null if the socket closed.</summary>
|
||||
public async Task<WebSocketRequestMessage?> ReadRequestAsync(CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
WebSocketMessage? message = await ReadMessageAsync(ct).ConfigureAwait(false);
|
||||
if (message is null)
|
||||
return null;
|
||||
if (message.Type == WebSocketMessage.Types.Type.Request && message.Request is not null)
|
||||
return message.Request;
|
||||
// RESPONSE / keepalive frames are ignored for the provisioning flow.
|
||||
}
|
||||
}
|
||||
|
||||
public Task SendResponseAsync(ulong id, uint status, string message, CancellationToken ct)
|
||||
{
|
||||
var frame = new WebSocketMessage
|
||||
{
|
||||
Type = WebSocketMessage.Types.Type.Response,
|
||||
Response = new WebSocketResponseMessage { Id = id, Status = status, Message = message },
|
||||
};
|
||||
return SendMessageAsync(frame, ct);
|
||||
}
|
||||
|
||||
/// <summary>Sends a keepalive request (GET /v1/keepalive) to keep the server from dropping us.</summary>
|
||||
public Task SendKeepAliveAsync(ulong id, CancellationToken ct)
|
||||
{
|
||||
var frame = new WebSocketMessage
|
||||
{
|
||||
Type = WebSocketMessage.Types.Type.Request,
|
||||
Request = new WebSocketRequestMessage { Id = id, Verb = "GET", Path = "/v1/keepalive" },
|
||||
};
|
||||
return SendMessageAsync(frame, ct);
|
||||
}
|
||||
|
||||
/// <summary>Describes why the last read ended (close status/description, or the live socket state).</summary>
|
||||
public string CloseReason =>
|
||||
$"state={_socket.State} status={_socket.CloseStatus} desc={_socket.CloseStatusDescription}";
|
||||
|
||||
private async Task<WebSocketMessage?> ReadMessageAsync(CancellationToken ct)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[8192];
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await _socket.ReceiveAsync(chunk, ct).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
return null;
|
||||
buffer.Write(chunk, 0, result.Count);
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
return WebSocketMessage.Parser.ParseFrom(buffer.ToArray());
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync(WebSocketMessage message, CancellationToken ct)
|
||||
{
|
||||
byte[] bytes = message.ToByteArray();
|
||||
await _sendLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Binary, endOfMessage: true, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_socket.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>
|
||||
/// The descriptor the server returns from <c>GET /v1/devices/transfer_archive</c> once the primary has
|
||||
/// uploaded the link'n'sync message-history archive (Signal-Server <c>RemoteAttachment</c>). The archive
|
||||
/// itself is fetched from the CDN and decrypted with keys derived from the provisioning
|
||||
/// <c>ephemeralBackupKey</c>. See docs/SYNC.md.
|
||||
/// </summary>
|
||||
public sealed class TransferArchiveDescriptor
|
||||
{
|
||||
public int Cdn { get; set; }
|
||||
public string? Key { get; set; }
|
||||
|
||||
/// <summary>Set instead of cdn/key when the primary reported it couldn't produce an archive
|
||||
/// (Signal-Server <c>RemoteAttachmentError</c>: e.g. CONTINUE_WITHOUT_UPLOAD / RELINK_REQUESTED).</summary>
|
||||
public string? Error { get; set; }
|
||||
|
||||
public bool IsError => !string.IsNullOrEmpty(Error);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package signal;
|
||||
|
||||
option java_package = "org.signal.storageservice.storage.protos.groups";
|
||||
option java_outer_classname = "GroupProtos";
|
||||
option java_multiple_files = true;
|
||||
option csharp_namespace = "Wingnal.Service.Protos.Groups";
|
||||
|
||||
message AvatarUploadAttributes {
|
||||
string key = 1;
|
||||
string credential = 2;
|
||||
string acl = 3;
|
||||
string algorithm = 4;
|
||||
string date = 5;
|
||||
string policy = 6;
|
||||
string signature = 7;
|
||||
}
|
||||
|
||||
// Stored data
|
||||
|
||||
message Member {
|
||||
enum Role {
|
||||
UNKNOWN = 0;
|
||||
DEFAULT = 1;
|
||||
ADMINISTRATOR = 2;
|
||||
}
|
||||
|
||||
bytes userId = 1;
|
||||
Role role = 2;
|
||||
bytes profileKey = 3;
|
||||
bytes presentation = 4;
|
||||
uint32 joinedAtVersion = 5;
|
||||
bytes labelEmoji = 6; // decrypts to a UTF-8 string
|
||||
bytes labelString = 7; // decrypts to a UTF-8 string
|
||||
}
|
||||
|
||||
message MemberPendingProfileKey {
|
||||
Member member = 1;
|
||||
bytes addedByUserId = 2;
|
||||
uint64 timestamp = 3; // ms since epoch
|
||||
}
|
||||
|
||||
message MemberPendingAdminApproval {
|
||||
bytes userId = 1;
|
||||
bytes profileKey = 2;
|
||||
bytes presentation = 3;
|
||||
uint64 timestamp = 4; // ms since epoch
|
||||
}
|
||||
|
||||
message MemberBanned {
|
||||
bytes userId = 1;
|
||||
uint64 timestamp = 2; // ms since epoch
|
||||
}
|
||||
|
||||
message AccessControl {
|
||||
enum AccessRequired {
|
||||
UNKNOWN = 0;
|
||||
ANY = 1;
|
||||
MEMBER = 2;
|
||||
ADMINISTRATOR = 3;
|
||||
UNSATISFIABLE = 4;
|
||||
}
|
||||
|
||||
AccessRequired attributes = 1;
|
||||
AccessRequired members = 2;
|
||||
AccessRequired addFromInviteLink = 3;
|
||||
AccessRequired memberLabel = 4;
|
||||
}
|
||||
|
||||
message Group {
|
||||
bytes publicKey = 1;
|
||||
bytes title = 2;
|
||||
bytes description = 11;
|
||||
// The URL for this group's avatar. The content at this URL can be
|
||||
// decrypted/deserialized into a `GroupAttributeBlob`.
|
||||
string avatarUrl = 3;
|
||||
bytes disappearingMessagesTimer = 4;
|
||||
AccessControl accessControl = 5;
|
||||
uint32 version = 6;
|
||||
repeated Member members = 7;
|
||||
repeated MemberPendingProfileKey membersPendingProfileKey = 8;
|
||||
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
|
||||
bytes inviteLinkPassword = 10;
|
||||
bool announcements_only = 12;
|
||||
repeated MemberBanned members_banned = 13;
|
||||
bool terminated = 14;
|
||||
// next: 15
|
||||
}
|
||||
|
||||
message GroupAttributeBlob {
|
||||
oneof content {
|
||||
string title = 1;
|
||||
bytes avatar = 2;
|
||||
uint32 disappearingMessagesDuration = 3;
|
||||
string descriptionText = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupInviteLink {
|
||||
message GroupInviteLinkContentsV1 {
|
||||
bytes groupMasterKey = 1;
|
||||
bytes inviteLinkPassword = 2;
|
||||
}
|
||||
|
||||
oneof contents {
|
||||
GroupInviteLinkContentsV1 contentsV1 = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupJoinInfo {
|
||||
bytes publicKey = 1;
|
||||
bytes title = 2;
|
||||
bytes description = 8;
|
||||
string avatar = 3;
|
||||
uint32 memberCount = 4;
|
||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||
uint32 version = 6;
|
||||
bool pendingAdminApproval = 7;
|
||||
// bool pendingAdminApprovalFull = 9;
|
||||
// next: 10
|
||||
}
|
||||
|
||||
// Deltas
|
||||
|
||||
message GroupChange {
|
||||
|
||||
message Actions {
|
||||
|
||||
message AddMemberAction {
|
||||
Member added = 1;
|
||||
bool joinFromInviteLink = 2;
|
||||
}
|
||||
|
||||
message DeleteMemberAction {
|
||||
bytes deletedUserId = 1;
|
||||
}
|
||||
|
||||
message ModifyMemberRoleAction {
|
||||
bytes userId = 1;
|
||||
Member.Role role = 2;
|
||||
}
|
||||
|
||||
message ModifyMemberLabelAction {
|
||||
bytes userId = 1;
|
||||
bytes labelEmoji = 2; // decrypts to a UTF-8 string
|
||||
bytes labelString = 3; // decrypts to a UTF-8 string
|
||||
}
|
||||
|
||||
message ModifyMemberProfileKeyAction {
|
||||
bytes presentation = 1;
|
||||
bytes user_id = 2;
|
||||
bytes profile_key = 3;
|
||||
}
|
||||
|
||||
message AddMemberPendingProfileKeyAction {
|
||||
MemberPendingProfileKey added = 1;
|
||||
}
|
||||
|
||||
message DeleteMemberPendingProfileKeyAction {
|
||||
bytes deletedUserId = 1;
|
||||
}
|
||||
|
||||
message PromoteMemberPendingProfileKeyAction {
|
||||
bytes presentation = 1;
|
||||
bytes user_id = 2;
|
||||
bytes profile_key = 3;
|
||||
}
|
||||
|
||||
message PromoteMemberPendingPniAciProfileKeyAction {
|
||||
bytes presentation = 1;
|
||||
bytes user_id = 2;
|
||||
bytes pni = 3;
|
||||
bytes profile_key = 4;
|
||||
}
|
||||
|
||||
message AddMemberPendingAdminApprovalAction {
|
||||
MemberPendingAdminApproval added = 1;
|
||||
}
|
||||
|
||||
message DeleteMemberPendingAdminApprovalAction {
|
||||
bytes deletedUserId = 1;
|
||||
}
|
||||
|
||||
message PromoteMemberPendingAdminApprovalAction {
|
||||
bytes userId = 1;
|
||||
Member.Role role = 2;
|
||||
}
|
||||
|
||||
message AddMemberBannedAction {
|
||||
MemberBanned added = 1;
|
||||
}
|
||||
|
||||
message DeleteMemberBannedAction {
|
||||
bytes deletedUserId = 1;
|
||||
}
|
||||
|
||||
message ModifyTitleAction {
|
||||
bytes title = 1;
|
||||
}
|
||||
|
||||
message ModifyDescriptionAction {
|
||||
bytes description = 1;
|
||||
}
|
||||
|
||||
message ModifyAvatarAction {
|
||||
string avatar = 1;
|
||||
}
|
||||
|
||||
message ModifyDisappearingMessageTimerAction {
|
||||
bytes timer = 1;
|
||||
}
|
||||
|
||||
message ModifyAttributesAccessControlAction {
|
||||
AccessControl.AccessRequired attributesAccess = 1;
|
||||
}
|
||||
|
||||
message ModifyMembersAccessControlAction {
|
||||
AccessControl.AccessRequired membersAccess = 1;
|
||||
}
|
||||
|
||||
message ModifyAddFromInviteLinkAccessControlAction {
|
||||
AccessControl.AccessRequired addFromInviteLinkAccess = 1;
|
||||
}
|
||||
|
||||
message ModifyMemberLabelAccessControlAction {
|
||||
AccessControl.AccessRequired memberLabelAccess = 1;
|
||||
}
|
||||
|
||||
message ModifyInviteLinkPasswordAction {
|
||||
bytes inviteLinkPassword = 1;
|
||||
}
|
||||
|
||||
message ModifyAnnouncementsOnlyAction {
|
||||
bool announcements_only = 1;
|
||||
}
|
||||
|
||||
message TerminateGroupAction {}
|
||||
|
||||
bytes sourceUserId = 1;
|
||||
// clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group
|
||||
// if clients set it during a request the server will respond with 400.
|
||||
bytes group_id = 25;
|
||||
uint32 version = 2;
|
||||
|
||||
repeated AddMemberAction addMembers = 3;
|
||||
repeated DeleteMemberAction deleteMembers = 4;
|
||||
repeated ModifyMemberRoleAction modifyMemberRoles = 5;
|
||||
repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6;
|
||||
repeated AddMemberPendingProfileKeyAction addMembersPendingProfileKey = 7;
|
||||
repeated DeleteMemberPendingProfileKeyAction deleteMembersPendingProfileKey = 8;
|
||||
repeated PromoteMemberPendingProfileKeyAction promoteMembersPendingProfileKey = 9;
|
||||
ModifyTitleAction modifyTitle = 10;
|
||||
ModifyAvatarAction modifyAvatar = 11;
|
||||
ModifyDisappearingMessageTimerAction modifyDisappearingMessageTimer = 12;
|
||||
ModifyAttributesAccessControlAction modifyAttributesAccess = 13;
|
||||
ModifyMembersAccessControlAction modifyMemberAccess = 14;
|
||||
ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1
|
||||
repeated AddMemberPendingAdminApprovalAction addMembersPendingAdminApproval = 16; // change epoch = 1
|
||||
repeated DeleteMemberPendingAdminApprovalAction deleteMembersPendingAdminApproval = 17; // change epoch = 1
|
||||
repeated PromoteMemberPendingAdminApprovalAction promoteMembersPendingAdminApproval = 18; // change epoch = 1
|
||||
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
|
||||
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
|
||||
ModifyAnnouncementsOnlyAction modify_announcements_only = 21; // change epoch = 3
|
||||
repeated AddMemberBannedAction add_members_banned = 22; // change epoch = 4
|
||||
repeated DeleteMemberBannedAction delete_members_banned = 23; // change epoch = 4
|
||||
repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5
|
||||
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6;
|
||||
ModifyMemberLabelAccessControlAction modifyMemberLabelAccess = 27; // change epoch = 6
|
||||
TerminateGroupAction terminate_group = 28; // change epoch = 7
|
||||
// next: 29
|
||||
}
|
||||
|
||||
bytes actions = 1;
|
||||
bytes serverSignature = 2;
|
||||
uint32 changeEpoch = 3;
|
||||
}
|
||||
|
||||
// External credentials
|
||||
|
||||
message ExternalGroupCredential {
|
||||
string token = 1;
|
||||
}
|
||||
|
||||
// API responses
|
||||
|
||||
message GroupResponse {
|
||||
Group group = 1;
|
||||
bytes group_send_endorsements_response = 2;
|
||||
}
|
||||
|
||||
message GroupChanges {
|
||||
message GroupChangeState {
|
||||
GroupChange groupChange = 1;
|
||||
Group groupState = 2;
|
||||
}
|
||||
|
||||
repeated GroupChangeState groupChanges = 1;
|
||||
bytes group_send_endorsements_response = 2;
|
||||
}
|
||||
|
||||
message GroupChangeResponse {
|
||||
GroupChange group_change = 1;
|
||||
bytes group_send_endorsements_response = 2;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
syntax = "proto2";
|
||||
|
||||
package signalservice;
|
||||
|
||||
option csharp_namespace = "Wingnal.Service.Protos";
|
||||
|
||||
// Mirrors libsignal-service's Provisioning.proto. Used for the secondary-device
|
||||
// linking flow: the primary phone encrypts a ProvisionMessage to the ephemeral
|
||||
// public key advertised by this client in the linking QR code.
|
||||
|
||||
message ProvisioningUuid {
|
||||
optional string uuid = 1;
|
||||
}
|
||||
|
||||
// The encrypted device name shown for this linked device in the primary's device list.
|
||||
message DeviceName {
|
||||
optional bytes ephemeralPublic = 1;
|
||||
optional bytes syntheticIv = 2;
|
||||
optional bytes ciphertext = 3;
|
||||
}
|
||||
|
||||
message ProvisionEnvelope {
|
||||
optional bytes publicKey = 1; // The primary device's ephemeral Curve25519 public key (33-byte DJB form).
|
||||
optional bytes body = 2; // version(1) || iv(16) || AES-256-CBC ciphertext || HMAC-SHA256(32).
|
||||
}
|
||||
|
||||
message ProvisionMessage {
|
||||
optional bytes aciIdentityKeyPublic = 1;
|
||||
optional bytes aciIdentityKeyPrivate = 2;
|
||||
optional string number = 3;
|
||||
optional string provisioningCode = 4;
|
||||
optional string userAgent = 5;
|
||||
optional bytes profileKey = 6;
|
||||
optional bool readReceipts = 7;
|
||||
optional string aci = 8;
|
||||
optional uint32 provisioningVersion = 9;
|
||||
optional string pni = 10;
|
||||
optional bytes pniIdentityKeyPublic = 11;
|
||||
optional bytes pniIdentityKeyPrivate = 12;
|
||||
optional bytes masterKey = 13; // deprecated upstream in favor of accountEntropyPool; kept optional for back-compat
|
||||
// Link'n'sync (message-history transfer) fields — see docs/SYNC.md. When this device advertises the
|
||||
// link+sync capability in the QR, the primary includes ephemeralBackupKey, the one-time AES key the
|
||||
// transfer archive is encrypted under.
|
||||
optional bytes ephemeralBackupKey = 14; // 32 bytes
|
||||
optional string accountEntropyPool = 15;
|
||||
optional bytes mediaRootBackupKey = 16; // 32 bytes
|
||||
optional bytes aciBinary = 17; // 16-byte UUID
|
||||
optional bytes pniBinary = 18; // 16-byte UUID
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
syntax = "proto2";
|
||||
|
||||
// Mirrors libsignal rust/protocol/src/proto/sealed_sender.proto (tag v0.96.1). Used to parse inbound
|
||||
// UNIDENTIFIED_SENDER envelopes (sealed sender), so messages other people send us are received.
|
||||
|
||||
package signal.proto.sealed_sender;
|
||||
|
||||
option csharp_namespace = "Wingnal.Service.Protos.SealedSender";
|
||||
|
||||
message ServerCertificate {
|
||||
message Certificate {
|
||||
optional uint32 id = 1;
|
||||
optional bytes key = 2;
|
||||
}
|
||||
|
||||
optional bytes certificate = 1;
|
||||
optional bytes signature = 2;
|
||||
}
|
||||
|
||||
message SenderCertificate {
|
||||
message Certificate {
|
||||
optional string senderE164 = 1;
|
||||
oneof senderUuid {
|
||||
string uuidString = 6;
|
||||
bytes uuidBytes = 7;
|
||||
}
|
||||
optional uint32 senderDevice = 2;
|
||||
optional fixed64 expires = 3;
|
||||
optional bytes identityKey = 4;
|
||||
oneof signer {
|
||||
bytes /*ServerCertificate*/ certificate = 5;
|
||||
uint32 id = 8;
|
||||
}
|
||||
}
|
||||
|
||||
optional bytes certificate = 1;
|
||||
optional bytes signature = 2;
|
||||
}
|
||||
|
||||
message UnidentifiedSenderMessage {
|
||||
|
||||
message Message {
|
||||
enum Type {
|
||||
PREKEY_MESSAGE = 1;
|
||||
MESSAGE = 2;
|
||||
reserved 3 to 6;
|
||||
SENDERKEY_MESSAGE = 7;
|
||||
PLAINTEXT_CONTENT = 8;
|
||||
}
|
||||
|
||||
enum ContentHint {
|
||||
reserved 0;
|
||||
RESENDABLE = 1;
|
||||
IMPLICIT = 2;
|
||||
}
|
||||
|
||||
optional Type type = 1;
|
||||
optional bytes senderCertificate = 2;
|
||||
optional bytes content = 3;
|
||||
optional ContentHint contentHint = 4;
|
||||
optional bytes groupId = 5;
|
||||
}
|
||||
|
||||
optional bytes ephemeralPublic = 1;
|
||||
optional bytes encryptedStatic = 2;
|
||||
optional bytes encryptedMessage = 3;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
syntax = "proto2";
|
||||
|
||||
package signalservice;
|
||||
|
||||
option csharp_namespace = "Wingnal.Service.Protos";
|
||||
|
||||
// Signal tunnels an HTTP-like request/response protocol over a single WebSocket.
|
||||
// Every binary frame on the socket is a WebSocketMessage.
|
||||
|
||||
message WebSocketRequestMessage {
|
||||
optional string verb = 1;
|
||||
optional string path = 2;
|
||||
optional bytes body = 3;
|
||||
repeated string headers = 5;
|
||||
optional uint64 id = 4;
|
||||
}
|
||||
|
||||
message WebSocketResponseMessage {
|
||||
optional uint64 id = 1;
|
||||
optional uint32 status = 2;
|
||||
optional string message = 3;
|
||||
repeated string headers = 5;
|
||||
optional bytes body = 4;
|
||||
}
|
||||
|
||||
message WebSocketMessage {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
REQUEST = 1;
|
||||
RESPONSE = 2;
|
||||
}
|
||||
|
||||
optional Type type = 1;
|
||||
optional WebSocketRequestMessage request = 2;
|
||||
optional WebSocketResponseMessage response = 3;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Cryptography;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Provisioning;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts the <see cref="ProvisionEnvelope"/> a primary device sends during secondary-device
|
||||
/// linking. This client advertises an ephemeral Curve25519 public key in the linking QR code; the
|
||||
/// phone performs ECDH against it, derives keys via HKDF, and AES-256-CBC+HMAC encrypts a
|
||||
/// <see cref="ProvisionMessage"/> carrying our new identity key and account identifiers.
|
||||
/// </summary>
|
||||
public sealed class ProvisioningCipher
|
||||
{
|
||||
private static readonly byte[] Info = "TextSecure Provisioning Message"u8.ToArray();
|
||||
|
||||
private readonly ECKeyPair _ephemeralKeyPair;
|
||||
|
||||
public ProvisioningCipher() : this(Curve25519.GenerateKeyPair()) { }
|
||||
|
||||
public ProvisioningCipher(ECKeyPair ephemeralKeyPair) => _ephemeralKeyPair = ephemeralKeyPair;
|
||||
|
||||
/// <summary>The 33-byte DjbECPublicKey advertised to the primary device in the QR code.</summary>
|
||||
public byte[] PublicKey => Curve25519.EncodePoint(_ephemeralKeyPair.PublicKey);
|
||||
|
||||
public ProvisionMessage Decrypt(ProvisionEnvelope envelope)
|
||||
{
|
||||
byte[] theirPublicKey = Curve25519.DecodePoint(envelope.PublicKey.Span);
|
||||
byte[] body = envelope.Body.ToByteArray();
|
||||
|
||||
if (body.Length < 1 + 16 + 32 || body[0] != 0x01)
|
||||
throw new InvalidOperationException("malformed provision envelope body");
|
||||
|
||||
byte[] sharedSecret = Curve25519.CalculateAgreement(theirPublicKey, _ephemeralKeyPair.PrivateKey);
|
||||
byte[] keys = CryptoPrimitives.Hkdf(sharedSecret, salt: null, info: Info, outputLength: 64);
|
||||
byte[] cipherKey = keys[..32];
|
||||
byte[] macKey = keys[32..];
|
||||
|
||||
int macOffset = body.Length - 32;
|
||||
byte[] mac = body[macOffset..];
|
||||
byte[] computed = CryptoPrimitives.HmacSha256(macKey, body.AsSpan(0, macOffset));
|
||||
if (!CryptographicOperations.FixedTimeEquals(mac, computed))
|
||||
throw new InvalidOperationException("provision envelope MAC verification failed");
|
||||
|
||||
byte[] iv = body[1..17];
|
||||
byte[] ciphertext = body[17..macOffset];
|
||||
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(cipherKey, iv, ciphertext);
|
||||
|
||||
return ProvisionMessage.Parser.ParseFrom(plaintext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Provisioning;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the secondary-device linking handshake over the provisioning WebSocket: receives the
|
||||
/// provisioning address, surfaces a QR URI for the primary phone to scan, then awaits and decrypts
|
||||
/// the <see cref="ProvisionMessage"/> the phone sends back.
|
||||
/// </summary>
|
||||
public sealed class ProvisioningManager
|
||||
{
|
||||
private readonly ProvisioningCipher _cipher = new();
|
||||
|
||||
/// <summary>
|
||||
/// Connects, invokes <paramref name="onQrReady"/> with the QR URI once the provisioning address
|
||||
/// arrives, then returns the decrypted provision message after the phone responds.
|
||||
/// </summary>
|
||||
/// <summary>libsignal/Signal-Desktop link+sync capability token advertised in the QR so the primary
|
||||
/// offers a message-history transfer archive (see docs/SYNC.md).</summary>
|
||||
public const string LinkAndSyncCapability = "backup5";
|
||||
|
||||
public async Task<ProvisionMessage> LinkAsync(Func<string, Task> onQrReady, CancellationToken ct,
|
||||
IReadOnlyList<string>? capabilities = null)
|
||||
{
|
||||
using var socket = new SignalWebSocket();
|
||||
var uri = new Uri(SignalServiceConfig.WebSocketUrl + SignalServiceConfig.ProvisioningWebSocketPath);
|
||||
await socket.ConnectAsync(uri, headers: null, ct).ConfigureAwait(false);
|
||||
|
||||
// Frame 1: server assigns this client a provisioning address.
|
||||
WebSocketRequestMessage addressRequest = await socket.ReadRequestAsync(ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("provisioning socket closed before address");
|
||||
var address = ProvisioningUuid.Parser.ParseFrom(addressRequest.Body);
|
||||
await socket.SendResponseAsync(addressRequest.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||
|
||||
await onQrReady(BuildQrUri(address.Uuid, _cipher.PublicKey, capabilities)).ConfigureAwait(false);
|
||||
|
||||
// Frame 2: the phone has scanned the QR and sent the encrypted provision message.
|
||||
WebSocketRequestMessage envelopeRequest = await socket.ReadRequestAsync(ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("provisioning socket closed before envelope");
|
||||
var envelope = ProvisionEnvelope.Parser.ParseFrom(envelopeRequest.Body);
|
||||
await socket.SendResponseAsync(envelopeRequest.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||
|
||||
return _cipher.Decrypt(envelope);
|
||||
}
|
||||
|
||||
/// <summary>Builds the <c>sgnl://linkdevice</c> URI encoded into the linking QR code. Optional
|
||||
/// <paramref name="capabilities"/> are added as a comma-separated <c>capabilities</c> param (e.g.
|
||||
/// <see cref="LinkAndSyncCapability"/> to request message-history transfer).</summary>
|
||||
public static string BuildQrUri(string provisioningUuid, byte[] ephemeralPublicKey,
|
||||
IReadOnlyList<string>? capabilities = null)
|
||||
{
|
||||
string pubKey = Convert.ToBase64String(ephemeralPublicKey);
|
||||
string uri = $"sgnl://linkdevice?uuid={Uri.EscapeDataString(provisioningUuid)}&pub_key={Uri.EscapeDataString(pubKey)}";
|
||||
if (capabilities is { Count: > 0 })
|
||||
uri += $"&capabilities={Uri.EscapeDataString(string.Join(',', capabilities))}";
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIF2zCCA8OgAwIBAgIUAMHz4g60cIDBpPr1gyZ/JDaaPpcwDQYJKoZIhvcNAQEL
|
||||
BQAwdTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcT
|
||||
DU1vdW50YWluIFZpZXcxHjAcBgNVBAoTFVNpZ25hbCBNZXNzZW5nZXIsIExMQzEZ
|
||||
MBcGA1UEAxMQU2lnbmFsIE1lc3NlbmdlcjAeFw0yMjAxMjYwMDQ1NTFaFw0zMjAx
|
||||
MjQwMDQ1NTBaMHUxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYw
|
||||
FAYDVQQHEw1Nb3VudGFpbiBWaWV3MR4wHAYDVQQKExVTaWduYWwgTWVzc2VuZ2Vy
|
||||
LCBMTEMxGTAXBgNVBAMTEFNpZ25hbCBNZXNzZW5nZXIwggIiMA0GCSqGSIb3DQEB
|
||||
AQUAA4ICDwAwggIKAoICAQDEecifxMHHlDhxbERVdErOhGsLO08PUdNkATjZ1kT5
|
||||
1uPf5JPiRbus9F4J/GgBQ4ANSAjIDZuFY0WOvG/i0qvxthpW70ocp8IjkiWTNiA8
|
||||
1zQNQdCiWbGDU4B1sLi2o4JgJMweSkQFiyDynqWgHpw+KmvytCzRWnvrrptIfE4G
|
||||
PxNOsAtXFbVH++8JO42IaKRVlbfpe/lUHbjiYmIpQroZPGPY4Oql8KM3o39ObPnT
|
||||
o1WoM4moyOOZpU3lV1awftvWBx1sbTBL02sQWfHRxgNVF+Pj0fdDMMFdFJobArrL
|
||||
VfK2Ua+dYN4pV5XIxzVarSRW73CXqQ+2qloPW/ynpa3gRtYeGWV4jl7eD0PmeHpK
|
||||
OY78idP4H1jfAv0TAVeKpuB5ZFZ2szcySxrQa8d7FIf0kNJe9gIRjbQ+XrvnN+ZZ
|
||||
vj6d+8uBJq8LfQaFhlVfI0/aIdggScapR7w8oLpvdflUWqcTLeXVNLVrg15cEDwd
|
||||
lV8PVscT/KT0bfNzKI80qBq8LyRmauAqP0CDjayYGb2UAabnhefgmRY6aBE5mXxd
|
||||
byAEzzCS3vDxjeTD8v8nbDq+SD6lJi0i7jgwEfNDhe9XK50baK15Udc8Cr/ZlhGM
|
||||
jNmWqBd0jIpaZm1rzWA0k4VwXtDwpBXSz8oBFshiXs3FD6jHY2IhOR3ppbyd4qRU
|
||||
pwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV
|
||||
HQ4EFgQUtfNLxuXWS9DlgGuMUMNnW7yx83EwHwYDVR0jBBgwFoAUtfNLxuXWS9Dl
|
||||
gGuMUMNnW7yx83EwDQYJKoZIhvcNAQELBQADggIBABUeiryS0qjykBN75aoHO9bV
|
||||
PrrX+DSJIB9V2YzkFVyh/io65QJMG8naWVGOSpVRwUwhZVKh3JVp/miPgzTGAo7z
|
||||
hrDIoXc+ih7orAMb19qol/2Ha8OZLa75LojJNRbZoCR5C+gM8C+spMLjFf9k3JVx
|
||||
dajhtRUcR0zYhwsBS7qZ5Me0d6gRXD0ZiSbadMMxSw6KfKk3ePmPb9gX+MRTS63c
|
||||
8mLzVYB/3fe/bkpq4RUwzUHvoZf+SUD7NzSQRQQMfvAHlxk11TVNxScYPtxXDyiy
|
||||
3Cssl9gWrrWqQ/omuHipoH62J7h8KAYbr6oEIq+Czuenc3eCIBGBBfvCpuFOgckA
|
||||
XXE4MlBasEU0MO66GrTCgMt9bAmSw3TrRP12+ZUFxYNtqWluRU8JWQ4FCCPcz9pg
|
||||
MRBOgn4lTxDZG+I47OKNuSRjFEP94cdgxd3H/5BK7WHUz1tAGQ4BgepSXgmjzifF
|
||||
T5FVTDTl3ZnWUVBXiHYtbOBgLiSIkbqGMCLtrBtFIeQ7RRTb3L+IE9R0UB0cJB3A
|
||||
Xbf1lVkOcmrdu2h8A32aCwtr5S1fBF1unlG7imPmqJfpOMWa8yIF/KWVm29JAPq8
|
||||
Lrsybb0z5gg8w7ZblEuB9zOW9M3l60DXuJO6l7g+deV6P96rv2unHS8UlvWiVWDy
|
||||
9qfgAJizyy3kqM4lOwBH
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Wingnal.Service;
|
||||
|
||||
/// <summary>Shared service-id (ACI/PNI) byte helpers, so the binary→UUID conversion isn't reimplemented
|
||||
/// in every store/decryptor. Uses the BCL's RFC-4122 big-endian Guid support.</summary>
|
||||
public static class ServiceIds
|
||||
{
|
||||
/// <summary>A service-id binary (16-byte ACI UUID, or 1-byte prefix + 16-byte PNI) → canonical
|
||||
/// lowercase UUID string, or null if malformed.</summary>
|
||||
public static string? StringFromBinary(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length == 17) bytes = bytes[1..]; // strip the service-id type prefix
|
||||
if (bytes.Length != 16) return null;
|
||||
return new Guid(bytes, bigEndian: true).ToString("D").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Wingnal.Service</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.28.3" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.67.0" PrivateAssets="All" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wingnal.Protocol\Wingnal.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Signal .proto files are added in a later step; glob compiles them when present. -->
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\**\*.proto" GrpcServices="None" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Signal's private root CA, pinned for all connections to chat.signal.org. -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\signal-ca.pem" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user