R9 Web Push, then the honest version: VAPID key, push subscriptions, encrypted pushes, 4.5.0 advertised

The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's
vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona;
every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy
admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the
aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the
push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold
neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added
conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both
instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and
scenarios/push.sh in batch A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 09:18:26 +02:00
1 parent 87529bca49
commit 9b792042e6
43 files changed
+2079 -49

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace PrivaPub.Infrastructure.Push
{
// The server's VAPID key (RFC 8292, completion program R9) and the JWT every push carries. The key is made the first time
// it is needed and kept in Mongo (PushKeys, one document); every process reads that one.
public interface IVapid
{
Task<VapidKey> Key(CancellationToken token);
}
public sealed class VapidKey
{
// RFC 8292 2: no more than 24 hours ahead; half of it leaves room for a push service's clock
public static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(12);
readonly ECParameters _parameters;
public VapidKey(byte[] publicPoint, byte[] d)
{
PublicPoint = publicPoint;
_parameters = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
D = d,
Q = new ECPoint { X = publicPoint[1..33], Y = publicPoint[33..] }
};
}
public byte[] PublicPoint { get; }
/// <summary>As the APIs give it (the instance's vapid.public_key, an app's vapid_key, a subscription's server_key):
/// URL-safe base64 with its padding, as Mastodon's VAPID_PUBLIC_KEY is written.</summary>
public string ForApi => Convert.ToBase64String(PublicPoint).Replace('+', '-').Replace('/', '_');
/// <summary>As headers take it (RFC 8292's k, the draft's Crypto-Key p256ecdsa): base64url without padding.</summary>
public string ForHeader => WebPushCrypto.Base64Url(PublicPoint);
/// <summary>The ES256 JWT for one push service (aud: its origin), signed by this key.</summary>
public string Token(string audience, string subject, DateTime now)
{
var header = WebPushCrypto.Base64Url("{\"typ\":\"JWT\",\"alg\":\"ES256\"}"u8);
var claims = WebPushCrypto.Base64Url(JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object>
{
["aud"] = audience,
["exp"] = new DateTimeOffset(now + TokenLifetime).ToUnixTimeSeconds(),
["sub"] = subject
}));
var signed = header + "." + claims;
using var key = ECDsa.Create(_parameters);
var signature = key.SignData(Encoding.ASCII.GetBytes(signed), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return signed + "." + WebPushCrypto.Base64Url(signature);
}
/// <summary>A push service's origin, the JWT's aud: scheme, host and a port that is not the scheme's own.</summary>
public static string Audience(Uri endpoint) => endpoint.GetLeftPart(UriPartial.Authority).ToLower(CultureInfo.InvariantCulture);
}
public class Vapid : IVapid
{
readonly SemaphoreSlim _gate = new(1, 1);
volatile VapidKey _key;
public async Task<VapidKey> Key(CancellationToken token)
{
if (_key is { } known)
return known;
await _gate.WaitAsync(token);
try
{
if (_key is { } meanwhile)
return meanwhile;
var stored = await Stored(token) ?? await Make(token);
_key = new VapidKey(WebPushCrypto.FromBase64Url(stored.PublicKey), WebPushCrypto.FromBase64Url(stored.PrivateKey));
return _key;
}
finally
{
_gate.Release();
}
}
static Task<PushKeys> Stored(CancellationToken token) =>
DB.Default.Find<PushKeys>().Match(k => k.ID == PushKeys.Name).ExecuteFirstAsync(token);
// the first need makes it; two processes at once keep the first one saved
static async Task<PushKeys> Make(CancellationToken token)
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = key.ExportParameters(true);
var keys = new PushKeys
{
ID = PushKeys.Name,
PublicKey = WebPushCrypto.Base64Url([0x04, .. parameters.Q.X, .. parameters.Q.Y]),
PrivateKey = WebPushCrypto.Base64Url(parameters.D)
};
try
{
await DB.Default.InsertAsync(keys, token);
return keys;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await Stored(token);
}
}
}
}