2
0
mirror of https://github.com/esiur/esiur-dotnet.git synced 2026-06-13 14:38:43 +00:00

removed unsafe

This commit is contained in:
2026-06-02 19:28:09 +03:00
parent 24cf15dec7
commit 3dc36149b7
31 changed files with 1155 additions and 338 deletions
+190
View File
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Esiur.Security.Authority;
using Esiur.Security.Authority.Providers;
namespace Esiur.Tests.Unit;
/// <summary>
/// Drives a pair of <see cref="PasswordAuthenticationHandler"/> instances (initiator and
/// responder) through the SHA3 challenge/response handshake and asserts both the happy
/// path (matching session keys) and the security hardening (constant-time challenge
/// checks, nonce validation, fail-closed behaviour on malformed peer input).
/// </summary>
public class AuthHandshakeTests
{
// ---- test credential store -------------------------------------------------------
class TestAccount
{
public string Identity;
public byte[] RawPassword;
public byte[] Salt;
public byte[] Hash; // SHA3-256(RawPassword || Salt), exactly what the verifier stores
}
static readonly byte[] FixedSalt = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 6 };
static TestAccount MakeAccount(string identity, string password)
{
var raw = Encoding.UTF8.GetBytes(password);
var hash = PasswordAuthenticationHandler.ComputeSha3(raw.Concat(FixedSalt).ToArray());
return new TestAccount { Identity = identity, RawPassword = raw, Salt = FixedSalt, Hash = hash };
}
class StubProvider : PasswordAuthenticationProvider
{
readonly Dictionary<string, TestAccount> _accounts;
readonly string _self;
public StubProvider(string self, params TestAccount[] accounts)
{
_self = self;
_accounts = accounts.ToDictionary(a => a.Identity, a => a);
}
public override IdentityPassword GetSelfIdentityAndCredential(string domain, string hostname)
=> new IdentityPassword(_self, _accounts[_self].RawPassword);
public override byte[] GetSelfCredential(string identity, string domain, string hostname)
=> _accounts.TryGetValue(identity, out var a) ? a.RawPassword : null;
public override PasswordHash GetHostedAccountCredential(string identity, string domain)
=> _accounts.TryGetValue(identity, out var a)
? new PasswordHash(a.Hash, a.Salt)
: new PasswordHash(null, null);
}
// ---- helpers ---------------------------------------------------------------------
static object[] DataOf(AuthenticationResult result)
=> ((List<object>)result.AuthenticationData)?.ToArray();
static byte[] PrivateNonce(PasswordAuthenticationHandler handler)
=> (byte[])typeof(PasswordAuthenticationHandler)
.GetField("_localNonce", BindingFlags.NonPublic | BindingFlags.Instance)
.GetValue(handler);
static (PasswordAuthenticationHandler init, PasswordAuthenticationHandler resp) NewPair(string identity = "alice")
{
var account = MakeAccount(identity, "correct horse battery staple");
var initiator = new PasswordAuthenticationHandler(
AuthenticationMode.InitializerIdentity, AuthenticationDirection.Initiator,
identity, null, "host", "domain", new StubProvider(identity, account));
var responder = new PasswordAuthenticationHandler(
AuthenticationMode.InitializerIdentity, AuthenticationDirection.Responder,
null, null, "host", "domain", new StubProvider(identity, account));
return (initiator, responder);
}
// ---- happy path ------------------------------------------------------------------
[Fact]
public void InitializerIdentity_Handshake_Derives_Matching_SessionKeys()
{
var (init, resp) = NewPair();
var r1 = init.Process(null); // -> [initNonce, initIdentity]
Assert.Equal(AuthenticationRuling.InProgress, r1.Ruling);
var r2 = resp.Process(DataOf(r1)); // -> [respNonce, respSalt, respChallenge]
Assert.Equal(AuthenticationRuling.InProgress, r2.Ruling);
var r3 = init.Process(DataOf(r2)); // -> [initChallenge], Succeeded
Assert.Equal(AuthenticationRuling.Succeeded, r3.Ruling);
var r4 = resp.Process(DataOf(r3)); // Succeeded
Assert.Equal(AuthenticationRuling.Succeeded, r4.Ruling);
Assert.NotNull(r3.SessionKey);
Assert.Equal(64, r3.SessionKey.Length); // 512-bit derived key
Assert.Equal(r3.SessionKey, r4.SessionKey); // both ends agree
}
[Fact]
public void Wrong_Password_Fails()
{
var account = MakeAccount("alice", "the real password");
var init = new PasswordAuthenticationHandler(
AuthenticationMode.InitializerIdentity, AuthenticationDirection.Initiator,
"alice", null, "host", "domain",
new StubProvider("alice", MakeAccount("alice", "a different password")));
var resp = new PasswordAuthenticationHandler(
AuthenticationMode.InitializerIdentity, AuthenticationDirection.Responder,
null, null, "host", "domain", new StubProvider("alice", account));
var r1 = init.Process(null);
var r2 = resp.Process(DataOf(r1));
// Initiator validates the responder's challenge against its (wrong) password and bails.
var r3 = init.Process(DataOf(r2));
Assert.Equal(AuthenticationRuling.Failed, r3.Ruling);
}
// ---- security properties ---------------------------------------------------------
[Fact]
public void Tampered_Challenge_Fails()
{
var (init, resp) = NewPair();
var r1 = init.Process(null);
var r2 = resp.Process(DataOf(r1));
var r3 = init.Process(DataOf(r2)); // [initChallenge]
var tampered = DataOf(r3);
((byte[])tampered[0])[0] ^= 0xFF; // flip a bit in the challenge
var r4 = resp.Process(tampered);
Assert.Equal(AuthenticationRuling.Failed, r4.Ruling);
}
[Fact]
public void Reflected_Nonce_Is_Rejected()
{
// Replay defence: feeding the responder its own nonce must be rejected.
var (init, resp) = NewPair();
var respNonce = PrivateNonce(resp);
var forged = new object[] { respNonce, "alice" };
var result = resp.Process(forged);
Assert.Equal(AuthenticationRuling.Failed, result.Ruling);
}
[Fact]
public void Short_Nonce_Is_Rejected()
{
var (_, resp) = NewPair();
var result = resp.Process(new object[] { new byte[5], "alice" });
Assert.Equal(AuthenticationRuling.Failed, result.Ruling);
}
[Theory]
[InlineData(0)] // empty
[InlineData(1)] // too few elements
public void Truncated_Input_Fails_Without_Throwing(int count)
{
var (_, resp) = NewPair();
var result = resp.Process(Enumerable.Range(0, count).Select(_ => (object)new byte[20]).ToArray());
Assert.Equal(AuthenticationRuling.Failed, result.Ruling);
}
[Fact]
public void Null_Input_Fails_Without_Throwing()
{
var (_, resp) = NewPair();
var result = resp.Process(null);
Assert.Equal(AuthenticationRuling.Failed, result.Ruling);
}
[Fact]
public void WrongType_Material_Fails_Closed()
{
// A peer sending a string where a nonce (byte[]) is expected must fail, not throw.
var (_, resp) = NewPair();
var result = resp.Process(new object[] { "not-a-nonce", "alice" });
Assert.Equal(AuthenticationRuling.Failed, result.Ruling);
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<!-- Reference Esiur both as a runtime assembly and as the source generator (analyzer),
mirroring Tests/Features/Functional so [Resource]/[Export] test types get generated code. -->
<ItemGroup>
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
</ItemGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
using System;
using System.Linq;
using Esiur.Misc;
namespace Esiur.Tests.Unit;
/// <summary>
/// Guards the fix that moved Global.GenerateBytes / GenerateCode off System.Random onto a
/// cryptographic RNG. These are sanity/entropy checks, not statistical proofs: their job is
/// to fail loudly if someone reintroduces a predictable or constant generator.
/// </summary>
public class SecureRandomTests
{
[Fact]
public void GenerateBytes_Returns_Requested_Length()
{
Assert.Equal(20, Global.GenerateBytes(20).Length);
Assert.Equal(0, Global.GenerateBytes(0).Length);
}
[Fact]
public void GenerateBytes_Are_Not_Repeated()
{
var a = Global.GenerateBytes(32);
var b = Global.GenerateBytes(32);
Assert.False(a.SequenceEqual(b), "Two nonces must not be identical.");
}
[Fact]
public void GenerateBytes_Are_Not_Constant()
{
var bytes = Global.GenerateBytes(64);
Assert.True(bytes.Distinct().Count() > 1, "Output must not be a single repeated byte.");
}
[Fact]
public void GenerateBytes_Have_Broad_Distribution()
{
// Across 4 KiB of output almost every byte value should appear at least once.
var bytes = Global.GenerateBytes(4096);
Assert.True(bytes.Distinct().Count() > 200,
"A cryptographic RNG should produce a wide spread of byte values.");
}
[Fact]
public void GenerateCode_Honours_Length_And_Alphabet()
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var code = Global.GenerateCode(24);
Assert.Equal(24, code.Length);
Assert.All(code, c => Assert.Contains(c, alphabet));
}
[Fact]
public void GenerateCode_Is_Not_Repeated()
{
Assert.NotEqual(Global.GenerateCode(24), Global.GenerateCode(24));
}
}
+215
View File
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using Esiur.Data;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
/// <summary>
/// Round-trips values through Codec.Compose -> Codec.ParseSync and asserts the value
/// survives. Because the serializer narrows integers/floats to the smallest wire type,
/// the parsed CLR type often differs from the input, so comparisons are value-based.
/// Exact wire bytes are pinned separately by <see cref="WireFormatGoldenTests"/>.
/// </summary>
public class SerializationRoundTripTests
{
static object RoundTrip(object value)
{
var bytes = Codec.Compose(value, Warehouse.Default, null);
var (consumed, parsed) = Codec.ParseSync(bytes, 0, Warehouse.Default);
Assert.Equal((uint)bytes.Length, consumed);
return parsed;
}
static void AssertIntRoundTrip(long value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToInt64(parsed));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(-1)]
[InlineData(sbyte.MinValue)]
[InlineData(sbyte.MaxValue)]
[InlineData((long)short.MinValue)]
[InlineData((long)short.MaxValue)]
[InlineData((long)int.MinValue)]
[InlineData((long)int.MaxValue)]
[InlineData(long.MinValue)]
[InlineData(long.MaxValue)]
public void Int64_NarrowsAndRoundTrips(long value) => AssertIntRoundTrip(value);
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(-1)]
[InlineData(int.MinValue)]
[InlineData(int.MaxValue)]
public void Int32_RoundTrips(int value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToInt32(parsed));
}
[Theory]
[InlineData((ulong)0)]
[InlineData((ulong)255)]
[InlineData((ulong)65535)]
[InlineData((ulong)uint.MaxValue)]
[InlineData(ulong.MaxValue)]
public void UInt64_NarrowsAndRoundTrips(ulong value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToUInt64(parsed));
}
[Theory]
[InlineData(0f)]
[InlineData(1.5f)]
[InlineData(-3.25f)]
[InlineData(3.4028235e38f)]
public void Float32_RoundTrips(float value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToSingle(parsed), 3);
}
[Theory]
[InlineData(0d)]
[InlineData(1.5d)]
[InlineData(-3.25d)]
[InlineData(0.1d)]
[InlineData(1.7976931348623157e308d)]
public void Float64_RoundTrips(double value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToDouble(parsed), 10);
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void Float_NaN_And_Infinity_Encode_As_Infinity_Token(double value)
{
// The serializer collapses NaN and +/- Infinity onto the single 1-byte Infinity
// token (0x04), which now decodes to a canonical +Infinity rather than crashing.
var bytes = Codec.Compose(value, Warehouse.Default, null);
Assert.Equal(new byte[] { 0x04 }, bytes);
var parsed = RoundTrip(value);
Assert.True(double.IsPositiveInfinity(Convert.ToDouble(parsed)));
}
[Theory]
[InlineData(0.5)]
[InlineData(1.1)] // hits the decimal -> Float64 branch (regression for the byte[4] overrun)
[InlineData(-1234.5)]
public void Decimal_FloatBranches_RoundTrip(double asDouble)
{
var value = (decimal)asDouble;
var parsed = RoundTrip(value);
Assert.Equal(Convert.ToDouble(value), Convert.ToDouble(parsed), 6);
}
[Fact]
public void Decimal_IntegerValue_RoundTrips()
{
var parsed = RoundTrip(42m);
Assert.Equal(42L, Convert.ToInt64(parsed));
}
[Fact]
public void Decimal_HighPrecision_RoundTrips()
{
// A value with a non-zero scale that is not exactly representable as float or
// double, so it stays a full 16-byte Decimal128 on the wire.
var value = 1.2345678901234567890123456789m;
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToDecimal(parsed));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Bool_RoundTrips(bool value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, Convert.ToBoolean(parsed));
}
[Theory]
[InlineData("")]
[InlineData("Hello, Esiur")]
[InlineData("Unicode éü☃😀")]
public void String_RoundTrips(string value)
{
var parsed = RoundTrip(value);
Assert.Equal(value, (string)parsed);
}
[Fact]
public void Null_RoundTrips()
{
var parsed = RoundTrip(null);
Assert.Null(parsed);
}
[Fact]
public void Char_RoundTrips()
{
var parsed = RoundTrip('A');
Assert.Equal('A', Convert.ToChar(parsed));
}
[Fact]
public void DateTime_RoundTrips_AsUtc()
{
var value = new DateTime(2026, 6, 2, 13, 45, 30, DateTimeKind.Utc);
var parsed = (DateTime)RoundTrip(value);
Assert.Equal(value.ToUniversalTime().Ticks, parsed.ToUniversalTime().Ticks);
}
[Fact]
public void Uuid_RoundTrips()
{
var value = new Uuid(Guid.Parse("12345678-90ab-cdef-1234-567890abcdef").ToByteArray());
var parsed = (Uuid)RoundTrip(value);
Assert.Equal(value.ToString(), parsed.ToString());
}
static List<object> ToObjectList(object value)
{
var got = new List<object>();
foreach (var o in (System.Collections.IEnumerable)value)
got.Add(o);
return got;
}
[Fact]
public void IntArray_RoundTrips()
{
// A typed int[] is reconstructed as an int[] (typed-list path), not a dynamic list.
var value = new int[] { 1, 2, 3, 100, -50, int.MaxValue };
var got = ToObjectList(RoundTrip(value)).ConvertAll(Convert.ToInt64);
Assert.Equal(new long[] { 1, 2, 3, 100, -50, int.MaxValue }, got);
}
[Fact]
public void StringList_RoundTrips()
{
var value = new object[] { "a", "b", "c" };
var got = ToObjectList(RoundTrip(value)).ConvertAll(o => (string)o);
Assert.Equal(new[] { "a", "b", "c" }, got);
}
[Fact]
public void Map_RoundTrips()
{
var value = new Map<string, int> { ["one"] = 1, ["two"] = 2 };
var parsed = RoundTrip(value);
Assert.NotNull(parsed);
}
}
+94
View File
@@ -0,0 +1,94 @@
using System;
using Esiur.Data;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
[Export]
public class PersonRecord : IRecord
{
public string Name { get; set; }
public int Age { get; set; }
public double Score { get; set; }
}
public enum Color
{
Red,
Green,
Blue,
}
/// <summary>
/// Covers the "typed" serialization paths (records, tuples, enums, typed maps/lists) that
/// rely on Tru.FromType. Uses a decode-then-re-encode stability check: re-composing a parsed
/// value must reproduce the exact original wire bytes. This is type-agnostic (no need to know
/// the parsed CLR type) and proves both the encode and decode halves agree on the format.
/// </summary>
public class TypedSerializationTests
{
static void AssertReencodeStable(object value)
{
var bytes1 = Codec.Compose(value, Warehouse.Default, null);
var (consumed, parsed) = Codec.ParseSync(bytes1, 0, Warehouse.Default);
Assert.Equal((uint)bytes1.Length, consumed);
var bytes2 = Codec.Compose(parsed, Warehouse.Default, null);
Assert.Equal(bytes1, bytes2);
}
[Fact]
public void Record_ReencodeIsStable()
{
AssertReencodeStable(new PersonRecord { Name = "Ada", Age = 36, Score = 99.5 });
}
[Fact]
public void Enum_Encodes_Typed_And_Decodes_To_Underlying_Int()
{
// An enum is sent as a Typed TDU carrying the constant index; the decoder (without
// CLR enum reconstruction) yields the underlying integer value. This is the existing
// protocol behaviour, asserted here so it stays stable.
var bytes = Codec.Compose(Color.Green, Warehouse.Default, null);
Assert.Equal(0x88, bytes[0]); // Typed class token
var (_, parsed) = Codec.ParseSync(bytes, 0, Warehouse.Default);
Assert.Equal((int)Color.Green, Convert.ToInt32(parsed));
}
[Theory]
[InlineData(1, "a")]
[InlineData(-5, "hello")]
public void Tuple2_ReencodeIsStable(int a, string b)
{
AssertReencodeStable((a, b));
}
[Fact]
public void Tuple3_ReencodeIsStable()
{
AssertReencodeStable((1, "two", 3.0));
}
[Fact]
public void TypedMap_ReencodeIsStable()
{
AssertReencodeStable(new Map<string, int> { ["one"] = 1, ["two"] = 2, ["three"] = 3 });
}
[Fact]
public void TypedIntList_ReencodeIsStable()
{
AssertReencodeStable(new int[] { 5, 4, 3, 2, 1, 0, -1 });
}
[Fact]
public void RecordList_ReencodeIsStable()
{
AssertReencodeStable(new[]
{
new PersonRecord { Name = "A", Age = 1, Score = 1.1 },
new PersonRecord { Name = "B", Age = 2, Score = 2.2 },
});
}
}
+86
View File
@@ -0,0 +1,86 @@
using System;
using Esiur.Data;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
/// <summary>
/// Pins the exact on-wire bytes produced by Codec.Compose for a representative value of
/// every TDU family. Esiur is a multi-language protocol (C#/JS/Dart) whose binary format
/// must stay byte-compatible, so these golden vectors are a guard rail: any later
/// refactor (e.g. serializer performance work) that changes a single byte fails here.
/// Values were captured from the current implementation.
/// </summary>
public class WireFormatGoldenTests
{
static string Hex(object value) =>
BitConverter.ToString(Codec.Compose(value, Warehouse.Default, null)).Replace("-", "").ToLowerInvariant();
[Theory]
// fixed, zero-payload tokens
[InlineData("null", null, "00")]
[InlineData("bool_false", false, "01")]
[InlineData("bool_true", true, "02")]
// signed integers, narrowed to the smallest width
[InlineData("int_0", 0, "0900")]
[InlineData("int_1", 1, "0901")]
[InlineData("int_minus1", -1, "09ff")]
[InlineData("int_127", 127, "097f")]
[InlineData("int_128", 128, "118000")]
[InlineData("int_200", 200, "11c800")]
[InlineData("int_40000", 40000, "19409c0000")]
[InlineData("int_70000", 70000, "1970110100")]
[InlineData("long_5e9", 5000000000L, "2100f2052a01000000")]
// unsigned integers
[InlineData("uint_255", (uint)255, "08ff")]
[InlineData("uint_256", (uint)256, "100001")]
[InlineData("ulong_max", ulong.MaxValue, "20ffffffffffffffff")]
// floating point
[InlineData("float_1p5", 1.5f, "1a0000c03f")]
[InlineData("double_0p1", 0.1d, "229a9999999999b93f")]
// char
[InlineData("char_A", 'A', "124100")]
// strings (length-prefixed UTF-8)
[InlineData("string_Hi", "Hi", "49024869")]
[InlineData("string_empty", "", "41")]
public void Compose_Matches_Golden(string name, object value, string expectedHex)
{
_ = name; // identifies the case in test output
Assert.Equal(expectedHex, Hex(value));
}
[Fact]
public void Decimal_HighPrecision_Golden()
{
Assert.Equal("2a00001c00321be4271581396eb1c9be46", Hex(1.2345678901234567890123456789m));
}
[Fact]
public void DateTime_Golden()
{
Assert.Equal("230039f035adc0de08", Hex(new DateTime(2026, 6, 2, 13, 45, 30, DateTimeKind.Utc)));
}
[Fact]
public void Uuid_Golden()
{
var uuid = new Uuid(Guid.Parse("12345678-90ab-cdef-1234-567890abcdef").ToByteArray());
Assert.Equal("2b78563412ab90efcd1234567890abcdef", Hex(uuid));
}
[Fact]
public void IntArray_TypedList_Golden()
{
// Typed list with Gvwie group-encoded payload for { 1, 2, 3 }.
Assert.Equal("88054809020406", Hex(new int[] { 1, 2, 3 }));
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void NaN_And_Infinity_Encode_To_Single_Token(double value)
{
Assert.Equal("04", Hex(value));
}
}