Add project files.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
// ── GET /v2/keys/{identifier}/{deviceId} response ──
|
||||
|
||||
public sealed class PreKeyResponse
|
||||
{
|
||||
public string IdentityKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||
public PreKeyResponseDevice[] Devices { get; set; } = Array.Empty<PreKeyResponseDevice>();
|
||||
}
|
||||
|
||||
public sealed class PreKeyResponseDevice
|
||||
{
|
||||
public uint DeviceId { get; set; }
|
||||
public uint RegistrationId { get; set; }
|
||||
public PreKeyDto? PreKey { get; set; } // optional one-time EC prekey
|
||||
public SignedPreKeyDto SignedPreKey { get; set; } = new();
|
||||
public SignedPreKeyDto? PqPreKey { get; set; } // ML-KEM (Kyber) signed prekey
|
||||
}
|
||||
|
||||
public sealed class PreKeyDto
|
||||
{
|
||||
public uint KeyId { get; set; }
|
||||
public string PublicKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||
}
|
||||
|
||||
public sealed class SignedPreKeyDto
|
||||
{
|
||||
public uint KeyId { get; set; }
|
||||
public string PublicKey { get; set; } = ""; // base64 (EC: 0x05‖32; Kyber: 0x08‖1568)
|
||||
public string Signature { get; set; } = ""; // base64
|
||||
}
|
||||
|
||||
// ── PUT /v1/messages/{destination} request ──
|
||||
|
||||
public sealed class OutgoingMessageList
|
||||
{
|
||||
public OutgoingMessage[] Messages { get; set; } = Array.Empty<OutgoingMessage>();
|
||||
public long Timestamp { get; set; }
|
||||
public bool Online { get; set; }
|
||||
public bool Urgent { get; set; }
|
||||
}
|
||||
|
||||
// ── PUT /v2/keys?identity={aci|pni} request (upload one-time prekeys) ──
|
||||
|
||||
public sealed class SetKeysRequest
|
||||
{
|
||||
public PreKeyEntity[]? PreKeys { get; set; } // one-time EC prekeys
|
||||
}
|
||||
|
||||
public sealed class PreKeyEntity
|
||||
{
|
||||
public uint KeyId { get; set; }
|
||||
public string PublicKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||
}
|
||||
|
||||
public sealed class OutgoingMessage
|
||||
{
|
||||
public int Type { get; set; } // envelope type: 3=PREKEY_MESSAGE, 1=DOUBLE_RATCHET
|
||||
public uint DestinationDeviceId { get; set; }
|
||||
public uint DestinationRegistrationId { get; set; }
|
||||
public string Content { get; set; } = ""; // base64 of the serialized ciphertext
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
// JSON DTOs for PUT /v1/devices/link. Field names match the Signal service contract.
|
||||
|
||||
public sealed record SignedPreKeyEntity(
|
||||
[property: JsonPropertyName("keyId")] uint KeyId,
|
||||
[property: JsonPropertyName("publicKey")] string PublicKey,
|
||||
[property: JsonPropertyName("signature")] string Signature);
|
||||
|
||||
public sealed record KyberPreKeyEntity(
|
||||
[property: JsonPropertyName("keyId")] uint KeyId,
|
||||
[property: JsonPropertyName("publicKey")] string PublicKey,
|
||||
[property: JsonPropertyName("signature")] string Signature);
|
||||
|
||||
public sealed record AccountAttributes(
|
||||
[property: JsonPropertyName("fetchesMessages")] bool FetchesMessages,
|
||||
[property: JsonPropertyName("registrationId")] uint RegistrationId,
|
||||
[property: JsonPropertyName("pniRegistrationId")] uint PniRegistrationId,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("capabilities")] AccountCapabilities Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Linked-device capability flags, serialized as a {name: bool} map. The server keeps the true
|
||||
/// entries with known names. New devices must declare <c>spqr</c> (required for new devices) and must
|
||||
/// not drop downgrade-protected capabilities the account already has (e.g. usernameChangeSyncMessage).
|
||||
/// </summary>
|
||||
public sealed record AccountCapabilities(
|
||||
[property: JsonPropertyName("storage")] bool Storage = true,
|
||||
[property: JsonPropertyName("spqr")] bool Spqr = true,
|
||||
[property: JsonPropertyName("usernameChangeSyncMessage")] bool UsernameChangeSyncMessage = true);
|
||||
|
||||
public sealed record LinkDeviceRequest(
|
||||
[property: JsonPropertyName("verificationCode")] string VerificationCode,
|
||||
[property: JsonPropertyName("accountAttributes")] AccountAttributes AccountAttributes,
|
||||
[property: JsonPropertyName("aciSignedPreKey")] SignedPreKeyEntity AciSignedPreKey,
|
||||
[property: JsonPropertyName("pniSignedPreKey")] SignedPreKeyEntity PniSignedPreKey,
|
||||
[property: JsonPropertyName("aciPqLastResortPreKey")] KyberPreKeyEntity AciPqLastResortPreKey,
|
||||
[property: JsonPropertyName("pniPqLastResortPreKey")] KyberPreKeyEntity PniPqLastResortPreKey);
|
||||
|
||||
public sealed record LinkDeviceResponse(
|
||||
[property: JsonPropertyName("uuid")] string Uuid,
|
||||
[property: JsonPropertyName("pni")] string Pni,
|
||||
[property: JsonPropertyName("deviceId")] int DeviceId);
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>Thin typed HTTP client for the Signal account/keys/messages REST API.</summary>
|
||||
public sealed class SignalRestClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public SignalRestClient(HttpClient? http = null)
|
||||
{
|
||||
_http = http ?? CreatePinnedClient();
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(SignalServiceConfig.UserAgent);
|
||||
_http.DefaultRequestHeaders.Add("X-Signal-Agent", SignalServiceConfig.UserAgent);
|
||||
}
|
||||
|
||||
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.ServiceUrl) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers this client as a new secondary device. Authenticates with Basic(number:password)
|
||||
/// using the password this device generated; returns the assigned aci/pni/deviceId.
|
||||
/// </summary>
|
||||
public async Task<LinkDeviceResponse> LinkDeviceAsync(
|
||||
string number, string password, LinkDeviceRequest request, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, "/v1/devices/link")
|
||||
{
|
||||
Content = JsonContent.Create(request),
|
||||
};
|
||||
string token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{number}:{password}"));
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", token);
|
||||
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException(
|
||||
$"device link failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<LinkDeviceResponse>(ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty link response");
|
||||
}
|
||||
|
||||
/// <summary>Fetches prekey bundles for a recipient. <paramref name="deviceId"/> may be "*" for all
|
||||
/// of the recipient's devices. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||
public async Task<PreKeyResponse> GetPreKeysAsync(string serviceId, string deviceId, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v2/keys/{serviceId}/{deviceId}");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"get prekeys failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
return await response.Content.ReadFromJsonAsync<PreKeyResponse>(Json, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty prekey response");
|
||||
}
|
||||
|
||||
/// <summary>Sends a list of per-device encrypted messages to a destination. Returns the raw body on
|
||||
/// a device-mismatch (409) / stale-devices (410) so the caller can react; throws on other failures.</summary>
|
||||
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendMessagesAsync(
|
||||
string serviceId, OutgoingMessageList messages, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v1/messages/{serviceId}")
|
||||
{
|
||||
Content = JsonContent.Create(messages, options: Json),
|
||||
};
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.Gone)
|
||||
return (false, response.StatusCode, body);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"send failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
return (true, response.StatusCode, body);
|
||||
}
|
||||
|
||||
/// <summary>Uploads prekeys for an identity (PUT /v2/keys?identity={aci|pni}). Used to register
|
||||
/// one-time prekeys after linking. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||
public async Task UploadPreKeysAsync(string identity, SetKeysRequest request, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v2/keys?identity={identity}")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: Json),
|
||||
};
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"upload prekeys failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-polls <c>GET /v1/devices/transfer_archive</c> for the link'n'sync message-history archive
|
||||
/// the primary uploads after linking. Returns the descriptor (cdn/key, or an error), or null on a
|
||||
/// 204 timeout (poll again). Auth: Basic {aci.deviceId:password}. SCAFFOLD — see docs/SYNC.md; the
|
||||
/// download+import side isn't built yet.
|
||||
/// </summary>
|
||||
public async Task<TransferArchiveDescriptor?> WaitForTransferArchiveAsync(string authToken, int timeoutSeconds, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v1/devices/transfer_archive?timeout={timeoutSeconds}");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return null; // long-poll elapsed with no archive yet
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"wait transfer archive failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||
}
|
||||
return await response.Content.ReadFromJsonAsync<TransferArchiveDescriptor>(Json, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Fetches a sealed-sender delivery certificate (GET /v1/certificate/delivery). Returns the
|
||||
/// raw SenderCertificate bytes, valid ~24h; callers should cache it.</summary>
|
||||
public async Task<byte[]> GetSenderCertificateAsync(string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, "/v1/certificate/delivery");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"get delivery certificate failed: {(int)response.StatusCode} {body}");
|
||||
}
|
||||
var dto = await response.Content.ReadFromJsonAsync<DeliveryCertificateDto>(Json, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty certificate response");
|
||||
return Convert.FromBase64String(dto.Certificate ?? throw new InvalidOperationException("no certificate"));
|
||||
}
|
||||
|
||||
/// <summary>Sends sealed-sender messages: NO account auth, just the recipient's unidentified-access
|
||||
/// key header. Metadata-minimized. Returns the same (ok/status/body) shape as the authenticated send
|
||||
/// so callers can fall back on rejection.</summary>
|
||||
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendSealedMessagesAsync(
|
||||
string serviceId, OutgoingMessageList messages, byte[] accessKey, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v1/messages/{serviceId}")
|
||||
{
|
||||
Content = JsonContent.Create(messages, options: Json),
|
||||
};
|
||||
msg.Headers.Add("Unidentified-Access-Key", Convert.ToBase64String(accessKey));
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return (response.IsSuccessStatusCode, response.StatusCode, body);
|
||||
}
|
||||
|
||||
private sealed class DeliveryCertificateDto { public string? Certificate { get; set; } }
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>Endpoints and identifiers for talking to the Signal production service.</summary>
|
||||
public static class SignalServiceConfig
|
||||
{
|
||||
/// <summary>HTTPS base for the chat/account REST API.</summary>
|
||||
public const string ServiceUrl = "https://chat.signal.org";
|
||||
|
||||
/// <summary>WSS base for the authenticated and provisioning WebSockets.</summary>
|
||||
public const string WebSocketUrl = "wss://chat.signal.org";
|
||||
|
||||
/// <summary>HTTPS base for the group storage service (GroupsV2). Separate host from chat, but chains to
|
||||
/// the same bundled Signal CA (<c>SignalTrust</c>), so the existing pin applies.</summary>
|
||||
public const string StorageUrl = "https://storage.signal.org";
|
||||
|
||||
/// <summary>Unauthenticated socket used during secondary-device linking.</summary>
|
||||
public const string ProvisioningWebSocketPath = "/v1/websocket/provisioning/";
|
||||
|
||||
/// <summary>Authenticated chat socket (used post-link to send/receive messages).</summary>
|
||||
public const string ChatWebSocketPath = "/v1/websocket/";
|
||||
|
||||
/// <summary>Sent as the User-Agent / X-Signal-Agent on requests.</summary>
|
||||
public const string UserAgent = "Wingnal";
|
||||
|
||||
/// <summary>CDN base URL for an AttachmentPointer's <c>cdnNumber</c>. 0/absent = legacy cdn0; 2/3 are
|
||||
/// the current attachment/backup CDNs. (cdn1 was retired.)</summary>
|
||||
public static string CdnUrl(uint cdnNumber) => cdnNumber switch
|
||||
{
|
||||
0 => "https://cdn.signal.org",
|
||||
2 => "https://cdn2.signal.org",
|
||||
3 => "https://cdn3.signal.org",
|
||||
_ => "https://cdn2.signal.org",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net.Security;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Certificate pinning for Signal's service. chat.signal.org is served from Signal's own private
|
||||
/// root CA (not a publicly trusted authority), so the OS trust store rejects it. This validates the
|
||||
/// server chain against the bundled Signal root only — and trusts nothing else.
|
||||
/// </summary>
|
||||
public static class SignalTrust
|
||||
{
|
||||
private static readonly X509Certificate2 SignalRootCa = LoadRootCa();
|
||||
|
||||
/// <summary>Validation callback for <see cref="System.Net.Http.SocketsHttpHandler"/> and
|
||||
/// <see cref="System.Net.WebSockets.ClientWebSocket"/>: accept only chains anchored at the
|
||||
/// pinned Signal root with a matching hostname.</summary>
|
||||
public static bool Validate(object sender, X509Certificate? certificate, X509Chain? _, SslPolicyErrors errors)
|
||||
{
|
||||
// The hostname is checked by the TLS stack before this callback; never accept a mismatch.
|
||||
if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||
return false;
|
||||
if (certificate is null)
|
||||
return false;
|
||||
|
||||
using var leaf = new X509Certificate2(certificate);
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(SignalRootCa);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
return chain.Build(leaf);
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadRootCa()
|
||||
{
|
||||
Assembly assembly = typeof(SignalTrust).Assembly;
|
||||
const string resource = "Wingnal.Service.Resources.signal-ca.pem";
|
||||
using Stream stream = assembly.GetManifestResourceStream(resource)
|
||||
?? throw new InvalidOperationException($"embedded resource {resource} not found");
|
||||
using var reader = new StreamReader(stream);
|
||||
return X509Certificate2.CreateFromPem(reader.ReadToEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net.WebSockets;
|
||||
using Google.Protobuf;
|
||||
using Wingnal.Service.Protos;
|
||||
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="ClientWebSocket"/> with Signal's request/response framing: every binary frame
|
||||
/// is a <see cref="WebSocketMessage"/>. Incoming REQUEST frames are surfaced to the caller; the
|
||||
/// caller answers them with <see cref="SendResponseAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class SignalWebSocket : IDisposable
|
||||
{
|
||||
private readonly ClientWebSocket _socket = new();
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
public async Task ConnectAsync(Uri uri, IReadOnlyDictionary<string, string>? headers, CancellationToken ct)
|
||||
{
|
||||
_socket.Options.AddSubProtocol("binary");
|
||||
_socket.Options.RemoteCertificateValidationCallback = SignalTrust.Validate;
|
||||
_socket.Options.SetRequestHeader("X-Signal-Agent", SignalServiceConfig.UserAgent);
|
||||
if (headers is not null)
|
||||
foreach ((string name, string value) in headers)
|
||||
_socket.Options.SetRequestHeader(name, value);
|
||||
|
||||
await _socket.ConnectAsync(uri, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Reads the next inbound REQUEST frame, or null if the socket closed.</summary>
|
||||
public async Task<WebSocketRequestMessage?> ReadRequestAsync(CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
WebSocketMessage? message = await ReadMessageAsync(ct).ConfigureAwait(false);
|
||||
if (message is null)
|
||||
return null;
|
||||
if (message.Type == WebSocketMessage.Types.Type.Request && message.Request is not null)
|
||||
return message.Request;
|
||||
// RESPONSE / keepalive frames are ignored for the provisioning flow.
|
||||
}
|
||||
}
|
||||
|
||||
public Task SendResponseAsync(ulong id, uint status, string message, CancellationToken ct)
|
||||
{
|
||||
var frame = new WebSocketMessage
|
||||
{
|
||||
Type = WebSocketMessage.Types.Type.Response,
|
||||
Response = new WebSocketResponseMessage { Id = id, Status = status, Message = message },
|
||||
};
|
||||
return SendMessageAsync(frame, ct);
|
||||
}
|
||||
|
||||
/// <summary>Sends a keepalive request (GET /v1/keepalive) to keep the server from dropping us.</summary>
|
||||
public Task SendKeepAliveAsync(ulong id, CancellationToken ct)
|
||||
{
|
||||
var frame = new WebSocketMessage
|
||||
{
|
||||
Type = WebSocketMessage.Types.Type.Request,
|
||||
Request = new WebSocketRequestMessage { Id = id, Verb = "GET", Path = "/v1/keepalive" },
|
||||
};
|
||||
return SendMessageAsync(frame, ct);
|
||||
}
|
||||
|
||||
/// <summary>Describes why the last read ended (close status/description, or the live socket state).</summary>
|
||||
public string CloseReason =>
|
||||
$"state={_socket.State} status={_socket.CloseStatus} desc={_socket.CloseStatusDescription}";
|
||||
|
||||
private async Task<WebSocketMessage?> ReadMessageAsync(CancellationToken ct)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[8192];
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await _socket.ReceiveAsync(chunk, ct).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
return null;
|
||||
buffer.Write(chunk, 0, result.Count);
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
return WebSocketMessage.Parser.ParseFrom(buffer.ToArray());
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync(WebSocketMessage message, CancellationToken ct)
|
||||
{
|
||||
byte[] bytes = message.ToByteArray();
|
||||
await _sendLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Binary, endOfMessage: true, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_socket.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>
|
||||
/// The descriptor the server returns from <c>GET /v1/devices/transfer_archive</c> once the primary has
|
||||
/// uploaded the link'n'sync message-history archive (Signal-Server <c>RemoteAttachment</c>). The archive
|
||||
/// itself is fetched from the CDN and decrypted with keys derived from the provisioning
|
||||
/// <c>ephemeralBackupKey</c>. See docs/SYNC.md.
|
||||
/// </summary>
|
||||
public sealed class TransferArchiveDescriptor
|
||||
{
|
||||
public int Cdn { get; set; }
|
||||
public string? Key { get; set; }
|
||||
|
||||
/// <summary>Set instead of cdn/key when the primary reported it couldn't produce an archive
|
||||
/// (Signal-Server <c>RemoteAttachmentError</c>: e.g. CONTINUE_WITHOUT_UPLOAD / RELINK_REQUESTED).</summary>
|
||||
public string? Error { get; set; }
|
||||
|
||||
public bool IsError => !string.IsNullOrEmpty(Error);
|
||||
}
|
||||
Reference in New Issue
Block a user