Teams-style chat list, contact name resolution, and fixes
UI:
- Rework the conversation list to a Teams/Outlook-style row: single-line
preview, right-aligned timestamp, unread dot, and a neutral elevated
rounded fill (drawn by a Border that wraps the whole row) for hover/select.
- Smooth the Chats/Calls/Stories rail indicator by suppressing the content
frame's transition, which competed with the indicator animation.
Contact names:
- Fetch + decrypt Signal profile names (ProfileCipher, ProfileService,
ProfileNameStore, GET /v1/profile) when a peer has no synced name.
- Capture each contact's profileKey from the link'n'sync backup and the
contacts sync (un-reserve ContactDetails.profileKey) so names can be
resolved; fall back to the phone number before a raw ACI.
Other:
- Report the device to Signal as "Wingnal: {machine name}".
- Resolve build warnings: target Wingnal.Service at net8.0-windows (CA1416),
initialize zkgroup credential fields (CS8618), hoist a stackalloc (CA2014).
- Skip the live chat-connect diagnostic test by default (it logged in as the
real account and disconnected the app).
- Bump app version 1.0.1.0 -> 1.0.2.0.
This commit is contained in:
@@ -74,6 +74,17 @@ public sealed class ContactsStore
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>The contact's phone number (E.164), if we synced/imported one — a friendlier last-resort
|
||||
/// label than a raw ACI when no name is available.</summary>
|
||||
public string? NumberFor(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT number 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)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite store of profile display names we've fetched and decrypted per ACI (via <c>ProfileCipher</c>),
|
||||
/// so the conversation list can name contacts who aren't in the primary's system address book. This is
|
||||
/// distinct from <see cref="ContactsStore"/> (which holds names synced from the primary): profile names
|
||||
/// are resolved on demand from each peer's profile key. Names are encrypted at rest with
|
||||
/// <see cref="LocalCipher"/> (%LOCALAPPDATA%\Wingnal\profile_names.db). Keyed by ACI.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileNameStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public ProfileNameStore(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, "profile_names.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 profile_names (aci TEXT PRIMARY KEY, name TEXT NOT NULL);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Store(string aci, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT INTO profile_names (aci, name) VALUES ($a, $n) ON CONFLICT(aci) DO UPDATE SET name = $n;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
cmd.Parameters.AddWithValue("$n", _cipher.Protect(name));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public string? Get(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM profile_names WHERE aci = $a;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>Removes all resolved profile names (used when unlinking).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM profile_names;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
|
||||
namespace Wingnal.Service.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts Signal profile fields (name, about) that a recipient encrypted with their 32-byte profile
|
||||
/// key. The blob layout matches libsignal's <c>ProfileCipher</c>: <c>nonce(12) || AES-256-GCM ciphertext
|
||||
/// || tag(16)</c>. The decrypted name plaintext is NUL-separated <c>given \0 family</c>, zero-padded to a
|
||||
/// fixed bucket length, so decoding strips the padding and rejoins the parts.
|
||||
/// </summary>
|
||||
public static class ProfileCipher
|
||||
{
|
||||
/// <summary>Decrypts a base64 profile-name blob to a display name. Returns null when the input is
|
||||
/// empty/malformed, the profile key is the wrong length, or GCM authentication fails (a stale or
|
||||
/// mismatched profile key) — callers treat that as "no name available".</summary>
|
||||
public static string? DecryptName(byte[] profileKey, string? base64Name)
|
||||
{
|
||||
if (profileKey.Length != 32 || string.IsNullOrEmpty(base64Name)) return null;
|
||||
|
||||
byte[] blob;
|
||||
try { blob = Convert.FromBase64String(base64Name); }
|
||||
catch (FormatException) { return null; }
|
||||
if (blob.Length < 12 + 16) return null; // must hold at least a nonce and a tag
|
||||
|
||||
var nonce = new byte[12];
|
||||
Array.Copy(blob, 0, nonce, 0, 12);
|
||||
var ciphertextAndTag = new byte[blob.Length - 12];
|
||||
Array.Copy(blob, 12, ciphertextAndTag, 0, ciphertextAndTag.Length);
|
||||
|
||||
byte[] plaintext;
|
||||
try { plaintext = CryptoPrimitives.AesGcmDecrypt(profileKey, nonce, ciphertextAndTag); }
|
||||
catch { return null; } // tag mismatch / bad key
|
||||
|
||||
return DecodePaddedName(plaintext);
|
||||
}
|
||||
|
||||
/// <summary>Plaintext is <c>given \0 family</c> followed by zero padding. Split on the first NUL (the
|
||||
/// family segment may be empty), UTF-8 decode each part, and rejoin. Returns null if the result is
|
||||
/// blank.</summary>
|
||||
private static string? DecodePaddedName(byte[] plaintext)
|
||||
{
|
||||
int split = Array.IndexOf(plaintext, (byte)0);
|
||||
string given, family;
|
||||
if (split < 0)
|
||||
{
|
||||
given = Encoding.UTF8.GetString(plaintext);
|
||||
family = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
given = Encoding.UTF8.GetString(plaintext, 0, split);
|
||||
int rest = split + 1;
|
||||
int end = Array.IndexOf(plaintext, (byte)0, rest);
|
||||
if (end < 0) end = plaintext.Length;
|
||||
family = Encoding.UTF8.GetString(plaintext, rest, end - rest);
|
||||
}
|
||||
|
||||
string name = $"{given} {family}".Trim();
|
||||
return name.Length == 0 ? null : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Crypto;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Net;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves display names for peers we can't name from synced contacts, by fetching their Signal profile
|
||||
/// (<c>GET /v1/profile</c>) and decrypting the name with the profile key learned from their inbound
|
||||
/// messages (see <see cref="MessageDecryptor"/> → <see cref="ProfileKeyStore"/>). Resolved names are
|
||||
/// cached in <see cref="ProfileNameStore"/> so a name is fetched at most once per peer.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileService
|
||||
{
|
||||
private readonly SignalRestClient _rest;
|
||||
private readonly ProfileKeyStore _profileKeys;
|
||||
private readonly ProfileNameStore _names;
|
||||
private readonly string _authToken;
|
||||
|
||||
public ProfileService(SignalRestClient rest, ProfileKeyStore profileKeys, ProfileNameStore names, string authToken)
|
||||
{
|
||||
_rest = rest;
|
||||
_profileKeys = profileKeys;
|
||||
_names = names;
|
||||
_authToken = authToken;
|
||||
}
|
||||
|
||||
/// <summary>The cached profile name for a peer, if one has been resolved; otherwise null.</summary>
|
||||
public string? NameFor(string aci) => _names.Get(aci);
|
||||
|
||||
/// <summary>Fetches, decrypts and caches the profile name for a peer. Returns the resolved name, or
|
||||
/// null when we have no profile key for them, their profile isn't accessible, or decryption fails.
|
||||
/// Best-effort and idempotent — a cached name short-circuits the network call.</summary>
|
||||
public async Task<string?> ResolveAsync(string aci, CancellationToken ct)
|
||||
{
|
||||
string? cached = _names.Get(aci);
|
||||
if (!string.IsNullOrEmpty(cached)) return cached;
|
||||
|
||||
byte[]? profileKey = _profileKeys.Get(aci);
|
||||
if (profileKey is null) { FileLog.Write($"profile: {aci} skip — no profile key"); return null; }
|
||||
|
||||
ProfileResponse? profile = await _rest.GetProfileAsync(aci, _authToken, ct).ConfigureAwait(false);
|
||||
if (profile is null) { FileLog.Write($"profile: {aci} — fetch returned null (inaccessible/HTTP error)"); return null; }
|
||||
|
||||
string? name = ProfileCipher.DecryptName(profileKey, profile.Name);
|
||||
if (name is null)
|
||||
{
|
||||
FileLog.Write($"profile: {aci} — decrypt/empty (name field len={profile.Name?.Length ?? 0})");
|
||||
return null;
|
||||
}
|
||||
_names.Store(aci, name);
|
||||
FileLog.Write($"profile: {aci} resolved (len={name.Length})"); // length only — no PII in the log
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,17 @@ namespace Wingnal.Service.Messaging;
|
||||
public sealed class SyncProcessor
|
||||
{
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly ProfileKeyStore? _profileKeys;
|
||||
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)
|
||||
public SyncProcessor(ContactsStore contacts, ProfileKeyStore? profileKeys = null,
|
||||
AttachmentDownloader? downloader = null)
|
||||
{
|
||||
_contacts = contacts;
|
||||
_profileKeys = profileKeys;
|
||||
_downloader = downloader ?? new AttachmentDownloader();
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ public sealed class SyncProcessor
|
||||
/// (no network) — the offline-testable core of contacts sync.</summary>
|
||||
public int ImportContacts(byte[] blob)
|
||||
{
|
||||
int count = 0;
|
||||
int count = 0, keys = 0;
|
||||
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
||||
{
|
||||
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
|
||||
@@ -63,8 +66,17 @@ public sealed class SyncProcessor
|
||||
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));
|
||||
|
||||
// Keep the contact's profile key so we can fetch + decrypt their profile name later, even
|
||||
// when the primary had no system-contact name for them (so the row isn't left as a raw ACI).
|
||||
if (_profileKeys is not null && record.Details.HasProfileKey)
|
||||
{
|
||||
byte[] pk = record.Details.ProfileKey.ToByteArray();
|
||||
if (pk.Length == 32) { _profileKeys.Store(aci, pk); keys++; }
|
||||
}
|
||||
count++;
|
||||
}
|
||||
FileLog.Write($"sync: imported {count} contact(s), {keys} profile key(s)");
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>The subset of <c>GET /v1/profile/{id}</c> we consume: the encrypted display <c>name</c>
|
||||
/// (base64 of <c>nonce || ciphertext || tag</c>, decryptable with the recipient's profile key). Other
|
||||
/// fields (about, avatar, capabilities, …) are ignored.</summary>
|
||||
public sealed record ProfileResponse
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? About { get; init; }
|
||||
}
|
||||
@@ -74,6 +74,19 @@ public sealed class SignalRestClient : IDisposable
|
||||
?? throw new InvalidOperationException("empty prekey response");
|
||||
}
|
||||
|
||||
/// <summary>Fetches a recipient's profile (GET /v1/profile/{serviceId}). The <c>name</c> field is
|
||||
/// base64 of a ProfileCipher blob encrypted under the recipient's profile key. Returns null when the
|
||||
/// profile isn't accessible (e.g. 401/403/404) so callers can fall back to a placeholder name rather
|
||||
/// than surfacing an error. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||
public async Task<ProfileResponse?> GetProfileAsync(string serviceId, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v1/profile/{serviceId}");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
return await response.Content.ReadFromJsonAsync<ProfileResponse>(Json, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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(
|
||||
|
||||
@@ -944,7 +944,7 @@ message ContactDetails {
|
||||
optional Avatar avatar = 3;
|
||||
reserved /* color */ 4;
|
||||
reserved /* verified */ 5;
|
||||
reserved /* profileKey */ 6;
|
||||
optional bytes profileKey = 6; // used to fetch + decrypt the contact's profile name
|
||||
reserved /* blocked */ 7;
|
||||
optional uint32 expireTimer = 8;
|
||||
optional uint32 expireTimerVersion = 12;
|
||||
|
||||
@@ -18,12 +18,15 @@ public sealed class BackupImporter
|
||||
{
|
||||
private readonly MessageStore _messages;
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly ProfileKeyStore? _profileKeys;
|
||||
private readonly string _ownAci;
|
||||
|
||||
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci)
|
||||
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci,
|
||||
ProfileKeyStore? profileKeys = null)
|
||||
{
|
||||
_messages = messages;
|
||||
_contacts = contacts;
|
||||
_profileKeys = profileKeys;
|
||||
_ownAci = ownAci.ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -56,6 +59,13 @@ public sealed class BackupImporter
|
||||
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));
|
||||
// Keep the profile key so an un-named contact's profile name can still be fetched
|
||||
// and decrypted later (the modern contacts sync no longer carries these).
|
||||
if (_profileKeys is not null && r.Contact.HasProfileKey)
|
||||
{
|
||||
byte[] pk = r.Contact.ProfileKey.ToByteArray();
|
||||
if (pk.Length == 32) _profileKeys.Store(aci, pk);
|
||||
}
|
||||
contactCount++;
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -24,13 +24,16 @@ public sealed class MessageHistoryImporter
|
||||
private readonly SignalRestClient _rest;
|
||||
private readonly MessageStore _messages;
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly ProfileKeyStore? _profileKeys;
|
||||
|
||||
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages, ContactsStore contacts)
|
||||
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages,
|
||||
ContactsStore contacts, ProfileKeyStore? profileKeys = null)
|
||||
{
|
||||
_account = account;
|
||||
_rest = rest;
|
||||
_messages = messages;
|
||||
_contacts = contacts;
|
||||
_profileKeys = profileKeys;
|
||||
}
|
||||
|
||||
/// <param name="ShouldRetry">True when the failure was transient (timed out / network / parse error)
|
||||
@@ -80,7 +83,7 @@ public sealed class MessageHistoryImporter
|
||||
|
||||
MessageBackupKey key = BackupKey.ForLinkAndSync(ephemeral, aci);
|
||||
BackupContents backup = BackupReader.Read(file, key);
|
||||
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci).Import(backup);
|
||||
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci, _profileKeys).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");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<!-- Windows-only in practice: the local stores use DPAPI (ProtectedData). Declaring the platform
|
||||
here keeps CA1416 quiet for the store callers, matching the app + test projects. -->
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Wingnal.Service</RootNamespace>
|
||||
|
||||
Reference in New Issue
Block a user