Files
WebDrop/server/tests/WebDrop.Core.Tests/VectorTests.cs
T

311 lines
14 KiB
C#

using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using WebDrop.Core;
namespace WebDrop.Core.Tests;
/// <summary>
/// 用 test-vectors/vectors.json 校验服务端这一侧能复现协议的每个字节。
/// 向量由 node:crypto 独立生成,这里用 .NET 的 HKDF / AesGcm / SHA256 再算一遍;
/// 两套互不相干的实现一致,才说明 protocol.md 没有歧义。
/// </summary>
public class VectorTests
{
private static readonly JsonElement V = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "vectors.json"))).RootElement;
private static byte[] H(JsonElement e) => Convert.FromHexString(e.GetString()!);
private static byte[] H(string s) => Convert.FromHexString(s);
private static byte[] Hkdf(byte[] ikm, byte[] salt, string info) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, Encoding.UTF8.GetBytes(info));
private static byte[] ChannelId => H(V.GetProperty("inputs").GetProperty("channel_id"));
private static byte[] KEnc => Hkdf(H(V.GetProperty("inputs").GetProperty("k")), ChannelId, "webdrop/v1/enc");
private static byte[] ItemKey(byte[] itemId) => Hkdf(KEnc, itemId, "webdrop/v1/item");
private static byte[] Aad(byte[] channelId, byte[] envelope)
{
// "WD1" ‖ channel_id ‖ envelope[0..22)
var aad = new byte[3 + 16 + 22];
"WD1"u8.CopyTo(aad);
channelId.CopyTo(aad, 3);
envelope.AsSpan(0, 22).CopyTo(aad.AsSpan(19));
return aad;
}
/// <summary>用 AES-GCM 解密一帧,返回明文;认证失败抛 CryptographicException。</summary>
private static byte[] Open(byte[] channelId, byte[] envelope)
{
Assert.True(Envelope.TryParse(envelope, out var env));
var iv = new byte[12];
BinaryPrimitives.WriteUInt32BigEndian(iv.AsSpan(8), env.FrameIndex);
var payload = envelope.AsSpan(Envelope.HeaderSize);
var plain = new byte[payload.Length - 16];
using var gcm = new AesGcm(ItemKey(env.ItemId.ToArray()), 16);
gcm.Decrypt(iv, payload[..^16], payload[^16..], plain, Aad(channelId, envelope));
return plain;
}
[Fact]
public void KeyDerivationMatchesTheVectors()
{
var inputs = V.GetProperty("inputs");
var kdf = V.GetProperty("kdf");
var k = H(inputs.GetProperty("k"));
var a = H(inputs.GetProperty("a"));
Assert.Equal(H(kdf.GetProperty("k_enc")), Hkdf(k, ChannelId, "webdrop/v1/enc"));
Assert.Equal(H(kdf.GetProperty("t_read")), Hkdf(k, ChannelId, "webdrop/v1/token/read"));
Assert.Equal(H(kdf.GetProperty("t_write")), Hkdf(k, ChannelId, "webdrop/v1/token/write"));
Assert.Equal(H(kdf.GetProperty("t_owner")), Hkdf(a, ChannelId, "webdrop/v1/token/owner"));
Assert.Equal(H(kdf.GetProperty("k_label")), Hkdf(KEnc, ChannelId, "webdrop/v1/label"));
foreach (var item in V.GetProperty("item_keys").EnumerateArray())
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
}
[Fact]
public void SignatureFramingAndEcdsaMatchTheVectors()
{
var vector = V.GetProperty("signatures");
static byte[] B64Url(string value) => Convert.FromBase64String(value.Replace('-', '+').Replace('_', '/') + new string('=', (4 - value.Length % 4) % 4));
var x = B64Url(vector.GetProperty("x").GetString()!);
var y = B64Url(vector.GetProperty("y").GetString()!);
var payload = H(vector.GetProperty("payload"));
var kind = vector.GetProperty("kind").GetString()!;
var signature = H(vector.GetProperty("signature"));
Assert.Equal(H(vector.GetProperty("message")), SignatureCodec.BuildMessage(kind, payload));
Assert.True(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
Assert.False(SignatureCodec.VerifyP256(x, y, "chat.edit", payload, signature));
payload[0] ^= 1;
Assert.False(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
}
[Fact]
public void ContentEventPayloadAndFrameManifestMatchTheVectors()
{
var vector = V.GetProperty("content_event");
var digests = vector.GetProperty("frame_digests").EnumerateArray().Select(H).ToList();
var manifest = SignatureCodec.FrameManifestHash(digests);
Assert.Equal(H(vector.GetProperty("manifest_hash")), manifest);
byte[] id(string property) => H(vector.GetProperty(property));
Assert.Equal(H(vector.GetProperty("payload")), SignatureCodec.ContentEventPayload(
id("space_id"), vector.GetProperty("channel_id").GetString()!, vector.GetProperty("username").GetString()!,
vector.GetProperty("device_id").GetString()!, id("event_id"), vector.GetProperty("kind").GetString()!, CryptoDomain.Chat,
vector.GetProperty("epoch").GetInt32(), id("item_id"), vector.GetProperty("frame_count").GetInt32(), manifest, id("target_item_id")));
}
[Fact]
public void ServerTokenHashMatchesTheStoredHashes()
{
// 服务端存的是令牌的 SHA-256;向量里的 *_hash 必须与 TokenHash.Compute 一致,
// 客户端才能发出服务端认得的哈希
var kdf = V.GetProperty("kdf");
foreach (var (token, hash) in new[] { ("t_read", "read_hash"), ("t_write", "write_hash"), ("t_owner", "owner_hash"), ("raw_write_token", "raw_write_hash") })
{
byte[] t = H(kdf.GetProperty(token));
Assert.Equal(H(kdf.GetProperty(hash)), TokenHash.Compute(t));
Assert.True(TokenHash.Matches(t, H(kdf.GetProperty(hash))));
}
}
[Fact]
public void IvAndAadMatchTheVectors()
{
foreach (var iv in V.GetProperty("iv").EnumerateArray())
{
var expected = new byte[12];
BinaryPrimitives.WriteUInt32BigEndian(expected.AsSpan(8), (uint)iv.GetProperty("index").GetInt64());
Assert.Equal(H(iv.GetProperty("iv")), expected);
}
foreach (var c in V.GetProperty("aad").EnumerateArray())
{
// 用一个只含头部的封装(payload_len = 0)构造 AAD 的前 22 字节
var env = new byte[Envelope.HeaderSize];
env[0] = 1;
env[1] = (byte)c.GetProperty("flags").GetInt32();
H(c.GetProperty("item_id")).CopyTo(env, 2);
BinaryPrimitives.WriteUInt32BigEndian(env.AsSpan(18), (uint)c.GetProperty("index").GetInt64());
Assert.Equal(H(c.GetProperty("aad")), Aad(ChannelId, env));
}
}
[Fact]
public void PaddingTiersMatchThePolicy()
{
foreach (var p in V.GetProperty("padding").EnumerateArray())
{
int len = p.GetProperty("data_len").GetInt32();
int tier = p.GetProperty("plaintext_len").GetInt32();
Assert.True(PayloadTiers.IsValidEncPayloadLength(tier + 16), $"data_len={len}");
Assert.True(tier >= 4 + len);
}
}
[Fact]
public void EveryFrameVectorDecryptsToTheExpectedPlaintextAndPassesServerValidation()
{
foreach (var f in V.GetProperty("frames").EnumerateArray())
{
string name = f.GetProperty("name").GetString()!;
byte[] envelope = H(f.GetProperty("envelope"));
// 服务端的封装解析与校验:这些帧就是合法客户端会发出的
Assert.True(Envelope.TryParse(envelope, out var env), name);
Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags);
Assert.Equal((uint)f.GetProperty("index").GetInt64(), env.FrameIndex);
Assert.True(env.IsEnc, name);
Assert.True(PayloadTiers.IsValidEncPayloadLength(env.PayloadLength), name);
// 解密得到的明文(含 padding)必须逐字节一致
byte[] plaintext = Open(ChannelId, envelope);
Assert.Equal(H(f.GetProperty("plaintext")), plaintext);
// 明文布局:u32 data_len ‖ data ‖ 零填充
int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plaintext);
Assert.Equal(H(f.GetProperty("data")), plaintext.AsSpan(4, dataLen).ToArray());
Assert.All(plaintext.AsSpan(4 + dataLen).ToArray(), b => Assert.Equal(0, b));
}
}
[Fact]
public void ItemVectorsFollowTheServerFramingRules()
{
foreach (var item in V.GetProperty("items").EnumerateArray())
{
string name = item.GetProperty("name").GetString()!;
bool gated = item.GetProperty("gated").GetBoolean();
var frames = item.GetProperty("frames").EnumerateArray().Select(e => H(e)).ToList();
var content = new List<byte>();
for (int i = 0; i < frames.Count; i++)
{
Assert.True(Envelope.TryParse(frames[i], out var env), name);
Assert.Equal((uint)i, env.FrameIndex);
Assert.Equal(gated, env.IsGated);
Assert.Equal(item.GetProperty("snapshot").GetBoolean(), env.IsSnapshot);
Assert.Equal(i == frames.Count - 1, env.IsFinal);
byte[] plain = Open(ChannelId, frames[i]);
int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plain);
var data = plain.AsSpan(4, dataLen).ToArray();
if (i == 0)
{
// 第 0 帧:u16 meta_len ‖ meta_json ‖ 内容
int metaLen = BinaryPrimitives.ReadUInt16BigEndian(data);
Assert.Equal(item.GetProperty("meta_json").GetString(), Encoding.UTF8.GetString(data.AsSpan(2, metaLen)));
content.AddRange(data.AsSpan(2 + metaLen).ToArray());
}
else
{
content.AddRange(data);
}
}
Assert.Equal(H(item.GetProperty("content")), content.ToArray());
// 非 GATED 条目必须是单帧且 FINAL,这是服务端强制的规则
if (!gated) Assert.Single(frames);
}
}
[Fact]
public void RawFrameVectorsParse()
{
foreach (var f in V.GetProperty("raw_frames").EnumerateArray())
{
Assert.True(Envelope.TryParse(H(f.GetProperty("envelope")), out var env));
Assert.False(env.IsEnc);
Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags);
Assert.Equal(H(f.GetProperty("payload")).Length, env.PayloadLength);
}
}
[Fact]
public void LabelVectorsDecryptWithTheDerivedKey()
{
byte[] kLabel = Hkdf(KEnc, ChannelId, "webdrop/v1/label");
foreach (var l in V.GetProperty("labels").EnumerateArray())
{
byte[] blob = H(l.GetProperty("blob"));
Assert.True(blob.Length <= 256, "设备名总长不得超过 256 字节(服务端上限)");
var plain = new byte[blob.Length - 12 - 16];
using var gcm = new AesGcm(kLabel, 16);
gcm.Decrypt(blob.AsSpan(0, 12), blob.AsSpan(12, plain.Length), blob.AsSpan(blob.Length - 16), plain, H(l.GetProperty("aad")));
Assert.Equal(l.GetProperty("name").GetString(), Encoding.UTF8.GetString(plain));
}
}
[Fact]
public void LogRecordVectorsMatchTheServerEncoders()
{
foreach (var r in V.GetProperty("log_records").EnumerateArray())
{
var parsed = r.GetProperty("parsed");
long seq = parsed.GetProperty("seq").GetInt64();
byte[] expected = H(r.GetProperty("hex"));
switch (parsed.GetProperty("kind").GetString())
{
case "tombstone":
var item = Id16.FromBytes(H(parsed.GetProperty("item_id")));
Assert.Equal(expected, LogRecord.EncodeTombstone(seq, item, (TombstoneReason)parsed.GetProperty("reason").GetInt32()));
break;
case "announce":
Assert.Equal(expected, LogRecord.EncodeAnnounce(seq, Id16.FromBytes(H(parsed.GetProperty("item_id"))),
(FrameFlags)parsed.GetProperty("flags").GetInt32(), parsed.GetProperty("created_at").GetInt64()));
break;
case "frame":
Assert.Equal(expected, LogRecord.Encode(seq, LogKind.Frame, H(parsed.GetProperty("envelope"))));
break;
}
}
}
[Fact]
public void LinkIdsAreCanonicalForTheServer()
{
string id = V.GetProperty("inputs").GetProperty("channel_id_b64").GetString()!;
Assert.True(Id16.TryParse(id, out var parsed));
Assert.Equal(ChannelId, parsed.ToArray());
Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _)); // 非规范编码,对应向量 non_canonical_id
}
[Fact]
public void NegativeVectorsAreRejectedByTheServerSideChecksOrByAuthentication()
{
byte[] original = ChannelId;
foreach (var n in V.GetProperty("negative").EnumerateArray())
{
string name = n.GetProperty("name").GetString()!;
string expect = n.GetProperty("expect").GetString()!;
byte[] envelope = H(n.GetProperty("envelope"));
byte[] channel = n.TryGetProperty("channel_id", out var c) ? H(c) : original;
switch (expect)
{
case "bad_envelope":
Assert.False(Envelope.TryParse(envelope, out _), name);
break;
case "auth_failed":
// 结构合法,但 GCM 认证必须失败(篡改、换头部、换频道都被 AAD 抓住)
Assert.True(Envelope.TryParse(envelope, out _), name);
Assert.ThrowsAny<CryptographicException>(() => Open(channel, envelope));
break;
case "wrong_mode":
// 没有 ENC 标志的帧:封装合法,但在 enc 频道上服务端会以 mode_mismatch 拒绝
Assert.True(Envelope.TryParse(envelope, out var env), name);
Assert.False(env.IsEnc, name);
break;
default:
Assert.Fail($"未知的期望类型 {expect}");
break;
}
}
}
}