#:sdk Microsoft.NET.Sdk.Web #:property PublishAot=false #:property InvariantGlobalization=true #:property Nullable=disable // pushsink: a Web Push service for the pasture (tools/pasture/scenarios/push.sh), behind Caddy as pushsink.test, which // takes PrivaPub's pushes as a browser's push service would and keeps what it took for the scenario to read. It makes the // keys of each subscription itself, so it can read what PrivaPub encrypted to them, and it checks what a push service // checks: the VAPID JWT (ES256 by the server's key, the push service's origin as aud, exp in the future and at most a day // ahead, a mailto: or https: sub) and the encryption (RFC 8291 aes128gcm, or the aesgcm draft with Encryption and // Crypto-Key). Its own code, not PrivaPub's, so a round trip proves the two agree. // POST /subscriptions {"server_key": ""} (optional; without it the // key is read from $PUSHSINK_INSTANCE/api/v2/instance): a new subscription, its // keys made here: {"id", "endpoint", "keys": {"p256dh", "auth"}} // GET /subscriptions/{id} the subscription, with what it answers next // POST /subscriptions/{id}/answer {"status": 410, "times": 1}: the next push(es) are answered so, after being read // GET /subscriptions/{id}/received every push it took or refused, oldest first: when, the status it answered, // whether every check passed (ok) and the errors, the headers, the JWT's claims, // the decrypted payload (parsed JSON) // DELETE /subscriptions/{id}/received forgets them // POST /push/{id} the push service: 201 when every check passes, 403 for the VAPID's failures, 400 // for the rest, 404 for no such subscription, or the status asked for // GET /health // Everything is in memory: a new container starts empty. using System.Buffers.Binary; using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; var publicBase = (Environment.GetEnvironmentVariable("PUSHSINK_BASE") ?? "https://pushsink.test").TrimEnd('/'); var instance = (Environment.GetEnvironmentVariable("PUSHSINK_INSTANCE") ?? "https://privapub.test").TrimEnd('/'); var listen = Environment.GetEnvironmentVariable("PUSHSINK_LISTEN") ?? "http://0.0.0.0:8080"; var subscriptions = new ConcurrentDictionary(); var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; var builder = WebApplication.CreateBuilder(); builder.Logging.SetMinimumLevel(LogLevel.Warning); builder.WebHost.UseUrls(listen); builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 64 * 1024); var app = builder.Build(); app.MapGet("/health", () => Results.Json(new { ok = true, subscriptions = subscriptions.Count })); app.MapPost("/subscriptions", async (HttpContext context) => { var request = context.Request.ContentLength > 0 ? await JsonNode.ParseAsync(context.Request.Body) : null; var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); var subscription = new Subscription(Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)), key, RandomNumberGenerator.GetBytes(16), request?["server_key"]?.GetValue()); subscriptions[subscription.Id] = subscription; return Results.Json(subscription.View(publicBase)); }); app.MapGet("/subscriptions/{id}", (string id) => subscriptions.TryGetValue(id, out var subscription) ? Results.Json(subscription.View(publicBase)) : Results.NotFound()); app.MapPost("/subscriptions/{id}/answer", async (HttpContext context, string id) => { if (!subscriptions.TryGetValue(id, out var subscription)) return Results.NotFound(); var request = await JsonNode.ParseAsync(context.Request.Body); var status = request?["status"]?.GetValue() ?? 0; if (status is < 200 or > 599) return Results.BadRequest(new { error = "status: 200 to 599" }); subscription.Answer(status, request?["times"]?.GetValue() ?? 1); return Results.Json(subscription.View(publicBase)); }); app.MapGet("/subscriptions/{id}/received", (string id) => subscriptions.TryGetValue(id, out var subscription) ? Results.Text(subscription.Received().ToJsonString(), "application/json") : Results.NotFound()); app.MapDelete("/subscriptions/{id}/received", (string id) => { if (!subscriptions.TryGetValue(id, out var subscription)) return Results.NotFound(); subscription.Forget(); return Results.Json(new { }); }); app.MapPost("/push/{id}", async (HttpContext context, string id) => { if (!subscriptions.TryGetValue(id, out var subscription)) return Results.NotFound(); using var buffer = new MemoryStream(); await context.Request.Body.CopyToAsync(buffer); var check = new Check(context.Request, buffer.ToArray(), subscription, publicBase); var serverKey = subscription.ServerKey ?? await InstanceKey(); check.Run(serverKey); var forced = subscription.NextAnswer(); var status = forced ?? (check.Errors.Count == 0 ? 201 : check.VapidFailed ? 403 : 400); subscription.Keep(check.Record(status)); if (status == 201) context.Response.Headers.Location = $"{publicBase}/messages/{Guid.NewGuid():N}"; return Results.StatusCode(status); }); app.Run(); return 0; // the server's key as its instance API gives it (configuration.vapid.public_key), read once a minute at most async Task InstanceKey() { if (InstanceCache.Key is { } known && DateTime.UtcNow - InstanceCache.At < TimeSpan.FromMinutes(1)) return known; try { var document = JsonNode.Parse(await http.GetStringAsync(instance + "/api/v2/instance")); InstanceCache.Key = document?["configuration"]?["vapid"]?["public_key"]?.GetValue(); InstanceCache.At = DateTime.UtcNow; } catch (Exception) { InstanceCache.Key = null; } return InstanceCache.Key; } static class InstanceCache { public static string Key; public static DateTime At; } sealed class Subscription(string id, ECDiffieHellman key, byte[] auth, string serverKey) { readonly Lock _gate = new(); readonly List _received = new(); int _answer; int _answerTimes; public string Id => id; public ECDiffieHellman Key => key; public byte[] Auth => auth; public string ServerKey => serverKey; public byte[] PublicPoint { get { var parameters = key.ExportParameters(false); return [0x04, .. parameters.Q.X, .. parameters.Q.Y]; } } public JsonObject View(string publicBase) { lock (_gate) return new JsonObject { ["id"] = id, ["endpoint"] = $"{publicBase}/push/{id}", ["keys"] = new JsonObject { ["p256dh"] = B64.Url(PublicPoint), ["auth"] = B64.Url(auth) }, ["server_key"] = serverKey, ["next_answer"] = _answerTimes > 0 ? _answer : null, ["next_answer_times"] = _answerTimes, ["received"] = _received.Count }; } public void Answer(int status, int times) { lock (_gate) { _answer = status; _answerTimes = Math.Max(1, times); } } public int? NextAnswer() { lock (_gate) { if (_answerTimes <= 0) return null; _answerTimes--; return _answer; } } public void Keep(JsonObject record) { lock (_gate) _received.Add(record); } public JsonArray Received() { lock (_gate) return new JsonArray(_received.Select(r => (JsonNode)r.DeepClone()).ToArray()); } public void Forget() { lock (_gate) _received.Clear(); } } // what a push service checks of one push, and what the sink keeps of it sealed class Check(HttpRequest request, byte[] body, Subscription subscription, string publicBase) { public List Errors { get; } = new(); public bool VapidFailed { get; private set; } JsonObject _claims; string _scheme; string _k; JsonNode _payload; string _encoding; public void Run(string expectedServerKey) { _encoding = request.Headers.ContentEncoding.ToString().Trim().ToLowerInvariant(); if (_encoding is not ("aes128gcm" or "aesgcm")) Errors.Add($"Content-Encoding is '{_encoding}', neither aes128gcm nor aesgcm"); if (!int.TryParse(request.Headers["TTL"].ToString(), out var ttl) || ttl < 0) Errors.Add("no TTL header (RFC 8030 5.2)"); Vapid(expectedServerKey); try { var plaintext = _encoding switch { "aes128gcm" => Decrypt.Aes128Gcm(body, subscription.Key, subscription.PublicPoint, subscription.Auth), "aesgcm" => Decrypt.AesGcm(body, Param(request.Headers["Encryption"].ToString(), "salt"), Param(request.Headers["Crypto-Key"].ToString(), "dh"), subscription.Key, subscription.PublicPoint, subscription.Auth), _ => null }; if (plaintext != null) _payload = JsonNode.Parse(plaintext); } catch (Exception ex) when (ex is CryptographicException or FormatException or JsonException or ArgumentException or IndexOutOfRangeException) { Errors.Add("the body could not be decrypted: " + ex.Message); } } // RFC 8292 3: Authorization: vapid t=, k=; the draft's: Authorization: WebPush and Crypto-Key's p256ecdsa void Vapid(string expectedServerKey) { var authorization = request.Headers.Authorization.ToString().Trim(); var space = authorization.IndexOf(' '); _scheme = space > 0 ? authorization[..space] : authorization; var rest = space > 0 ? authorization[(space + 1)..].Trim() : string.Empty; string jwt; if (_scheme.Equals("vapid", StringComparison.OrdinalIgnoreCase)) { jwt = Param(rest, "t"); _k = Param(rest, "k"); } else if (_scheme.Equals("WebPush", StringComparison.OrdinalIgnoreCase)) { jwt = rest; _k = Param(request.Headers["Crypto-Key"].ToString(), "p256ecdsa"); } else { Fail($"Authorization's scheme is '{_scheme}', neither vapid nor WebPush"); return; } if (_encoding == "aes128gcm" && !_scheme.Equals("vapid", StringComparison.OrdinalIgnoreCase)) Fail("an aes128gcm push carries RFC 8292's vapid authorization"); var key = B64.Decode(_k); if (key is not { Length: 65 } || key[0] != 0x04) { Fail("the VAPID key (k or p256ecdsa) is no uncompressed P-256 point"); return; } if (string.IsNullOrEmpty(expectedServerKey)) Fail("the server's key is unknown (no server_key given, and the instance API did not say)"); else if (!B64.Decode(expectedServerKey).AsSpan().SequenceEqual(key)) Fail("the VAPID key is not the server's (the instance API's vapid.public_key)"); var parts = jwt?.Split('.') ?? []; if (parts.Length != 3) { Fail("the VAPID token is no JWT"); return; } JsonNode header; try { header = JsonNode.Parse(B64.Decode(parts[0])); _claims = JsonNode.Parse(B64.Decode(parts[1])) as JsonObject; } catch (Exception) { Fail("the JWT's header or claims are not JSON"); return; } if (header?["alg"]?.GetValue() != "ES256") Fail($"the JWT's alg is '{header?["alg"]}', not ES256"); using (var verifier = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = key[1..33], Y = key[33..] } })) { var signature = B64.Decode(parts[2]); if (signature is not { Length: 64 } || !verifier.VerifyData(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) Fail("the JWT's ES256 signature does not verify with the VAPID key"); } var audience = new Uri($"{publicBase}/push/{subscription.Id}").GetLeftPart(UriPartial.Authority); if (_claims?["aud"]?.GetValue() != audience) Fail($"the JWT's aud is '{_claims?["aud"]}', not the push service's origin {audience}"); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var exp = _claims?["exp"]?.GetValue() ?? 0; if (exp <= now) Fail("the JWT has expired (exp)"); else if (exp > now + 24 * 3600 + 60) Fail("the JWT's exp is more than 24 hours ahead (RFC 8292 2)"); var sub = _claims?["sub"]?.GetValue() ?? string.Empty; if (!sub.StartsWith("mailto:", StringComparison.Ordinal) && !sub.StartsWith("https:", StringComparison.Ordinal)) Fail($"the JWT's sub is '{sub}', neither mailto: nor https:"); } void Fail(string error) { VapidFailed = true; Errors.Add(error); } // a parameter of a header written a=b, c=d or a=b;c=d (Crypto-Key may hold several) static string Param(string header, string name) { foreach (var part in (header ?? string.Empty).Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var equals = part.IndexOf('='); if (equals > 0 && part[..equals].Trim().Equals(name, StringComparison.OrdinalIgnoreCase)) return part[(equals + 1)..].Trim().Trim('"'); } return null; } public JsonObject Record(int status) => new() { ["at"] = DateTime.UtcNow.ToString("O"), ["status"] = status, ["ok"] = Errors.Count == 0, ["errors"] = new JsonArray(Errors.Select(e => (JsonNode)e).ToArray()), ["encoding"] = _encoding, ["headers"] = new JsonObject { ["ttl"] = request.Headers["TTL"].ToString(), ["urgency"] = request.Headers["Urgency"].ToString(), ["content_type"] = request.ContentType, ["content_encoding"] = request.Headers.ContentEncoding.ToString(), ["authorization_scheme"] = _scheme, ["encryption"] = request.Headers["Encryption"].ToString(), ["crypto_key"] = request.Headers["Crypto-Key"].ToString(), ["user_agent"] = request.Headers.UserAgent.ToString() }, ["vapid"] = new JsonObject { ["k"] = _k, ["aud"] = _claims?["aud"]?.DeepClone(), ["exp"] = _claims?["exp"]?.DeepClone(), ["sub"] = _claims?["sub"]?.DeepClone() }, ["bytes"] = body.Length, ["payload"] = _payload?.DeepClone() }; } // the client's side of RFC 8291 and of the aesgcm draft static class Decrypt { public static byte[] Aes128Gcm(byte[] body, ECDiffieHellman client, byte[] clientPublic, byte[] auth) { // RFC 8188 2.1: salt (16), record size (4), key id length (1), key id: the sender's public key var salt = body[..16]; var recordSize = (int)BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4)); var idLength = body[20]; var serverPublic = body[21..(21 + idLength)]; if (recordSize < 18) throw new CryptographicException($"record size {recordSize} is below 18"); var shared = Agree(client, serverPublic); var keyInfo = Concat(Encoding.ASCII.GetBytes("WebPush: info\0"), clientPublic, serverPublic); var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, auth), 32, keyInfo); var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt); var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Encoding.ASCII.GetBytes("Content-Encoding: aes128gcm\0")); var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Encoding.ASCII.GetBytes("Content-Encoding: nonce\0")); var content = body[(21 + idLength)..]; var plaintext = new List(); var records = (content.Length + recordSize - 1) / recordSize; for (var i = 0; i < records; i++) { var record = content[(i * recordSize)..Math.Min(content.Length, (i + 1) * recordSize)]; var recordNonce = (byte[])nonce.Clone(); for (var b = 0; b < 8; b++)//the record's sequence number, XORed into the nonce's last bytes recordNonce[11 - b] ^= (byte)((long)i >> (8 * b)); var opened = Open(cek, recordNonce, record); var end = opened.Length - 1; while (end >= 0 && opened[end] == 0) end--; var last = i == records - 1; if (end < 0 || opened[end] != (last ? 0x02 : 0x01)) throw new CryptographicException("a record's delimiter is wrong"); plaintext.AddRange(opened[..end]); } return plaintext.ToArray(); } public static byte[] AesGcm(byte[] body, string salt, string serverKey, ECDiffieHellman client, byte[] clientPublic, byte[] auth) { var saltBytes = B64.Decode(salt) ?? throw new FormatException("no salt in the Encryption header"); var serverPublic = B64.Decode(serverKey) ?? throw new FormatException("no dh in the Crypto-Key header"); var shared = Agree(client, serverPublic); var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, auth), 32, Encoding.ASCII.GetBytes("Content-Encoding: auth\0")); var context = Concat(Encoding.ASCII.GetBytes("P-256\0"), Length16(clientPublic), clientPublic, Length16(serverPublic), serverPublic); var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, saltBytes); var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Concat(Encoding.ASCII.GetBytes("Content-Encoding: aesgcm\0"), context)); var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Concat(Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"), context)); var opened = Open(cek, nonce, body); var padding = BinaryPrimitives.ReadUInt16BigEndian(opened); return opened[(2 + padding)..]; } static byte[] Agree(ECDiffieHellman client, byte[] serverPublic) { if (serverPublic is not { Length: 65 } || serverPublic[0] != 0x04) throw new CryptographicException("the sender's key is no uncompressed P-256 point"); using var server = ECDiffieHellman.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = serverPublic[1..33], Y = serverPublic[33..] } }); return client.DeriveRawSecretAgreement(server.PublicKey); } static byte[] Open(byte[] key, byte[] nonce, byte[] sealedRecord) { if (sealedRecord.Length < 16) throw new CryptographicException("a record shorter than its tag"); var plaintext = new byte[sealedRecord.Length - 16]; using var aes = new AesGcm(key, 16); aes.Decrypt(nonce, sealedRecord.AsSpan(0, plaintext.Length), sealedRecord.AsSpan(plaintext.Length), plaintext); return plaintext; } static byte[] Length16(byte[] value) { var length = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(length, (ushort)value.Length); return length; } static byte[] Concat(params byte[][] parts) => parts.SelectMany(p => p).ToArray(); } static class B64 { public static string Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); public static byte[] Decode(string text) { if (string.IsNullOrWhiteSpace(text)) return null; var normal = text.Trim().TrimEnd('=').Replace('-', '+').Replace('_', '/'); normal = normal.PadRight(normal.Length + (4 - normal.Length % 4) % 4, '='); try { return Convert.FromBase64String(normal); } catch (FormatException) { return null; } } }