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 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; } /// 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. public string ForApi => Convert.ToBase64String(PublicPoint).Replace('+', '-').Replace('/', '_'); /// As headers take it (RFC 8292's k, the draft's Crypto-Key p256ecdsa): base64url without padding. public string ForHeader => WebPushCrypto.Base64Url(PublicPoint); /// The ES256 JWT for one push service (aud: its origin), signed by this key. 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 { ["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); } /// A push service's origin, the JWT's aud: scheme, host and a port that is not the scheme's own. 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 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 Stored(CancellationToken token) => DB.Default.Find().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 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); } } } }