diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs
new file mode 100644
index 00000000..0082b855
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs
@@ -0,0 +1,1338 @@
+using System.Text.Json.Nodes;
+using EcencyApi.Handlers;
+using EcencyApi.Infrastructure;
+using Xunit;
+using T = EcencyApi.Tests.CurationDeskTestSupport;
+
+namespace EcencyApi.Tests;
+
+[CollectionDefinition("recovery", DisableParallelization = true)]
+public class RecoveryCollection { }
+
+/// Pure rules for self-serve account recovery: key lineage, window, proof, input formats.
+public class RecoveryRulesTests
+{
+ // Golden vector produced by @hiveio/dhive: PrivateKey.fromLogin("recoverytest", ..., "owner"),
+ // signature = oldOwner.sign(sha256(challenge)).toString().
+ internal const string Username = "recoverytest";
+ internal const string OldOwnerPub = "STM6TR1xz8uFu24zpZYdYCkKYnhBq7L8aELAe8uL9gny1D1NiYWRs";
+ internal const string NewOwnerPub = "STM5Ki4Y2rPorZ4GKJEaxWZFEKPSZ79eHGih5y7UVPkcgqa8YUfDN";
+ internal const string Nonce = "0123456789abcdef0123456789abcdef";
+ internal const string Signature =
+ "20412e9e9230c61f9389e7ed345174e78e46666c2d3fc134a102443159cc74a9f160f9674d0d12de1ba8bfcb380934a199a16055a7612367c2b7d656e0cb301940";
+
+ private static readonly DateTime Head = new(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ internal static JsonObject Entry(string key, string lastValid, int weight = 1, int threshold = 1) =>
+ MultiKeyEntry(lastValid, threshold, (key, weight));
+
+ internal static JsonObject MultiKeyEntry(string lastValid, int threshold, params (string Key, int Weight)[] keys)
+ {
+ var auths = new JsonArray();
+ foreach (var (key, weight) in keys)
+ {
+ auths.Add(new JsonArray(key, weight));
+ }
+ return new JsonObject
+ {
+ ["id"] = 1,
+ ["account"] = Username,
+ ["previous_owner_authority"] = new JsonObject
+ {
+ ["weight_threshold"] = threshold,
+ ["account_auths"] = new JsonArray(),
+ ["key_auths"] = auths,
+ },
+ ["last_valid_time"] = lastValid,
+ };
+ }
+
+ private static string Fmt(DateTime t) => t.ToString("yyyy-MM-ddTHH:mm:ss");
+
+ private static DateTime? ValidUntil(JsonArray history, string key, DateTime head, DateTime? lastRecovery) =>
+ RecoveryRules.CheckProvenKey(RecoveryRules.ReadOwnerHistory(history), key, head, lastRecovery).ValidUntil;
+
+ private static RecoveryRules.Verdict ProvenVerdict(JsonArray history, string key) =>
+ RecoveryRules.CheckProvenKey(RecoveryRules.ReadOwnerHistory(history), key, Head, null).Verdict;
+
+ private static bool HasRecentOwnerChange(JsonArray history, DateTime head, DateTime? lastRecovery) =>
+ RecoveryRules.CheckOwnerHistory(RecoveryRules.ReadOwnerHistory(history), head, lastRecovery)
+ != RecoveryRules.Verdict.NoRecentOwnerChange;
+
+ [Fact]
+ public void ChallengeIsTheAgreedString()
+ {
+ Assert.Equal($"ecency-recovery:{Username}:{NewOwnerPub}:{Nonce}",
+ RecoveryRules.Challenge(Username, NewOwnerPub, Nonce));
+ }
+
+ [Fact]
+ public void GoldenProofVerifies()
+ {
+ Assert.True(RecoveryRules.ProofMatches(Username, NewOwnerPub, Nonce, Signature, OldOwnerPub));
+ }
+
+ [Fact]
+ public void ProofForAnotherKeyIsRejected()
+ {
+ Assert.False(RecoveryRules.ProofMatches(Username, NewOwnerPub, Nonce, Signature, NewOwnerPub));
+ }
+
+ [Theory]
+ [InlineData("recoverytest", "ffffffffffffffffffffffffffffffff")]
+ [InlineData("otheraccount", Nonce)]
+ public void ProofIsBoundToUsernameAndNonce(string username, string nonce)
+ {
+ Assert.False(RecoveryRules.ProofMatches(username, NewOwnerPub, nonce, Signature, OldOwnerPub));
+ }
+
+ [Fact]
+ public void ProofIsBoundToTheNewKey()
+ {
+ Assert.False(RecoveryRules.ProofMatches(Username, OldOwnerPub, Nonce, Signature, OldOwnerPub));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("zz")]
+ [InlineData("20412e9e")]
+ public void MalformedSignatureIsRejected(string sig)
+ {
+ Assert.False(RecoveryRules.ProofMatches(Username, NewOwnerPub, Nonce, sig, OldOwnerPub));
+ }
+
+ [Fact]
+ public void ProofSignedByHiveCryptoVerifies()
+ {
+ var key = HiveCrypto.FromLogin("someone", "pw", "owner");
+ var pub = HiveCrypto.PublicKeyToString(key.CreatePubKey());
+ var sig = HiveCrypto.Sign(key, HiveCrypto.Sha256Utf8(RecoveryRules.Challenge("someone", NewOwnerPub, Nonce)));
+ Assert.True(RecoveryRules.ProofMatches("someone", NewOwnerPub, Nonce, sig, pub));
+ }
+
+ [Theory]
+ [InlineData(OldOwnerPub, true)]
+ [InlineData(NewOwnerPub, true)]
+ [InlineData("STM5Ki4Y2rPorZ4GKJEaxWZFEKPSZ79eHGih5y7UVPkcgqa8YUfDM", false)] // checksum
+ [InlineData("TST5Ki4Y2rPorZ4GKJEaxWZFEKPSZ79eHGih5y7UVPkcgqa8YUfDN", false)] // prefix
+ [InlineData("STM", false)]
+ [InlineData("STM0OIl", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void PublicKeyFormat(string? key, bool ok)
+ {
+ Assert.Equal(ok, HiveCrypto.IsPublicKey(key));
+ }
+
+ [Theory]
+ [InlineData("3f2b1c9e-8d7a-4b6c-9e5f-1a2b3c4d5e6f", true)]
+ [InlineData("3F2B1C9E-8D7A-4B6C-9E5F-1A2B3C4D5E6F", false)]
+ [InlineData("3f2b1c9e8d7a4b6c9e5f1a2b3c4d5e6f", false)]
+ [InlineData("../admin", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void RequestIdFormat(string? id, bool ok)
+ {
+ Assert.Equal(ok, RecoveryRules.IsRequestId(id));
+ }
+
+ [Theory]
+ [InlineData("123456", true)]
+ [InlineData("000000", true)]
+ [InlineData("12345", false)]
+ [InlineData("1234567", false)]
+ [InlineData("12345a", false)]
+ [InlineData("١٢٣٤٥٦", false)]
+ [InlineData(null, false)]
+ public void CodeFormat(string? code, bool ok)
+ {
+ Assert.Equal(ok, RecoveryRules.IsCode(code));
+ }
+
+ [Fact]
+ public void KeyReplacedByARecoveryIsRejected()
+ {
+ // The key a recovery replaced carries last_valid_time == the recovery time.
+ var tr = Head.AddDays(-2);
+ var history = new JsonArray(Entry(OldOwnerPub, Fmt(tr)));
+ Assert.Null(ValidUntil(history, OldOwnerPub, Head, tr));
+ }
+
+ [Fact]
+ public void KeyNewerThanTheLastRecoveryIsAccepted()
+ {
+ var tr = Head.AddDays(-2);
+ var lvt = tr.AddSeconds(3);
+ var history = new JsonArray(Entry(OldOwnerPub, Fmt(lvt)));
+ Assert.Equal(lvt, ValidUntil(history, OldOwnerPub, Head, tr));
+ }
+
+ [Fact]
+ public void NoRecoveryMeansNoLineageConstraint()
+ {
+ var lvt = Head.AddDays(-5);
+ var history = new JsonArray(Entry(OldOwnerPub, Fmt(lvt)));
+ Assert.Equal(lvt, ValidUntil(history, OldOwnerPub, Head, null));
+ }
+
+ [Fact]
+ public void WindowEdgeIsExclusive()
+ {
+ var atEdge = new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-30))));
+ Assert.Null(ValidUntil(atEdge, OldOwnerPub, Head, null));
+
+ var inside = Head.AddDays(-30).AddSeconds(1);
+ var justInside = new JsonArray(Entry(OldOwnerPub, Fmt(inside)));
+ Assert.Equal(inside, ValidUntil(justInside, OldOwnerPub, Head, null));
+ }
+
+ [Fact]
+ public void SeveralMatchesPickTheLatest()
+ {
+ var older = Head.AddDays(-10);
+ var newer = Head.AddDays(-1);
+ var history = new JsonArray(
+ Entry(OldOwnerPub, Fmt(older)),
+ Entry(NewOwnerPub, Fmt(Head.AddHours(-1))),
+ Entry(OldOwnerPub, Fmt(newer)));
+ Assert.Equal(newer, ValidUntil(history, OldOwnerPub, Head, null));
+ }
+
+ [Fact]
+ public void UnknownKeyIsRejected()
+ {
+ var history = new JsonArray(Entry(NewOwnerPub, Fmt(Head.AddDays(-1))));
+ Assert.Null(ValidUntil(history, OldOwnerPub, Head, null));
+ }
+
+ [Fact]
+ public void RecentOwnerChangeRespectsWindowAndLineage()
+ {
+ Assert.False(HasRecentOwnerChange(new JsonArray(), Head, null));
+ Assert.False(HasRecentOwnerChange(
+ new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-31)))), Head, null));
+ var tr = Head.AddDays(-1);
+ Assert.False(HasRecentOwnerChange(
+ new JsonArray(Entry(OldOwnerPub, Fmt(tr))), Head, tr));
+ Assert.True(HasRecentOwnerChange(
+ new JsonArray(Entry(OldOwnerPub, Fmt(tr.AddSeconds(3)))), Head, tr));
+ }
+
+ [Fact]
+ public void LastRecoveryComesFromTheAccount()
+ {
+ Assert.Equal(new DateTime(2026, 11, 10, 14, 25, 30, DateTimeKind.Utc),
+ RecoveryRules.LastAccountRecovery(new JsonObject { ["last_account_recovery"] = "2026-11-10T14:25:30" }));
+ Assert.Null(RecoveryRules.LastAccountRecovery(new JsonObject { ["last_account_recovery"] = "1970-01-01T00:00:00" }));
+ }
+
+ public static IEnumerable