Add project files.

This commit is contained in:
micro
2026-06-20 09:31:59 -05:00
parent 9bb3f00d86
commit 4baa4ce8c0
346 changed files with 55751 additions and 0 deletions
+14
View File
@@ -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());
}
+52
View File
@@ -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;
}
}
+115
View File
@@ -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);
+100
View File
@@ -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;
}