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:
1 parent
87529bca49
commit
9b792042e6
43 files changed
+2079
-49
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user