Add project files.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user