116 lines
3.9 KiB
C#
116 lines
3.9 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|