using System.Security.Cryptography;
using System.Text;
using System.Buffers.Binary;
namespace WebDrop.Core;
/// Protocol signature framing. Payload encodings are defined by their signed statement type.
public static class SignatureCodec
{
private static ReadOnlySpan Magic => [0x57, 0x44, 0x53, 0x01]; // WDS\x01
private const int MaxKindBytes = 64;
private const int MaxPayloadBytes = 1_048_576;
public static byte[] DeviceCertificatePayload(string username, string deviceId, ReadOnlySpan x, ReadOnlySpan y)
{
if (string.IsNullOrEmpty(username) || username.Length > 32 || username.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-'))
|| !Guid.TryParseExact(deviceId, "D", out _) || x.Length != 32 || y.Length != 32)
throw new ArgumentException("Invalid device certificate fields.");
var user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
var device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
var payload = new byte[1 + user.Length + 1 + device.Length + 64];
int offset = 0;
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
x.CopyTo(payload.AsSpan(offset)); y.CopyTo(payload.AsSpan(offset + 32));
return payload;
}
public static byte[] ContentEventPayload(ReadOnlySpan spaceId, string channelId, string username, string deviceId,
ReadOnlySpan eventId, string eventKind, CryptoDomain domain, int epoch, ReadOnlySpan itemId,
int frameCount, ReadOnlySpan manifestHash, ReadOnlySpan targetItemId)
{
if (spaceId.Length != 16 || eventId.Length != 16 || itemId.Length != 16 || manifestHash.Length != 32
|| targetItemId.Length is not (0 or 16) || epoch < 0 || frameCount < 1
|| !Guid.TryParseExact(channelId, "D", out _) || !Enum.IsDefined(domain)
|| !IsAsciiIdentity(username, 32) || !Guid.TryParseExact(deviceId, "D", out _)
|| eventKind.Length is < 1 or > 32 || eventKind.Any(c => c is not (>= 'a' and <= 'z') and not '.' and not '-'))
throw new ArgumentException("Invalid signed content event fields.");
byte[] channel = Encoding.ASCII.GetBytes(channelId.ToLowerInvariant());
byte[] user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
byte[] device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
byte[] kind = Encoding.ASCII.GetBytes(eventKind);
var payload = new byte[16 + 1 + channel.Length + 1 + user.Length + 1 + device.Length + 16 + 1 + kind.Length + 1 + 4 + 16 + 4 + 32 + 1 + 16];
int offset = 0;
spaceId.CopyTo(payload.AsSpan(offset)); offset += 16;
payload[offset++] = (byte)channel.Length; channel.CopyTo(payload, offset); offset += channel.Length;
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
eventId.CopyTo(payload.AsSpan(offset)); offset += 16;
payload[offset++] = (byte)kind.Length; kind.CopyTo(payload, offset); offset += kind.Length;
payload[offset++] = (byte)domain;
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)epoch); offset += 4;
itemId.CopyTo(payload.AsSpan(offset)); offset += 16;
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)frameCount); offset += 4;
manifestHash.CopyTo(payload.AsSpan(offset)); offset += 32;
payload[offset++] = targetItemId.IsEmpty ? (byte)0 : (byte)1;
if (!targetItemId.IsEmpty) targetItemId.CopyTo(payload.AsSpan(offset));
return payload;
}
public static byte[] FrameManifestHash(IReadOnlyList frameDigests)
{
if (frameDigests.Count is < 1 or > 65_536 || frameDigests.Any(d => d.Length != 32)) throw new ArgumentException("Invalid frame manifest.");
var data = new byte[8 + frameDigests.Count * 36];
"WD1M"u8.CopyTo(data);
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(4), (uint)frameDigests.Count);
int offset = 8;
for (int i = 0; i < frameDigests.Count; i++)
{
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(offset), (uint)i); offset += 4;
frameDigests[i].CopyTo(data, offset); offset += 32;
}
return SHA256.HashData(data);
}
private static bool IsAsciiIdentity(string value, int maxLength) => value.Length is >= 3 && value.Length <= maxLength
&& value.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-');
public static byte[] BuildMessage(string kind, ReadOnlySpan payload)
{
if (string.IsNullOrEmpty(kind) || kind.Length > MaxKindBytes
|| kind[0] is < 'a' or > 'z'
|| kind.Any(c => c is not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '.' and not '_' and not '-'))
throw new ArgumentException("Invalid signature kind.", nameof(kind));
if (payload.Length > MaxPayloadBytes) throw new ArgumentOutOfRangeException(nameof(payload));
var kindBytes = Encoding.ASCII.GetBytes(kind);
var message = new byte[Magic.Length + 2 + kindBytes.Length + 4 + payload.Length];
int offset = 0;
Magic.CopyTo(message.AsSpan(offset)); offset += Magic.Length;
message[offset++] = (byte)(kindBytes.Length >> 8);
message[offset++] = (byte)kindBytes.Length;
kindBytes.CopyTo(message, offset); offset += kindBytes.Length;
uint length = (uint)payload.Length;
message[offset++] = (byte)(length >> 24);
message[offset++] = (byte)(length >> 16);
message[offset++] = (byte)(length >> 8);
message[offset++] = (byte)length;
payload.CopyTo(message.AsSpan(offset));
return message;
}
/// Verifies WebCrypto ECDSA P-256/SHA-256 signatures in fixed-width IEEE P1363 form.
public static bool VerifyP256(ReadOnlySpan x, ReadOnlySpan y, string kind,
ReadOnlySpan payload, ReadOnlySpan signature)
{
if (x.Length != 32 || y.Length != 32 || signature.Length != 64) return false;
try
{
using var key = ECDsa.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = x.ToArray(), Y = y.ToArray() },
});
return key.VerifyData(BuildMessage(kind, payload), signature, HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (ArgumentException) { return false; }
catch (CryptographicException) { return false; }
}
}