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; } } }