465 lines
18 KiB
C#
465 lines
18 KiB
C#
#: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": "<the instance's vapid.public_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<string, Subscription>();
|
||
|
|
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<string>());
|
||
|
|
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<int>() ?? 0;
|
||
|
|
if (status is < 200 or > 599)
|
||
|
|
return Results.BadRequest(new { error = "status: 200 to 599" });
|
||
|
|
subscription.Answer(status, request?["times"]?.GetValue<int>() ?? 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<string> 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<string>();
|
||
|
|
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<JsonObject> _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<string> 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=<jwt>, k=<key>; the draft's: Authorization: WebPush <jwt> 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<string>() != "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<string>() != 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<long>() ?? 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>() ?? 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<byte>();
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|