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
+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;
}
}