Add project files.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Connects the authenticated chat WebSocket and delivers incoming 1:1 texts. The server pushes
|
||||
/// queued messages as PUT /api/v1/message (Envelope body); we ack each with 200 and answer
|
||||
/// keepalives. Decryption failures are surfaced via <paramref name="onError"/> but still acked so the
|
||||
/// queue drains (see SHORTCUTS.md).
|
||||
/// </summary>
|
||||
public sealed class ChatReceiver
|
||||
{
|
||||
private readonly SignalAccount _account;
|
||||
private readonly MessageDecryptor _decryptor;
|
||||
|
||||
public ChatReceiver(SignalAccount account, ISignalProtocolStore store,
|
||||
Account.ProfileKeyStore? profileKeys = null, ISenderKeyStore? senderKeys = null)
|
||||
{
|
||||
_account = account;
|
||||
_decryptor = new MessageDecryptor(store, profileKeys, senderKeys);
|
||||
}
|
||||
|
||||
public async Task ReceiveAsync(
|
||||
Func<DecryptedMessage, Task> onMessage,
|
||||
Action<Envelope, Exception>? onError,
|
||||
CancellationToken ct,
|
||||
Func<SyncMessage, Task>? onSync = null,
|
||||
Func<string, ReceiptMessage, Task>? onReceipt = null,
|
||||
Func<string, TypingMessage, Task>? onTyping = null)
|
||||
{
|
||||
using var socket = new SignalWebSocket();
|
||||
var uri = new Uri($"{SignalServiceConfig.WebSocketUrl}{SignalServiceConfig.ChatWebSocketPath}");
|
||||
var headers = new Dictionary<string, string> { ["Authorization"] = $"Basic {_account.BasicAuthToken()}" };
|
||||
|
||||
FileLog.Write($"chat: connecting login={_account.Aci}.{_account.DeviceId} (Authorization header)");
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(uri, headers, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"chat: CONNECT FAILED {ex.GetType().Name}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
FileLog.Write("chat: connected (websocket upgrade OK)");
|
||||
|
||||
using var loopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task keepAlive = KeepAliveLoopAsync(socket, loopCts.Token);
|
||||
|
||||
int frames = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
WebSocketRequestMessage? request = await socket.ReadRequestAsync(ct).ConfigureAwait(false);
|
||||
if (request is null)
|
||||
{
|
||||
FileLog.Write($"chat: socket closed after {frames} frame(s). {socket.CloseReason}");
|
||||
break;
|
||||
}
|
||||
|
||||
frames++;
|
||||
bool isMessage = request.Verb == "PUT" && request.Path == "/api/v1/message";
|
||||
FileLog.Write($"chat: frame #{frames} verb={request.Verb} path={request.Path} bodyLen={request.Body?.Length ?? 0}");
|
||||
|
||||
if (!isMessage)
|
||||
{
|
||||
// keepalive, queue-empty, etc. — ack immediately.
|
||||
await socket.SendResponseAsync(request.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
Envelope envelope = Envelope.Parser.ParseFrom(request.Body);
|
||||
FileLog.Write($"chat: envelope type={envelope.Type} from={envelope.SourceServiceId}.{envelope.SourceDeviceId} contentLen={envelope.Content?.Length ?? 0}");
|
||||
try
|
||||
{
|
||||
MessageDecryptor.Result result = _decryptor.DecryptEnvelope(envelope);
|
||||
// Ack only after a successful decrypt, so a message we can't yet handle is redelivered.
|
||||
await socket.SendResponseAsync(request.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Message is { } message)
|
||||
{
|
||||
FileLog.Write($"chat: decrypted text from {message.PeerServiceId} outgoing={message.Outgoing}");
|
||||
await onMessage(message).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (onSync is not null && result.Content?.SyncMessage is { } sync)
|
||||
{
|
||||
FileLog.Write("chat: handling sync message");
|
||||
await onSync(sync).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (onReceipt is not null && result.Content?.ReceiptMessage is { } receipt)
|
||||
{
|
||||
FileLog.Write($"chat: {receipt.Type} receipt from {result.Sender} for {receipt.Timestamp.Count} message(s)");
|
||||
await onReceipt(result.Sender, receipt).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (onTyping is not null && result.Content?.TypingMessage is { } typing)
|
||||
{
|
||||
FileLog.Write($"chat: typing {typing.Action} from {result.Sender}");
|
||||
await onTyping(result.Sender, typing).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.Message is null && result.Content is null)
|
||||
FileLog.Write($"chat: decrypted, nothing surfaced (type={envelope.Type})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Dump($"failed-envelope-{frames}.bin", request.Body!.ToByteArray());
|
||||
FileLog.Write($"chat: DECRYPT FAILED type={envelope.Type} (not acked, will redeliver):{Environment.NewLine}{ex}");
|
||||
onError?.Invoke(envelope, ex);
|
||||
}
|
||||
}
|
||||
|
||||
loopCts.Cancel();
|
||||
try { await keepAlive.ConfigureAwait(false); } catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private static async Task KeepAliveLoopAsync(SignalWebSocket socket, CancellationToken ct)
|
||||
{
|
||||
ulong id = 1;
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
|
||||
await socket.SendKeepAliveAsync(id++, ct).ConfigureAwait(false);
|
||||
FileLog.Write("chat: sent keepalive");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex) { FileLog.Write($"chat: keepalive stopped: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>One parsed contact from a contacts-sync blob: the record plus its inline avatar bytes (if
|
||||
/// any).</summary>
|
||||
public sealed record ContactRecord(ContactDetails Details, byte[]? Avatar);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the decrypted contacts-sync blob. The blob is a flat stream of records, each:
|
||||
/// <c>[varint length][ContactDetails protobuf]</c>, immediately followed by <c>[avatar.length bytes]</c>
|
||||
/// when the record declares an avatar. Mirrors Signal's DeviceContactsInputStream.
|
||||
/// </summary>
|
||||
public static class ContactRecordStream
|
||||
{
|
||||
public static IReadOnlyList<ContactRecord> Parse(byte[] blob)
|
||||
{
|
||||
var result = new List<ContactRecord>();
|
||||
using var stream = new MemoryStream(blob, writable: false);
|
||||
|
||||
while (stream.Position < stream.Length)
|
||||
{
|
||||
ContactDetails details = ContactDetails.Parser.ParseDelimitedFrom(stream);
|
||||
|
||||
byte[]? avatar = null;
|
||||
if (details.Avatar is { } a && a.Length > 0)
|
||||
{
|
||||
avatar = new byte[a.Length];
|
||||
int read = stream.Read(avatar, 0, avatar.Length);
|
||||
if (read != avatar.Length)
|
||||
throw new InvalidDataException("truncated contact avatar in sync blob");
|
||||
}
|
||||
|
||||
result.Add(new ContactRecord(details, avatar));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>A decrypted 1:1 text message (incoming, or a synced transcript of one we sent). When the
|
||||
/// message carried media, <see cref="Attachment"/> is the first attachment pointer (download separately).</summary>
|
||||
public sealed record DecryptedMessage(
|
||||
string PeerServiceId,
|
||||
uint SenderDeviceId,
|
||||
string Body,
|
||||
long Timestamp,
|
||||
bool Outgoing)
|
||||
{
|
||||
public Protos.AttachmentPointer? Attachment { get; init; }
|
||||
|
||||
/// <summary>For a group (GroupsV2) message, the lowercase-hex group identifier this belongs to; null for
|
||||
/// a 1:1 message. When set, the conversation is keyed by the group rather than by <see cref="PeerServiceId"/>.</summary>
|
||||
public string? GroupId { get; init; }
|
||||
|
||||
/// <summary>For a group message, the 32-byte group master key (from GroupContextV2) — persisted so the
|
||||
/// group can later be fetched/decrypted from the storage service.</summary>
|
||||
public byte[]? GroupMasterKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Protocol.ZkGroup;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Phase G1 (receive-only groups): wires the Sender Key messaging primitive into the receive path. Processes
|
||||
/// an inbound Sender Key Distribution Message (so we can decrypt that sender's future group messages),
|
||||
/// decrypts an inbound group Sender Key message to its plaintext <c>Content</c>, and derives the 32-byte
|
||||
/// group identifier from a group master key (to route the message into the right group thread). No zkgroup
|
||||
/// credential machinery is needed to *receive* — only the group-id derivation (<see cref="GroupSecretParams"/>).
|
||||
/// </summary>
|
||||
public sealed class GroupMessageProcessor
|
||||
{
|
||||
private readonly GroupSessionBuilder _builder;
|
||||
private readonly GroupSessionCipher _cipher;
|
||||
|
||||
public GroupMessageProcessor(ISenderKeyStore store)
|
||||
{
|
||||
_builder = new GroupSessionBuilder(store);
|
||||
_cipher = new GroupSessionCipher(store);
|
||||
}
|
||||
|
||||
/// <summary>Installs a sender's distribution (their sender key) so their group messages can be decrypted.</summary>
|
||||
public void ProcessDistribution(SignalProtocolAddress sender, byte[] skdmBytes) =>
|
||||
_builder.Process(sender, SenderKeyDistributionMessage.Parse(skdmBytes));
|
||||
|
||||
/// <summary>Decrypts a received group Sender Key message to its (still padding-wrapped) plaintext.</summary>
|
||||
public byte[] DecryptGroupMessage(SignalProtocolAddress sender, byte[] senderKeyMessageBytes) =>
|
||||
_cipher.Decrypt(sender, SenderKeyMessage.Parse(senderKeyMessageBytes));
|
||||
|
||||
/// <summary>The lowercase-hex group identifier derived from a 32-byte group master key
|
||||
/// (<c>GroupContextV2.masterKey</c>), used to key a group conversation.</summary>
|
||||
public static string GroupIdHex(byte[] masterKey) =>
|
||||
Convert.ToHexString(GroupSecretParams.DeriveFromMasterKey(masterKey).GroupIdentifier).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Phase G2 (send) crypto assembly: builds the one group ciphertext that every member decrypts. The body is
|
||||
/// encrypted ONCE with our group Sender Key (<see cref="GroupSessionCipher"/>); the caller then fans the
|
||||
/// resulting <c>SenderKeyMessage</c> out to each member device wrapped as sealed-sender (type 7), after
|
||||
/// distributing our <c>SenderKeyDistributionMessage</c> (1:1, sealed) to members who don't have our key yet.
|
||||
/// The fan-out/transport itself lives in the live send path; this type is the offline-testable core.
|
||||
/// </summary>
|
||||
public sealed class GroupSendBuilder
|
||||
{
|
||||
private readonly SignalProtocolAddress _self;
|
||||
private readonly Guid _distributionId;
|
||||
private readonly GroupSessionBuilder _builder;
|
||||
private readonly GroupSessionCipher _cipher;
|
||||
|
||||
public GroupSendBuilder(ISenderKeyStore store, SignalProtocolAddress self, Guid distributionId)
|
||||
{
|
||||
_self = self;
|
||||
_distributionId = distributionId;
|
||||
_builder = new GroupSessionBuilder(store);
|
||||
_cipher = new GroupSessionCipher(store);
|
||||
}
|
||||
|
||||
/// <summary>Creates (or recreates) our sender key and returns the distribution message to send 1:1 to
|
||||
/// members so they can decrypt our group messages.</summary>
|
||||
public SenderKeyDistributionMessage CreateDistribution() => _builder.Create(_self, _distributionId);
|
||||
|
||||
/// <summary>Encrypts a group <see cref="Content"/> into the wire <c>SenderKeyMessage</c> bytes that go,
|
||||
/// once, to every member device (the caller attaches the GroupContextV2 to the Content beforehand).</summary>
|
||||
public byte[] EncryptMessage(Content content)
|
||||
{
|
||||
byte[] padded = MessagePadding.Add(content.ToByteArray());
|
||||
return _cipher.Encrypt(_self, _distributionId, padded).Serialize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.Ratchet;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts an unsealed (authenticated) <see cref="Envelope"/> into a 1:1 text. Handles
|
||||
/// PREKEY_MESSAGE (establishes the session) and DOUBLE_RATCHET envelopes; returns null for envelope
|
||||
/// types or content kinds we don't surface yet (receipts, sealed sender, non-text sync, etc.).
|
||||
/// </summary>
|
||||
public sealed class MessageDecryptor
|
||||
{
|
||||
private readonly ISignalProtocolStore _store;
|
||||
private readonly Account.ProfileKeyStore? _profileKeys;
|
||||
private readonly ISenderKeyStore? _senderKeys;
|
||||
|
||||
public MessageDecryptor(ISignalProtocolStore store, Account.ProfileKeyStore? profileKeys = null,
|
||||
ISenderKeyStore? senderKeys = null)
|
||||
{
|
||||
_store = store;
|
||||
_profileKeys = profileKeys;
|
||||
_senderKeys = senderKeys;
|
||||
}
|
||||
|
||||
/// <summary>The full result of decrypting an envelope: the sender, the parsed <see cref="Content"/>
|
||||
/// (null for envelope types we don't session-decrypt), and the surfaced 1:1 text (if any).</summary>
|
||||
public sealed record Result(string Sender, uint SenderDevice, Content? Content, DecryptedMessage? Message);
|
||||
|
||||
/// <summary>Surfaced-text-only view, kept for callers/tests that just want the chat bubble.</summary>
|
||||
public DecryptedMessage? Decrypt(Envelope envelope) => DecryptEnvelope(envelope).Message;
|
||||
|
||||
public Result DecryptEnvelope(Envelope envelope)
|
||||
{
|
||||
string sender = ResolveServiceId(envelope.SourceServiceId, envelope.SourceServiceIdBinary);
|
||||
uint senderDevice = envelope.SourceDeviceId;
|
||||
byte[] ciphertext = envelope.Content.ToByteArray();
|
||||
bool isPreKey;
|
||||
|
||||
switch (envelope.Type)
|
||||
{
|
||||
case Envelope.Types.Type.PrekeyMessage:
|
||||
isPreKey = true;
|
||||
break;
|
||||
case Envelope.Types.Type.DoubleRatchet:
|
||||
isPreKey = false;
|
||||
break;
|
||||
case Envelope.Types.Type.UnidentifiedSender:
|
||||
// Sealed sender: unwrap to the real sender + inner ciphertext, then decrypt as usual.
|
||||
SealedSenderDecryptor.Unsealed u = SealedSenderDecryptor.Decrypt(
|
||||
ciphertext, _store.GetIdentityKeyPair(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
sender = u.SenderUuid;
|
||||
senderDevice = u.SenderDevice;
|
||||
ciphertext = u.Content;
|
||||
if (u.CiphertextType == 7) // 7 = group sender-key (GroupsV2 G1 receive)
|
||||
return DecryptGroupSenderKey(sender, senderDevice, ciphertext, envelope);
|
||||
isPreKey = u.CiphertextType == 1; // PREKEY_MESSAGE
|
||||
if (u.CiphertextType is not (1 or 2)) // 2 = MESSAGE; 8 = plaintext
|
||||
return new Result(sender, senderDevice, null, null);
|
||||
break;
|
||||
default:
|
||||
return new Result(sender, senderDevice, null, null); // receipts, etc.
|
||||
}
|
||||
|
||||
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||
var cipher = new SessionCipher(_store, _store, _store, _store, _store, address);
|
||||
byte[] padded = isPreKey
|
||||
? cipher.DecryptPreKeyMessage(PreKeySignalMessage.Parse(ciphertext))
|
||||
: cipher.DecryptSignalMessage(SignalMessage.Parse(ciphertext));
|
||||
|
||||
byte[] plaintext = StripPadding(padded);
|
||||
var content = Content.Parser.ParseFrom(plaintext);
|
||||
|
||||
// Capture the sender's profile key so we can later send THEM sealed-sender messages.
|
||||
if (_profileKeys is not null && content.DataMessage?.ProfileKey is { Length: 32 } pk)
|
||||
_profileKeys.Store(sender, pk.ToByteArray());
|
||||
|
||||
// Install any group sender key the peer distributed (so their future group messages decrypt).
|
||||
ProcessDistributionIfPresent(content, sender, senderDevice);
|
||||
|
||||
DecryptedMessage? message = SurfaceText(content, sender, senderDevice, envelope);
|
||||
return new Result(sender, senderDevice, content, message);
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a group (Sender Key) message that arrived as a sealed-sender SENDERKEY envelope,
|
||||
/// and surfaces it routed to its group thread. Requires a sender-key store; otherwise the message is
|
||||
/// dropped (we couldn't have its sender key without one).</summary>
|
||||
private Result DecryptGroupSenderKey(string sender, uint senderDevice, byte[] senderKeyMessage, Envelope envelope)
|
||||
{
|
||||
if (_senderKeys is null) return new Result(sender, senderDevice, null, null);
|
||||
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||
byte[] padded = new GroupMessageProcessor(_senderKeys).DecryptGroupMessage(address, senderKeyMessage);
|
||||
var content = Content.Parser.ParseFrom(StripPadding(padded));
|
||||
|
||||
if (_profileKeys is not null && content.DataMessage?.ProfileKey is { Length: 32 } pk)
|
||||
_profileKeys.Store(sender, pk.ToByteArray());
|
||||
|
||||
DecryptedMessage? message = SurfaceText(content, sender, senderDevice, envelope);
|
||||
return new Result(sender, senderDevice, content, message);
|
||||
}
|
||||
|
||||
private void ProcessDistributionIfPresent(Content content, string sender, uint senderDevice)
|
||||
{
|
||||
if (_senderKeys is null || content.SenderKeyDistributionMessage.IsEmpty) return;
|
||||
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||
new GroupMessageProcessor(_senderKeys)
|
||||
.ProcessDistribution(address, content.SenderKeyDistributionMessage.ToByteArray());
|
||||
}
|
||||
|
||||
private static DecryptedMessage? SurfaceText(Content content, string sender, uint senderDevice, Envelope envelope)
|
||||
{
|
||||
// Direct incoming 1:1 message (text, or a placeholder for media/reactions so it still appears).
|
||||
if (content.DataMessage is { } dm)
|
||||
{
|
||||
string? text = Describe(dm);
|
||||
if (text is not null)
|
||||
{
|
||||
long ts = dm.Timestamp != 0 ? (long)dm.Timestamp : (long)envelope.ServerTimestamp;
|
||||
(string? groupId, byte[]? masterKey) = GroupContext(dm.GroupV2);
|
||||
return new DecryptedMessage(sender, senderDevice, text, ts, Outgoing: false)
|
||||
{
|
||||
Attachment = dm.Attachments.Count > 0 ? dm.Attachments[0] : null,
|
||||
GroupId = groupId, GroupMasterKey = masterKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript of a message we sent from another device (synced to us).
|
||||
if (content.SyncMessage?.Sent is { } sent && sent.Message is { } sentMsg && Describe(sentMsg) is { } sentText)
|
||||
{
|
||||
string peer = sent.DestinationServiceId ?? sender;
|
||||
long ts = sent.Timestamp != 0 ? (long)sent.Timestamp : (long)envelope.ServerTimestamp;
|
||||
(string? groupId, byte[]? masterKey) = GroupContext(sentMsg.GroupV2);
|
||||
return new DecryptedMessage(peer, senderDevice, sentText, ts, Outgoing: true)
|
||||
{
|
||||
Attachment = sentMsg.Attachments.Count > 0 ? sentMsg.Attachments[0] : null,
|
||||
GroupId = groupId, GroupMasterKey = masterKey,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Derives the (groupId, masterKey) from a GroupContextV2, or (null, null) for a 1:1 message.</summary>
|
||||
private static (string? groupId, byte[]? masterKey) GroupContext(GroupContextV2? groupV2)
|
||||
{
|
||||
if (groupV2?.MasterKey is { Length: 32 } mk)
|
||||
{
|
||||
byte[] key = mk.ToByteArray();
|
||||
return (GroupMessageProcessor.GroupIdHex(key), key);
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
/// <summary>The display text for a DataMessage: the body, else a placeholder for an attachment or
|
||||
/// reaction (so media/reactions show up instead of being silently dropped). Null = nothing to show
|
||||
/// (receipts, typing, empty).</summary>
|
||||
private static string? Describe(DataMessage dm)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(dm.Body)) return dm.Body;
|
||||
|
||||
if (dm.Reaction is { } r && !string.IsNullOrEmpty(r.Emoji))
|
||||
return r.Remove ? "removed a reaction" : $"reacted {r.Emoji}";
|
||||
|
||||
if (dm.Attachments.Count > 0)
|
||||
return DescribeAttachment(dm.Attachments[0], dm.Attachments.Count);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeAttachment(AttachmentPointer a, int count)
|
||||
{
|
||||
string label =
|
||||
(a.Flags & (uint)AttachmentPointer.Types.Flags.VoiceMessage) != 0 ? "🎙 Voice message" :
|
||||
!string.IsNullOrEmpty(a.ContentType) && a.ContentType.StartsWith("image/") ? "📷 Photo" :
|
||||
!string.IsNullOrEmpty(a.ContentType) && a.ContentType.StartsWith("video/") ? "🎥 Video" :
|
||||
!string.IsNullOrEmpty(a.FileName) ? $"📎 {a.FileName}" : "📎 Attachment";
|
||||
return count > 1 ? $"{label} (+{count - 1} more)" : label;
|
||||
}
|
||||
|
||||
/// <summary>Resolves a service id, preferring the string form and falling back to the binary form
|
||||
/// (16-byte ACI UUID, or 1-byte prefix + 16-byte UUID for PNI).</summary>
|
||||
private static string ResolveServiceId(string asString, Google.Protobuf.ByteString binary) =>
|
||||
!string.IsNullOrEmpty(asString) ? asString : ServiceIds.StringFromBinary(binary.Span) ?? string.Empty;
|
||||
|
||||
/// <summary>Removes Signal's PushTransportDetails padding (0x80 terminator + trailing zeros).</summary>
|
||||
private static byte[] StripPadding(byte[] message) => MessagePadding.Strip(message);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>Signal's PushTransportDetails padding: append a 0x80 terminator, then zero-pad to a 160-byte
|
||||
/// multiple. Applied to a serialized <c>Content</c> before encryption (1:1 and group) and removed after
|
||||
/// decryption. Shared by the 1:1 send/receive path and the group (Sender Key) path.</summary>
|
||||
public static class MessagePadding
|
||||
{
|
||||
public static byte[] Add(byte[] message)
|
||||
{
|
||||
var padded = new byte[PaddedLength(message.Length + 1) - 1];
|
||||
Array.Copy(message, padded, message.Length);
|
||||
padded[message.Length] = 0x80;
|
||||
return padded;
|
||||
}
|
||||
|
||||
public static byte[] Strip(byte[] message)
|
||||
{
|
||||
int paddingStart = 0;
|
||||
for (int i = message.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (message[i] == 0x80) { paddingStart = i; break; }
|
||||
if (message[i] != 0x00) { paddingStart = message.Length; break; }
|
||||
}
|
||||
return message[..paddingStart];
|
||||
}
|
||||
|
||||
private static int PaddedLength(int messageLength)
|
||||
{
|
||||
int withTerminator = messageLength + 1;
|
||||
int parts = withTerminator / 160;
|
||||
if (withTerminator % 160 != 0) parts++;
|
||||
return parts * 160;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.Ratchet;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Net;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Sends an unsealed (authenticated) 1:1 text. Fetches the recipient's prekey bundles, establishes a
|
||||
/// PQXDH session per device (initiator/Alice — SPQR runs automatically), encrypts a padded
|
||||
/// DataMessage, and PUTs the per-device ciphertexts to /v1/messages. To message your own account
|
||||
/// ("Note to Self"), pass your own ACI as the destination; your own device is skipped.
|
||||
/// </summary>
|
||||
public sealed class MessageSender
|
||||
{
|
||||
private readonly SignalAccount _account;
|
||||
private readonly ISignalProtocolStore _store;
|
||||
private readonly SignalRestClient _rest;
|
||||
private readonly Account.ProfileKeyStore? _profileKeys;
|
||||
private byte[]? _senderCertificate; // cached delivery certificate (~24h)
|
||||
|
||||
public MessageSender(SignalAccount account, ISignalProtocolStore store, SignalRestClient rest,
|
||||
Account.ProfileKeyStore? profileKeys = null)
|
||||
{
|
||||
_account = account;
|
||||
_store = store;
|
||||
_rest = rest;
|
||||
_profileKeys = profileKeys;
|
||||
}
|
||||
|
||||
public sealed record SendResult(bool Ok, int DeviceCount, string Detail);
|
||||
|
||||
// Sesame §3.3: if the recipient's device set changed (server 409 mismatched / 410 stale), re-fetch
|
||||
// the authoritative device list and retry, bounded to avoid looping on a malicious/buggy server.
|
||||
private const int MaxSendAttempts = 3;
|
||||
|
||||
public async Task<SendResult> SendTextAsync(string destinationServiceId, string text, CancellationToken ct = default)
|
||||
{
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var dataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp };
|
||||
var content = new Content { DataMessage = dataMessage };
|
||||
SendResult result = await SendContentAsync(destinationServiceId, content, timestamp, ct).ConfigureAwait(false);
|
||||
|
||||
// Multi-device: after messaging someone else, sync a "Sent" transcript to our OWN account so the
|
||||
// user's other linked devices show the outgoing message. (Note-to-Self already reaches them, since
|
||||
// that send targets our other devices directly.) Best-effort — never fails the real send.
|
||||
if (result.Ok && !IsSelf(destinationServiceId))
|
||||
await TrySyncSentTranscriptAsync(destinationServiceId, dataMessage, timestamp, ct).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Builds the <see cref="Content"/> that syncs an outgoing message to our other devices: a
|
||||
/// <c>SyncMessage.Sent</c> carrying the destination, timestamp, and the exact DataMessage we sent.</summary>
|
||||
public static Content BuildSentTranscript(string destinationServiceId, DataMessage message, long timestamp)
|
||||
{
|
||||
var sent = new SyncMessage.Types.Sent
|
||||
{
|
||||
DestinationServiceId = destinationServiceId,
|
||||
Timestamp = (ulong)timestamp,
|
||||
Message = message,
|
||||
};
|
||||
sent.UnidentifiedStatus.Add(new SyncMessage.Types.Sent.Types.UnidentifiedDeliveryStatus
|
||||
{
|
||||
DestinationServiceId = destinationServiceId,
|
||||
Unidentified = false,
|
||||
});
|
||||
return new Content { SyncMessage = new SyncMessage { Sent = sent } };
|
||||
}
|
||||
|
||||
private async Task TrySyncSentTranscriptAsync(string destinationServiceId, DataMessage message, long timestamp, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
Content transcript = BuildSentTranscript(destinationServiceId, message, timestamp);
|
||||
SendResult r = await SendContentAsync(_account.Aci, transcript, timestamp, ct).ConfigureAwait(false);
|
||||
FileLog.Write($"send: synced Sent transcript to self ok={r.Ok} detail={r.Detail}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"send: Sent transcript sync FAILED {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSelf(string serviceId) =>
|
||||
string.Equals(serviceId, _account.Aci, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort sealed-sender (metadata-minimized) delivery: if we know the recipient's profile key
|
||||
/// and can get a delivery certificate, re-wrap the already-built per-device ciphertexts as sealed
|
||||
/// envelopes and send them WITHOUT our auth credentials, using the recipient's unidentified-access
|
||||
/// key. Returns true only on success; ANY missing prerequisite or failure returns false so the
|
||||
/// caller falls back to the (working) authenticated send. The inner ciphertext is reused, so the
|
||||
/// ratchet is not advanced twice.
|
||||
/// </summary>
|
||||
private async Task<bool> TrySealedAsync(string destinationServiceId, OutgoingMessageList authList, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_profileKeys is null || IsSelf(destinationServiceId)) return false;
|
||||
byte[]? profileKey = _profileKeys.Get(destinationServiceId);
|
||||
if (profileKey is null) return false;
|
||||
|
||||
_senderCertificate ??= await _rest.GetSenderCertificateAsync(_account.BasicAuthToken(), ct).ConfigureAwait(false);
|
||||
byte[] accessKey = Wingnal.Service.Crypto.UnidentifiedAccess.DeriveAccessKey(profileKey);
|
||||
IdentityKeyPair ourIdentity = _account.AciIdentityKeyPair;
|
||||
|
||||
var sealedMessages = new List<OutgoingMessage>(authList.Messages.Length);
|
||||
foreach (OutgoingMessage m in authList.Messages)
|
||||
{
|
||||
IdentityKey? theirIdentity = _store.GetIdentity(new SignalProtocolAddress(destinationServiceId, m.DestinationDeviceId));
|
||||
if (theirIdentity is null) return false; // need their identity to seal
|
||||
|
||||
byte[] inner = DecodeBase64(m.Content);
|
||||
int innerType = m.Type == 3 ? 1 : 2; // PREKEY_MESSAGE / MESSAGE (sealed inner type)
|
||||
byte[] sealedBytes = SealedSenderDecryptor.EncryptWithCertificate(ourIdentity, theirIdentity, _senderCertificate, innerType, inner);
|
||||
sealedMessages.Add(new OutgoingMessage
|
||||
{
|
||||
Type = 6, // UNIDENTIFIED_SENDER
|
||||
DestinationDeviceId = m.DestinationDeviceId,
|
||||
DestinationRegistrationId = m.DestinationRegistrationId,
|
||||
Content = Convert.ToBase64String(sealedBytes),
|
||||
});
|
||||
}
|
||||
|
||||
var sealedList = new OutgoingMessageList
|
||||
{
|
||||
Messages = sealedMessages.ToArray(),
|
||||
Timestamp = authList.Timestamp,
|
||||
Online = authList.Online,
|
||||
Urgent = authList.Urgent,
|
||||
};
|
||||
(bool ok, _, _) = await _rest.SendSealedMessagesAsync(destinationServiceId, sealedList, accessKey, ct).ConfigureAwait(false);
|
||||
return ok;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // any problem → fall back to authenticated send (never regress)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sends our other devices a SyncMessage.Request for each of <paramref name="types"/> (a
|
||||
/// sync Content to our own ACI), asking the primary to push account state (contacts, blocked,
|
||||
/// configuration). GROUPS is intentionally omitted — it was removed from the sync protocol (groups
|
||||
/// now live in the storage service; see docs/GROUPS.md). Best-effort: returns the first failure.</summary>
|
||||
public async Task<SendResult> SendSyncRequestsAsync(IEnumerable<SyncMessage.Types.Request.Types.Type> types,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
SendResult last = new(true, 0, "no requests");
|
||||
foreach (SyncMessage.Types.Request.Types.Type type in types)
|
||||
{
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var content = new Content
|
||||
{
|
||||
SyncMessage = new SyncMessage { Request = new SyncMessage.Types.Request { Type = type } },
|
||||
};
|
||||
last = await SendContentAsync(_account.Aci, content, timestamp, ct).ConfigureAwait(false);
|
||||
if (!last.Ok) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>Encrypts a padded <see cref="Content"/> and sends it per-device (reuse-first, then
|
||||
/// fetch+establish on 409/410). The 1:1 text and sync-request paths both funnel through here.</summary>
|
||||
public async Task<SendResult> SendContentAsync(string destinationServiceId, Content content, long timestamp,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
byte[] padded = AddPadding(content.ToByteArray());
|
||||
string auth = _account.BasicAuthToken();
|
||||
|
||||
// Reuse path (Sesame): if we already have sessions for this recipient's devices, encrypt with
|
||||
// them and send WITHOUT a /v2/keys fetch. Only fall back to fetching when there's no session
|
||||
// (first message) or the server reports the device set changed (409/410).
|
||||
OutgoingMessageList? reuse = BuildFromExistingSessions(destinationServiceId, padded, timestamp);
|
||||
if (reuse is { Messages.Length: > 0 })
|
||||
{
|
||||
if (await TrySealedAsync(destinationServiceId, reuse, ct).ConfigureAwait(false))
|
||||
return new SendResult(true, reuse.Messages.Length, $"sent sealed to {reuse.Messages.Length} device(s) (reused sessions)");
|
||||
(bool ok, var status, string body) = await _rest.SendMessagesAsync(destinationServiceId, reuse, auth, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
return new SendResult(true, reuse.Messages.Length, $"sent to {reuse.Messages.Length} device(s) (reused sessions)");
|
||||
if (status is not (System.Net.HttpStatusCode.Conflict or System.Net.HttpStatusCode.Gone))
|
||||
return new SendResult(false, reuse.Messages.Length, $"{(int)status}: {body}");
|
||||
// device set changed — fall through to the fetch+establish path below.
|
||||
}
|
||||
|
||||
for (int attempt = 1; attempt <= MaxSendAttempts; attempt++)
|
||||
{
|
||||
// "*" returns the recipient's current device set, so a re-fetch reconciles both missing
|
||||
// (added) and stale (removed/rotated) devices.
|
||||
PreKeyResponse bundles = await _rest.GetPreKeysAsync(destinationServiceId, "*", auth, ct).ConfigureAwait(false);
|
||||
|
||||
OutgoingMessageList list = BuildOutgoingList(destinationServiceId, padded, bundles, timestamp);
|
||||
if (list.Messages.Length == 0)
|
||||
return new SendResult(false, 0, "no target devices (only our own device present)");
|
||||
|
||||
if (await TrySealedAsync(destinationServiceId, list, ct).ConfigureAwait(false))
|
||||
return new SendResult(true, list.Messages.Length, $"sent sealed to {list.Messages.Length} device(s)");
|
||||
|
||||
(bool ok, var status, string body) = await _rest.SendMessagesAsync(destinationServiceId, list, auth, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
return new SendResult(true, list.Messages.Length, $"sent to {list.Messages.Length} device(s)");
|
||||
|
||||
bool deviceSetChanged = status is System.Net.HttpStatusCode.Conflict or System.Net.HttpStatusCode.Gone;
|
||||
if (!deviceSetChanged || attempt == MaxSendAttempts)
|
||||
return new SendResult(false, list.Messages.Length, $"{(int)status}: {body}");
|
||||
// else: device set changed — loop to re-fetch and retry.
|
||||
}
|
||||
return new SendResult(false, 0, "device set kept changing after retries");
|
||||
}
|
||||
|
||||
/// <summary>Pure (no network): encrypt a padded text DataMessage to every device in the bundle and
|
||||
/// build the per-device outgoing list. Exposed for offline testing.</summary>
|
||||
public OutgoingMessageList BuildOutgoingList(string destinationServiceId, string text, PreKeyResponse bundles, long timestamp)
|
||||
{
|
||||
var content = new Content { DataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp } };
|
||||
return BuildOutgoingList(destinationServiceId, AddPadding(content.ToByteArray()), bundles, timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Encrypt already-padded Content bytes to every device in the bundle.</summary>
|
||||
public OutgoingMessageList BuildOutgoingList(string destinationServiceId, byte[] padded, PreKeyResponse bundles, long timestamp)
|
||||
{
|
||||
IdentityKey theirIdentity = IdentityKey.Decode(DecodeBase64(bundles.IdentityKey));
|
||||
var messages = new List<OutgoingMessage>();
|
||||
foreach (PreKeyResponseDevice dev in bundles.Devices)
|
||||
{
|
||||
// Skip our own device when messaging our own account (Note to Self).
|
||||
if (string.Equals(destinationServiceId, _account.Aci, StringComparison.OrdinalIgnoreCase)
|
||||
&& dev.DeviceId == (uint)_account.DeviceId)
|
||||
continue;
|
||||
|
||||
var address = new SignalProtocolAddress(destinationServiceId, dev.DeviceId);
|
||||
PreKeyBundle bundle = BuildBundle(theirIdentity, dev);
|
||||
|
||||
new SessionBuilder(_store, _store, _store, _store, _store, address).Process(bundle);
|
||||
ICiphertextMessage cipher = new SessionCipher(_store, _store, _store, _store, _store, address).Encrypt(padded);
|
||||
|
||||
int wireType = cipher.Type == CiphertextMessageType.PreKey ? 3 : 1; // PREKEY_MESSAGE / DOUBLE_RATCHET
|
||||
messages.Add(new OutgoingMessage
|
||||
{
|
||||
Type = wireType,
|
||||
DestinationDeviceId = dev.DeviceId,
|
||||
DestinationRegistrationId = dev.RegistrationId,
|
||||
Content = Convert.ToBase64String(cipher.Serialize()),
|
||||
});
|
||||
}
|
||||
|
||||
return new OutgoingMessageList
|
||||
{
|
||||
Messages = messages.ToArray(),
|
||||
Timestamp = timestamp,
|
||||
Online = false,
|
||||
Urgent = true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Encrypt to every device of <paramref name="destinationServiceId"/> that already has a
|
||||
/// session, reusing it (no prekey fetch). Returns null if there are no existing sessions. Public for
|
||||
/// offline testing.</summary>
|
||||
public OutgoingMessageList? BuildFromExistingSessions(string destinationServiceId, string text, long timestamp)
|
||||
{
|
||||
var content = new Content { DataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp } };
|
||||
return BuildFromExistingSessions(destinationServiceId, AddPadding(content.ToByteArray()), timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Reuse existing sessions to encrypt already-padded Content bytes.</summary>
|
||||
public OutgoingMessageList? BuildFromExistingSessions(string destinationServiceId, byte[] padded, long timestamp)
|
||||
{
|
||||
IReadOnlyList<uint> deviceIds = _store.GetSubDeviceSessions(destinationServiceId);
|
||||
if (deviceIds.Count == 0) return null;
|
||||
|
||||
var messages = new List<OutgoingMessage>();
|
||||
foreach (uint deviceId in deviceIds)
|
||||
{
|
||||
if (string.Equals(destinationServiceId, _account.Aci, StringComparison.OrdinalIgnoreCase)
|
||||
&& deviceId == (uint)_account.DeviceId)
|
||||
continue;
|
||||
|
||||
var address = new SignalProtocolAddress(destinationServiceId, deviceId);
|
||||
uint registrationId = _store.LoadSession(address).State.RemoteRegistrationId;
|
||||
ICiphertextMessage cipher = new SessionCipher(_store, _store, _store, _store, _store, address).Encrypt(padded);
|
||||
messages.Add(new OutgoingMessage
|
||||
{
|
||||
Type = cipher.Type == CiphertextMessageType.PreKey ? 3 : 1,
|
||||
DestinationDeviceId = deviceId,
|
||||
DestinationRegistrationId = registrationId,
|
||||
Content = Convert.ToBase64String(cipher.Serialize()),
|
||||
});
|
||||
}
|
||||
|
||||
return new OutgoingMessageList
|
||||
{
|
||||
Messages = messages.ToArray(),
|
||||
Timestamp = timestamp,
|
||||
Online = false,
|
||||
Urgent = true,
|
||||
};
|
||||
}
|
||||
|
||||
private static PreKeyBundle BuildBundle(IdentityKey theirIdentity, PreKeyResponseDevice dev)
|
||||
{
|
||||
byte[] signedPreKey = Curve25519.DecodePoint(DecodeBase64(dev.SignedPreKey.PublicKey));
|
||||
byte[]? preKey = dev.PreKey is { } pk ? Curve25519.DecodePoint(DecodeBase64(pk.PublicKey)) : null;
|
||||
byte[]? kyber = dev.PqPreKey is { } qk ? KemKeySerialization.Deserialize(DecodeBase64(qk.PublicKey)) : null;
|
||||
|
||||
return new PreKeyBundle(
|
||||
registrationId: dev.RegistrationId,
|
||||
deviceId: dev.DeviceId,
|
||||
preKeyId: dev.PreKey?.KeyId,
|
||||
preKeyPublic: preKey,
|
||||
signedPreKeyId: dev.SignedPreKey.KeyId,
|
||||
signedPreKeyPublic: signedPreKey,
|
||||
signedPreKeySignature: DecodeBase64(dev.SignedPreKey.Signature),
|
||||
identityKey: theirIdentity,
|
||||
kyberPreKeyId: dev.PqPreKey?.KeyId,
|
||||
kyberPreKeyPublic: kyber,
|
||||
kyberPreKeySignature: dev.PqPreKey is { } q ? DecodeBase64(q.Signature) : null);
|
||||
}
|
||||
|
||||
// Signal serializes keys/signatures as base64 (sometimes URL-safe and/or without padding).
|
||||
private static byte[] DecodeBase64(string s)
|
||||
{
|
||||
string t = s.Replace('-', '+').Replace('_', '/');
|
||||
switch (t.Length % 4) { case 2: t += "=="; break; case 3: t += "="; break; }
|
||||
return Convert.FromBase64String(t);
|
||||
}
|
||||
|
||||
// Signal PushTransportDetails padding (shared with the group path + the receive-side strip).
|
||||
private static byte[] AddPadding(byte[] message) => MessagePadding.Add(message);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Service.Account;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>A stored 1:1 message. <see cref="MediaPath"/> is the local file of a downloaded attachment
|
||||
/// (null for plain text).</summary>
|
||||
public sealed record StoredMessage(string Peer, string Body, long Timestamp, bool Outgoing)
|
||||
{
|
||||
public string? MediaPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One conversation thread, keyed by peer service id, with its most recent message.</summary>
|
||||
public sealed record Conversation(string Peer, string LastBody, long LastTimestamp, bool LastOutgoing);
|
||||
|
||||
/// <summary>
|
||||
/// SQLite store for received/sent 1:1 texts (%LOCALAPPDATA%\Wingnal\messages.db). Message BODIES are
|
||||
/// encrypted at rest with <see cref="LocalCipher"/> (peer/timestamp metadata stays plaintext so the
|
||||
/// list/threads can still be queried + sorted). Legacy plaintext rows decrypt-through unchanged.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class MessageStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public MessageStore(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, "messages.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
peer TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
outgoing INTEGER NOT NULL,
|
||||
media TEXT
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
// Idempotent migration for DBs created before the media column existed.
|
||||
try
|
||||
{
|
||||
using SqliteCommand alter = conn.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE messages ADD COLUMN media TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
catch (SqliteException) { /* column already exists */ }
|
||||
}
|
||||
|
||||
public void Add(StoredMessage message)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT INTO messages (peer, body, timestamp, outgoing, media) VALUES ($peer, $body, $ts, $out, $media);";
|
||||
cmd.Parameters.AddWithValue("$peer", message.Peer);
|
||||
cmd.Parameters.AddWithValue("$body", _cipher.Protect(message.Body)); // encrypted at rest
|
||||
cmd.Parameters.AddWithValue("$ts", message.Timestamp);
|
||||
cmd.Parameters.AddWithValue("$out", message.Outgoing ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$media", message.MediaPath is null ? DBNull.Value : _cipher.Protect(message.MediaPath));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The most recent <paramref name="limit"/> messages for one peer's thread, returned
|
||||
/// oldest-first for display. (Selects the NEWEST N by timestamp DESC then reverses — selecting ASC
|
||||
/// would return the OLDEST N and hide recent history in a long thread.)</summary>
|
||||
public IReadOnlyList<StoredMessage> Recent(string peer, int limit = 500)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT peer, body, timestamp, outgoing, media FROM messages WHERE peer = $peer ORDER BY timestamp DESC LIMIT $limit;";
|
||||
cmd.Parameters.AddWithValue("$peer", peer);
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var result = new List<StoredMessage>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
result.Add(new StoredMessage(reader.GetString(0), _cipher.Unprotect(reader.GetString(1)),
|
||||
reader.GetInt64(2), reader.GetInt64(3) != 0)
|
||||
{
|
||||
MediaPath = reader.IsDBNull(4) ? null : _cipher.Unprotect(reader.GetString(4)),
|
||||
});
|
||||
result.Reverse(); // chronological (oldest → newest) for the thread view
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>One row per peer (the conversation list), most-recently-active first.</summary>
|
||||
public IReadOnlyList<Conversation> Conversations()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
// Pick each peer's CHRONOLOGICALLY newest message (max TIMESTAMP, not max id — rows are inserted
|
||||
// in import order, not time order). SQLite fills the bare columns from the MAX(timestamp) row.
|
||||
cmd.CommandText =
|
||||
"""
|
||||
SELECT peer, body, MAX(timestamp) AS timestamp, outgoing
|
||||
FROM messages
|
||||
GROUP BY peer
|
||||
ORDER BY timestamp DESC;
|
||||
""";
|
||||
|
||||
var result = new List<Conversation>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
result.Add(new Conversation(reader.GetString(0), _cipher.Unprotect(reader.GetString(1)),
|
||||
reader.GetInt64(2), reader.GetInt64(3) != 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>A set of identity keys for the messages already stored (peer‖ts‖outgoing‖body), so a
|
||||
/// bulk import can skip ones it already added (idempotent re-import).</summary>
|
||||
public HashSet<string> ExistingKeys()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT peer, timestamp, outgoing, body FROM messages;";
|
||||
var set = new HashSet<string>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
set.Add(KeyOf(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2) != 0,
|
||||
_cipher.Unprotect(reader.GetString(3))));
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>The de-dup identity of a message.</summary>
|
||||
public static string KeyOf(string peer, long timestamp, bool outgoing, string body) =>
|
||||
string.Join('|', peer, timestamp, outgoing ? 1 : 0, body);
|
||||
|
||||
/// <summary>Removes exact-duplicate rows (same peer+timestamp+outgoing+body), keeping the earliest.
|
||||
/// Done in code because the body column is now encrypted (non-deterministic), so SQL GROUP BY can't
|
||||
/// see content equality. Returns rows deleted.</summary>
|
||||
public int Deduplicate()
|
||||
{
|
||||
using var conn = Open();
|
||||
var seen = new HashSet<string>();
|
||||
var toDelete = new List<long>();
|
||||
using (SqliteCommand read = conn.CreateCommand())
|
||||
{
|
||||
read.CommandText = "SELECT id, peer, timestamp, outgoing, body FROM messages ORDER BY id;";
|
||||
using SqliteDataReader reader = read.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
string key = KeyOf(reader.GetString(1), reader.GetInt64(2), reader.GetInt64(3) != 0,
|
||||
_cipher.Unprotect(reader.GetString(4)));
|
||||
if (!seen.Add(key)) toDelete.Add(reader.GetInt64(0));
|
||||
}
|
||||
}
|
||||
foreach (long id in toDelete)
|
||||
{
|
||||
using SqliteCommand del = conn.CreateCommand();
|
||||
del.CommandText = "DELETE FROM messages WHERE id = $id;";
|
||||
del.Parameters.AddWithValue("$id", id);
|
||||
del.ExecuteNonQuery();
|
||||
}
|
||||
return toDelete.Count;
|
||||
}
|
||||
|
||||
/// <summary>Removes all stored messages (used when unlinking).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM messages;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Deletes every message in one peer's thread (peer is stored in the clear).</summary>
|
||||
public void DeleteConversation(string peer)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM messages WHERE peer = $peer;";
|
||||
cmd.Parameters.AddWithValue("$peer", peer);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Deletes one message identified by (peer, timestamp, direction). Body isn't used since it's
|
||||
/// encrypted; the timestamp is effectively unique within a peer + direction.</summary>
|
||||
public void DeleteMessage(string peer, long timestamp, bool outgoing)
|
||||
{
|
||||
using var conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM messages WHERE peer = $peer AND timestamp = $ts AND outgoing = $out;";
|
||||
cmd.Parameters.AddWithValue("$peer", peer);
|
||||
cmd.Parameters.AddWithValue("$ts", timestamp);
|
||||
cmd.Parameters.AddWithValue("$out", outgoing ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the small "sideband" <see cref="Content"/> messages that ride alongside chat: delivery/read
|
||||
/// receipts (the ✓✓ that tells the sender we got/read their message) and typing notifications. Pure
|
||||
/// (no network), so the wire shape is offline-testable.
|
||||
/// </summary>
|
||||
public static class Receipts
|
||||
{
|
||||
/// <summary>A DELIVERY receipt acknowledging the message(s) with these sent-timestamps arrived.</summary>
|
||||
public static Content Delivery(params long[] sentTimestamps) =>
|
||||
Build(ReceiptMessage.Types.Type.Delivery, sentTimestamps);
|
||||
|
||||
/// <summary>A READ receipt for the message(s) the user has now seen.</summary>
|
||||
public static Content Read(params long[] sentTimestamps) =>
|
||||
Build(ReceiptMessage.Types.Type.Read, sentTimestamps);
|
||||
|
||||
/// <summary>A typing START/STOP notification for a 1:1 thread.</summary>
|
||||
public static Content Typing(bool started, long timestamp) => new()
|
||||
{
|
||||
TypingMessage = new TypingMessage
|
||||
{
|
||||
Action = started ? TypingMessage.Types.Action.Started : TypingMessage.Types.Action.Stopped,
|
||||
Timestamp = (ulong)timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
private static Content Build(ReceiptMessage.Types.Type type, IEnumerable<long> timestamps)
|
||||
{
|
||||
var receipt = new ReceiptMessage { Type = type };
|
||||
foreach (long t in timestamps) receipt.Timestamp.Add((ulong)t);
|
||||
return new Content { ReceiptMessage = receipt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves user-entered recipient text into a destination service id (ACI UUID). An ACI is accepted
|
||||
/// directly (normalized to lowercase canonical form). A phone number (e164) currently can't be resolved
|
||||
/// client-side: Signal removed the unauthenticated number→ACI lookup, so it requires the Contact
|
||||
/// Discovery Service (CDSI, an SGX-attested enclave) which Wingnal doesn't implement yet — see
|
||||
/// SHORTCUTS.md. Until then, paste the contact's ACI UUID.
|
||||
/// </summary>
|
||||
public static partial class RecipientResolver
|
||||
{
|
||||
public sealed record Result(bool Ok, string? ServiceId, string? Error);
|
||||
|
||||
[GeneratedRegex(@"^\+[1-9]\d{6,14}$")]
|
||||
private static partial Regex E164();
|
||||
|
||||
public static Result Resolve(string? input)
|
||||
{
|
||||
string text = (input ?? string.Empty).Trim();
|
||||
if (text.Length == 0)
|
||||
return new Result(false, null, "Enter a recipient.");
|
||||
|
||||
if (Guid.TryParse(text, out Guid aci))
|
||||
return new Result(true, aci.ToString("D").ToLowerInvariant(), null);
|
||||
|
||||
if (E164().IsMatch(text))
|
||||
return new Result(false, null,
|
||||
"Phone-number lookup needs Signal's Contact Discovery (CDSI), which Wingnal doesn't " +
|
||||
"support yet. Paste the contact's ACI UUID instead.");
|
||||
|
||||
return new Result(false, null, "Not a valid ACI UUID (or +e164 number).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Security.Cryptography;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.State;
|
||||
using Wingnal.Service.Protos.SealedSender;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a Sealed Sender v1 envelope (UNIDENTIFIED_SENDER) to recover the sender and the inner
|
||||
/// ciphertext, which the normal session pipeline then decrypts. This is how messages other people send
|
||||
/// us (and their replies) arrive — modern Signal clients default to sealed sender. Byte-exact with
|
||||
/// libsignal v0.96.1 sealed_sender.rs (v1). Sealed Sender v2 (multi-recipient) is not handled here.
|
||||
///
|
||||
/// NOTE: the sender certificate's server signature is NOT validated against Signal's trust root yet
|
||||
/// (see SHORTCUTS.md). The message is still cryptographically authenticated by the inner Double Ratchet
|
||||
/// session (its MAC binds to the sender's identity key), so a forged certificate can't forge content;
|
||||
/// trust-root validation only adds server-attested sender identity.
|
||||
/// </summary>
|
||||
public static class SealedSenderDecryptor
|
||||
{
|
||||
private const byte SealedSenderV1MajorVersion = 1;
|
||||
private const byte SealedSenderV2MajorVersion = 2;
|
||||
private static readonly byte[] SaltPrefix = "UnidentifiedDelivery"u8.ToArray();
|
||||
|
||||
/// <summary>The unwrapped sealed-sender payload: who sent it + the inner ciphertext to feed the
|
||||
/// session cipher.</summary>
|
||||
public sealed record Unsealed(string SenderUuid, uint SenderDevice, int CiphertextType, byte[] Content);
|
||||
|
||||
public static Unsealed Decrypt(byte[] serialized, IdentityKeyPair ourIdentity, long nowMs,
|
||||
IReadOnlyList<byte[]>? trustRoots = null)
|
||||
{
|
||||
if (serialized.Length < 1) throw new InvalidMessageException("sealed sender message empty");
|
||||
int version = (serialized[0] >> 4) & 0xF;
|
||||
if (version == SealedSenderV2MajorVersion)
|
||||
throw new InvalidMessageException("sealed sender v2 not supported");
|
||||
if (version is not (0 or SealedSenderV1MajorVersion))
|
||||
throw new InvalidMessageException($"unknown sealed sender version {version}");
|
||||
|
||||
UnidentifiedSenderMessage outer = UnidentifiedSenderMessage.Parser.ParseFrom(serialized.AsSpan(1).ToArray());
|
||||
byte[] ephemeralPublic = Curve25519.DecodePoint(outer.EphemeralPublic.Span);
|
||||
byte[] encryptedStatic = outer.EncryptedStatic.ToByteArray();
|
||||
byte[] encryptedMessage = outer.EncryptedMessage.ToByteArray();
|
||||
|
||||
byte[] ourIdPriv = ourIdentity.PrivateKey;
|
||||
byte[] ourIdPub = ourIdentity.PublicKey.Serialize(); // 33-byte DjbECPublicKey
|
||||
|
||||
// Ephemeral keys: HKDF(salt = "UnidentifiedDelivery" || ourPub || ephPub, ikm = ECDH(eph, ourId)).
|
||||
byte[] ephSalt = Concat(SaltPrefix, ourIdPub, Curve25519.EncodePoint(ephemeralPublic));
|
||||
byte[] ephSecret = Curve25519.CalculateAgreement(ephemeralPublic, ourIdPriv);
|
||||
byte[] ephKeys = CryptoPrimitives.Hkdf(ephSecret, ephSalt, info: null, 96);
|
||||
byte[] chainKey = ephKeys.AsSpan(0, 32).ToArray();
|
||||
byte[] ephCipherKey = ephKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] ephMacKey = ephKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
byte[] senderStaticBytes = DecryptCtrHmac(encryptedStatic, ephCipherKey, ephMacKey);
|
||||
byte[] senderStaticPublic = Curve25519.DecodePoint(senderStaticBytes);
|
||||
|
||||
// Static keys: HKDF(salt = chainKey || encryptedStatic, ikm = ECDH(senderStatic, ourId)); first 32 discarded.
|
||||
byte[] staticSalt = Concat(chainKey, encryptedStatic);
|
||||
byte[] staticSecret = Curve25519.CalculateAgreement(senderStaticPublic, ourIdPriv);
|
||||
byte[] staticKeys = CryptoPrimitives.Hkdf(staticSecret, staticSalt, info: null, 96);
|
||||
byte[] staticCipherKey = staticKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] staticMacKey = staticKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
byte[] innerBytes = DecryptCtrHmac(encryptedMessage, staticCipherKey, staticMacKey);
|
||||
UnidentifiedSenderMessage.Types.Message inner = UnidentifiedSenderMessage.Types.Message.Parser.ParseFrom(innerBytes);
|
||||
|
||||
// Validate the server-attested sender certificate (trust root → server → sender, not expired)
|
||||
// before believing the claimed sender.
|
||||
var senderCert = SenderCertificate.Parser.ParseFrom(inner.SenderCertificate);
|
||||
SenderCertificateValidator.Validate(senderCert, nowMs, trustRoots ?? SenderCertificateValidator.ProductionTrustRoots);
|
||||
|
||||
(string uuid, uint device) = ExtractSender(senderCert);
|
||||
return new Unsealed(uuid, device, (int)inner.Type, inner.Content.ToByteArray());
|
||||
}
|
||||
|
||||
/// <summary>Builds a Sealed Sender v1 envelope using a real (server-issued) sender certificate.
|
||||
/// Mirrors libsignal sealed_sender_encrypt.</summary>
|
||||
public static byte[] EncryptWithCertificate(IdentityKeyPair senderIdentity, IdentityKey recipientIdentity,
|
||||
byte[] senderCertificate, int ciphertextType, byte[] content)
|
||||
{
|
||||
byte[] recipientPubRaw = recipientIdentity.PublicKey;
|
||||
byte[] recipientPubEnc = recipientIdentity.Serialize();
|
||||
byte[] senderIdPubEnc = senderIdentity.PublicKey.Serialize();
|
||||
|
||||
ECKeyPair ephemeral = Curve25519.GenerateKeyPair();
|
||||
byte[] ephPubEnc = Curve25519.EncodePoint(ephemeral.PublicKey);
|
||||
|
||||
byte[] ephSalt = Concat(SaltPrefix, recipientPubEnc, ephPubEnc);
|
||||
byte[] ephSecret = Curve25519.CalculateAgreement(recipientPubRaw, ephemeral.PrivateKey);
|
||||
byte[] ephKeys = CryptoPrimitives.Hkdf(ephSecret, ephSalt, info: null, 96);
|
||||
byte[] chainKey = ephKeys.AsSpan(0, 32).ToArray();
|
||||
byte[] ephCipherKey = ephKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] ephMacKey = ephKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
byte[] encryptedStatic = EncryptCtrHmac(senderIdPubEnc, ephCipherKey, ephMacKey);
|
||||
|
||||
byte[] staticSalt = Concat(chainKey, encryptedStatic);
|
||||
byte[] staticSecret = Curve25519.CalculateAgreement(recipientPubRaw, senderIdentity.PrivateKey);
|
||||
byte[] staticKeys = CryptoPrimitives.Hkdf(staticSecret, staticSalt, info: null, 96);
|
||||
byte[] staticCipherKey = staticKeys.AsSpan(32, 32).ToArray();
|
||||
byte[] staticMacKey = staticKeys.AsSpan(64, 32).ToArray();
|
||||
|
||||
var inner = new UnidentifiedSenderMessage.Types.Message
|
||||
{
|
||||
Type = (UnidentifiedSenderMessage.Types.Message.Types.Type)ciphertextType,
|
||||
SenderCertificate = Google.Protobuf.ByteString.CopyFrom(senderCertificate),
|
||||
Content = Google.Protobuf.ByteString.CopyFrom(content),
|
||||
};
|
||||
byte[] encryptedMessage = EncryptCtrHmac(inner.ToByteArray(), staticCipherKey, staticMacKey);
|
||||
|
||||
var outer = new UnidentifiedSenderMessage
|
||||
{
|
||||
EphemeralPublic = Google.Protobuf.ByteString.CopyFrom(ephPubEnc),
|
||||
EncryptedStatic = Google.Protobuf.ByteString.CopyFrom(encryptedStatic),
|
||||
EncryptedMessage = Google.Protobuf.ByteString.CopyFrom(encryptedMessage),
|
||||
};
|
||||
byte[] body = outer.ToByteArray();
|
||||
var result = new byte[1 + body.Length];
|
||||
result[0] = 0x11; // SEALED_SENDER_V1_FULL_VERSION
|
||||
Buffer.BlockCopy(body, 0, result, 1, body.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Test helper: builds a signed cert chain (trustRoot → server → sender) and seals with it.</summary>
|
||||
public static byte[] Encrypt(IdentityKeyPair senderIdentity, IdentityKey recipientIdentity,
|
||||
string senderUuid, uint senderDevice, int ciphertextType, byte[] content, ECKeyPair trustRoot, long expiresMs)
|
||||
{
|
||||
byte[] cert = BuildCertificate(senderIdentity, senderUuid, senderDevice, trustRoot, expiresMs);
|
||||
return EncryptWithCertificate(senderIdentity, recipientIdentity, cert, ciphertextType, content);
|
||||
}
|
||||
|
||||
private static byte[] BuildCertificate(IdentityKeyPair senderIdentity, string senderUuid, uint senderDevice,
|
||||
ECKeyPair trustRoot, long expiresMs)
|
||||
{
|
||||
ECKeyPair serverKey = Curve25519.GenerateKeyPair();
|
||||
var serverInner = new ServerCertificate.Types.Certificate
|
||||
{
|
||||
Id = 1,
|
||||
Key = Google.Protobuf.ByteString.CopyFrom(Curve25519.EncodePoint(serverKey.PublicKey)),
|
||||
};
|
||||
byte[] serverInnerBytes = serverInner.ToByteArray();
|
||||
var serverCert = new ServerCertificate
|
||||
{
|
||||
Certificate = Google.Protobuf.ByteString.CopyFrom(serverInnerBytes),
|
||||
Signature = Google.Protobuf.ByteString.CopyFrom(
|
||||
XEd25519.CalculateSignature(trustRoot.PrivateKey, serverInnerBytes, RandomNumberGenerator.GetBytes(64))),
|
||||
};
|
||||
var certInner = new SenderCertificate.Types.Certificate
|
||||
{
|
||||
UuidString = senderUuid,
|
||||
SenderDevice = senderDevice,
|
||||
Expires = (ulong)expiresMs,
|
||||
IdentityKey = Google.Protobuf.ByteString.CopyFrom(senderIdentity.PublicKey.Serialize()),
|
||||
Certificate_ = serverCert.ToByteString(),
|
||||
};
|
||||
byte[] certInnerBytes = certInner.ToByteArray();
|
||||
return new SenderCertificate
|
||||
{
|
||||
Certificate = Google.Protobuf.ByteString.CopyFrom(certInnerBytes),
|
||||
Signature = Google.Protobuf.ByteString.CopyFrom(
|
||||
XEd25519.CalculateSignature(serverKey.PrivateKey, certInnerBytes, RandomNumberGenerator.GetBytes(64))),
|
||||
}.ToByteArray();
|
||||
}
|
||||
|
||||
private static byte[] EncryptCtrHmac(byte[] plaintext, byte[] cipherKey, byte[] macKey)
|
||||
{
|
||||
byte[] ctext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], plaintext);
|
||||
byte[] mac = CryptoPrimitives.HmacSha256(macKey, ctext).AsSpan(0, 10).ToArray();
|
||||
var result = new byte[ctext.Length + 10];
|
||||
Buffer.BlockCopy(ctext, 0, result, 0, ctext.Length);
|
||||
Buffer.BlockCopy(mac, 0, result, ctext.Length, 10);
|
||||
return result;
|
||||
}
|
||||
|
||||
// aes256_ctr_hmacsha256: data = AES-256-CTR(ct) || HMAC-SHA256(macKey, ct)[..10]. Zero CTR nonce.
|
||||
private static byte[] DecryptCtrHmac(byte[] data, byte[] cipherKey, byte[] macKey)
|
||||
{
|
||||
const int macLen = 10;
|
||||
if (data.Length < macLen) throw new InvalidMessageException("sealed sender ciphertext truncated");
|
||||
int ctLen = data.Length - macLen;
|
||||
byte[] ctext = data.AsSpan(0, ctLen).ToArray();
|
||||
byte[] theirMac = data.AsSpan(ctLen, macLen).ToArray();
|
||||
|
||||
byte[] ourMac = CryptoPrimitives.HmacSha256(macKey, ctext).AsSpan(0, macLen).ToArray();
|
||||
if (!CryptographicOperations.FixedTimeEquals(ourMac, theirMac))
|
||||
throw new InvalidMessageException("sealed sender MAC mismatch");
|
||||
|
||||
return CryptoPrimitives.AesCtr(cipherKey, new byte[16], ctext);
|
||||
}
|
||||
|
||||
private static (string Uuid, uint Device) ExtractSender(SenderCertificate cert)
|
||||
{
|
||||
var inner = SenderCertificate.Types.Certificate.Parser.ParseFrom(cert.Certificate);
|
||||
|
||||
string uuid = inner.SenderUuidCase switch
|
||||
{
|
||||
SenderCertificate.Types.Certificate.SenderUuidOneofCase.UuidString => inner.UuidString,
|
||||
SenderCertificate.Types.Certificate.SenderUuidOneofCase.UuidBytes => ServiceIds.StringFromBinary(inner.UuidBytes.Span) ?? "",
|
||||
_ => string.Empty,
|
||||
};
|
||||
if (string.IsNullOrEmpty(uuid))
|
||||
throw new InvalidMessageException("sealed sender certificate has no sender uuid");
|
||||
return (uuid.ToLowerInvariant(), inner.SenderDevice);
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] parts)
|
||||
{
|
||||
var result = new byte[parts.Sum(p => p.Length)];
|
||||
int o = 0;
|
||||
foreach (byte[] p in parts) { Buffer.BlockCopy(p, 0, result, o, p.Length); o += p.Length; }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Service.Protos.SealedSender;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a sealed-sender <see cref="SenderCertificate"/> against Signal's unidentified-sender trust
|
||||
/// root: the trust root must have signed the embedded <see cref="ServerCertificate"/>, that server key
|
||||
/// must have signed the sender certificate, and the certificate must not have expired. This is what
|
||||
/// proves the claimed sender ACI/device is server-attested (not just self-asserted). Mirrors libsignal
|
||||
/// sealed_sender.rs <c>SenderCertificate::validate</c>.
|
||||
/// </summary>
|
||||
public static class SenderCertificateValidator
|
||||
{
|
||||
/// <summary>Signal's production unidentified-sender trust roots (33-byte DjbECPublicKey).</summary>
|
||||
public static readonly IReadOnlyList<byte[]> ProductionTrustRoots = new[]
|
||||
{
|
||||
Convert.FromBase64String("BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF"),
|
||||
Convert.FromBase64String("BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6"),
|
||||
};
|
||||
|
||||
/// <summary>Server certificates that real sender certificates reference by id (field 8) instead of
|
||||
/// embedding (field 5), to save space — keyed by id. From libsignal's <c>KNOWN_SERVER_CERTIFICATES</c>
|
||||
/// (id 2 = staging, id 3 = production). These are the full serialized <see cref="ServerCertificate"/> protobufs.</summary>
|
||||
private static readonly IReadOnlyDictionary<uint, byte[]> KnownServerCertificates = new Dictionary<uint, byte[]>
|
||||
{
|
||||
[2] = Convert.FromHexString(
|
||||
"0a25080212210539450d63ebd0752c0fd4038b9d07a916f5e174b756d409b5ca79f4c97400631e" +
|
||||
"124064c5a38b1e927497d3d4786b101a623ab34a7da3954fae126b04dba9d7a3604ed88cdc8550950f0d4a9134ceb7e19b94139151d2c3d6e1c81e9d1128aafca806"),
|
||||
[3] = Convert.FromHexString(
|
||||
"0a250803122105bc9d1d290be964810dfa7e94856480a3f7060d004c9762c24c575a1522353a5a" +
|
||||
"1240c11ec3c401eb0107ab38f8600e8720a63169e0e2eb8a3fae24f63099f85ea319c3c1c46d3454706ae2a679d1fee690a488adda98a2290b66c906bb60295ed781"),
|
||||
};
|
||||
|
||||
public static void Validate(SenderCertificate cert, long nowMs, IReadOnlyList<byte[]> trustRoots)
|
||||
{
|
||||
SenderCertificate.Types.Certificate inner =
|
||||
SenderCertificate.Types.Certificate.Parser.ParseFrom(cert.Certificate);
|
||||
|
||||
// The signer is either embedded (field 5) or referenced by id (field 8); real Signal certs use the id.
|
||||
byte[] serverCertBytes = inner.SignerCase switch
|
||||
{
|
||||
SenderCertificate.Types.Certificate.SignerOneofCase.Certificate_ => inner.Certificate_.ToByteArray(),
|
||||
SenderCertificate.Types.Certificate.SignerOneofCase.Id when KnownServerCertificates.TryGetValue(inner.Id, out byte[]? c) => c,
|
||||
SenderCertificate.Types.Certificate.SignerOneofCase.Id =>
|
||||
throw new InvalidMessageException($"unknown sealed-sender server certificate id {inner.Id}"),
|
||||
_ => throw new InvalidMessageException("sender certificate has no server certificate"),
|
||||
};
|
||||
if ((long)inner.Expires < nowMs)
|
||||
throw new InvalidMessageException("sender certificate expired");
|
||||
|
||||
// 1) The trust root must have signed the server certificate.
|
||||
ServerCertificate server = ServerCertificate.Parser.ParseFrom(serverCertBytes);
|
||||
bool serverTrusted = trustRoots.Any(root =>
|
||||
XEd25519.VerifySignature(Curve25519.DecodePoint(root),
|
||||
server.Certificate.Span, server.Signature.Span));
|
||||
if (!serverTrusted)
|
||||
throw new InvalidMessageException("server certificate not signed by a trust root");
|
||||
|
||||
// 2) The server key must have signed the sender certificate.
|
||||
ServerCertificate.Types.Certificate serverInner =
|
||||
ServerCertificate.Types.Certificate.Parser.ParseFrom(server.Certificate);
|
||||
byte[] serverKey = Curve25519.DecodePoint(serverInner.Key.Span);
|
||||
if (!XEd25519.VerifySignature(serverKey, cert.Certificate.Span, cert.Signature.Span))
|
||||
throw new InvalidMessageException("sender certificate not signed by the server");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Attachments;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Applies inbound <see cref="SyncMessage"/>s pushed by the primary device: downloads + imports the
|
||||
/// contacts blob (SyncMessage.Contacts → <see cref="ContactsStore"/>) so the conversation list can show
|
||||
/// names, and records read state (SyncMessage.Read). The contact-import mapping is factored into
|
||||
/// <see cref="ImportContacts"/> so it can be tested offline without the CDN.
|
||||
/// </summary>
|
||||
public sealed class SyncProcessor
|
||||
{
|
||||
private readonly ContactsStore _contacts;
|
||||
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)
|
||||
{
|
||||
_contacts = contacts;
|
||||
_downloader = downloader ?? new AttachmentDownloader();
|
||||
}
|
||||
|
||||
public async Task ProcessAsync(SyncMessage sync, CancellationToken ct = default)
|
||||
{
|
||||
if (sync.Contacts?.Blob is { } pointer)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] blob = await _downloader.DownloadAsync(pointer, ct).ConfigureAwait(false);
|
||||
int n = ImportContacts(blob);
|
||||
FileLog.Write($"sync: imported {n} contact(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"sync: contacts import failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SyncMessage.Types.Read read in sync.Read)
|
||||
{
|
||||
string? aci = AciOf(read.SenderAci, read.SenderAciBinary);
|
||||
if (aci is not null)
|
||||
ReadReceiptReceived?.Invoke(aci, (long)read.Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted contacts blob and upserts each contact. Returns the count. Pure
|
||||
/// (no network) — the offline-testable core of contacts sync.</summary>
|
||||
public int ImportContacts(byte[] blob)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
||||
{
|
||||
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
|
||||
if (aci is null) continue; // ACI-less contacts (e164-only) can't key a conversation yet
|
||||
|
||||
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));
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>Prefers the string ACI; falls back to the 16-byte binary form. Returns lowercase
|
||||
/// canonical UUID, or null if neither is present/valid.</summary>
|
||||
private static string? AciOf(string asString, ByteString binary)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(asString) && Guid.TryParse(asString, out Guid g))
|
||||
return g.ToString("D").ToLowerInvariant();
|
||||
return ServiceIds.StringFromBinary(binary.Span);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user