Add project files.

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