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:
Austin
2026-07-09 16:22:15 -05:00
parent 77fb0c4204
commit 45b6d0291e
22 changed files with 532 additions and 43 deletions
+11
View File
@@ -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;
}
}
+63
View File
@@ -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;
}
}
+14 -2
View File
@@ -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;
}
+10
View File
@@ -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; }
}
+13
View File
@@ -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(
+1 -1
View File
@@ -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;
+11 -1
View File
@@ -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");
+3 -1
View File
@@ -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>