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 UnreadableLastRecovery() => new[] + { + new object[] { new JsonObject() }, + new object[] { new JsonObject { ["last_account_recovery"] = "" } }, + new object[] { new JsonObject { ["last_account_recovery"] = "yesterday" } }, + new object[] { new JsonObject { ["last_account_recovery"] = 1731248730 } }, + new object[] { new JsonObject { ["last_account_recovery"] = null } }, + }; + + [Theory] + [MemberData(nameof(UnreadableLastRecovery))] + public void UnreadableLastRecoveryIsAReadFailure(JsonObject account) + { + Assert.ThrowsAny(() => RecoveryRules.LastAccountRecovery(account)); + } + + // ---- key weight ---- + + [Fact] + public void PartialWeightKeyIsNotSufficient() + { + var history = new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-1)), weight: 1, threshold: 2)); + Assert.Equal(RecoveryRules.Verdict.KeyNotSufficient, ProvenVerdict(history, OldOwnerPub)); + Assert.Null(ValidUntil(history, OldOwnerPub, Head, null)); + } + + [Fact] + public void TwoOfTwoKeysAreEachNotSufficient() + { + var history = new JsonArray(MultiKeyEntry(Fmt(Head.AddDays(-1)), 2, (OldOwnerPub, 1), (NewOwnerPub, 1))); + Assert.Equal(RecoveryRules.Verdict.KeyNotSufficient, ProvenVerdict(history, OldOwnerPub)); + Assert.Equal(RecoveryRules.Verdict.KeyNotSufficient, ProvenVerdict(history, NewOwnerPub)); + Assert.Equal(RecoveryRules.Verdict.KeyNotSufficient, + RecoveryRules.CheckOwnerHistory(RecoveryRules.ReadOwnerHistory(history), Head, null)); + } + + [Fact] + public void WeightEqualToThresholdIsSufficient() + { + var lvt = Head.AddDays(-1); + var history = new JsonArray(MultiKeyEntry(Fmt(lvt), 3, (OldOwnerPub, 3), (NewOwnerPub, 1))); + Assert.Equal(RecoveryRules.Verdict.Ok, ProvenVerdict(history, OldOwnerPub)); + Assert.Equal(lvt, ValidUntil(history, OldOwnerPub, Head, null)); + Assert.Equal(RecoveryRules.Verdict.KeyNotSufficient, ProvenVerdict(history, NewOwnerPub)); + Assert.Equal(RecoveryRules.Verdict.Ok, + RecoveryRules.CheckOwnerHistory(RecoveryRules.ReadOwnerHistory(history), Head, null)); + } + + [Fact] + public void ASufficientEntryWinsOverANewerInsufficientOne() + { + var older = Head.AddDays(-5); + var history = new JsonArray( + Entry(OldOwnerPub, Fmt(older)), + Entry(OldOwnerPub, Fmt(Head.AddDays(-1)), weight: 1, threshold: 2)); + Assert.Equal(older, ValidUntil(history, OldOwnerPub, Head, null)); + } + + [Fact] + public void AKeyOnlyInAnOldEntryIsNotRecentEvenIfSufficient() + { + var history = new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-40)))); + Assert.Equal(RecoveryRules.Verdict.KeyNotRecent, ProvenVerdict(history, OldOwnerPub)); + } + + public static IEnumerable UnreadableOwnerHistory() + { + JsonObject Bad(Action change) + { + var e = Entry(OldOwnerPub, "2026-11-08T10:00:00"); + change(e); + return e; + } + JsonObject Auth(JsonObject e) => (JsonObject)e["previous_owner_authority"]!; + return new[] + { + new object[] { new JsonObject() }, + new object[] { new JsonArray(JsonValue.Create(4)) }, + new object[] { new JsonArray(Bad(e => e["last_valid_time"] = "not a time")) }, + new object[] { new JsonArray(Bad(e => e.Remove("last_valid_time"))) }, + new object[] { new JsonArray(Bad(e => e.Remove("previous_owner_authority"))) }, + new object[] { new JsonArray(Bad(e => Auth(e)["weight_threshold"] = "1")) }, + new object[] { new JsonArray(Bad(e => Auth(e)["weight_threshold"] = -1)) }, + new object[] { new JsonArray(Bad(e => Auth(e)["key_auths"] = "x")) }, + new object[] { new JsonArray(Bad(e => Auth(e)["key_auths"] = new JsonArray(new JsonArray(OldOwnerPub)))) }, + new object[] { new JsonArray(Bad(e => Auth(e)["key_auths"] = new JsonArray(new JsonArray(OldOwnerPub, "1")))) }, + new object[] { new JsonArray(Bad(e => Auth(e)["key_auths"] = new JsonArray(new JsonArray(7, 1)))) }, + new object[] { new JsonArray(Bad(e => Auth(e)["key_auths"] = new JsonArray(new JsonArray(OldOwnerPub, -1)))) }, + new object[] { new JsonArray(Bad(e => Auth(e)["key_auths"] = new JsonArray(new JsonArray(OldOwnerPub, 1.5)))) }, + }; + } + + [Theory] + [MemberData(nameof(UnreadableOwnerHistory))] + public void UnreadableOwnerHistoryIsAReadFailure(JsonNode history) + { + Assert.ThrowsAny(() => RecoveryRules.ReadOwnerHistory(history)); + } + + [Theory] + [InlineData(1, 1, true)] + [InlineData(3, 3, true)] + [InlineData(65535, 65535, true)] + [InlineData(1, 2, false)] + [InlineData(0, 1, false)] + public void RecoveredOwnerNeedsTheNewKeyToMeetItsThreshold(int weight, int threshold, bool ok) + { + Assert.Equal(ok, RecoveryRules.IsRecoveredOwner(Authority(NewOwnerPub, weight, threshold), NewOwnerPub)); + } + + [Fact] + public void RecoveredOwnerHoldsOnlyTheNewKeyAndNoAccounts() + { + Assert.False(RecoveryRules.IsRecoveredOwner(Authority(OldOwnerPub), NewOwnerPub)); + var extraKey = Authority(NewOwnerPub); + ((JsonArray)extraKey["key_auths"]!).Add(new JsonArray(OldOwnerPub, 1)); + Assert.False(RecoveryRules.IsRecoveredOwner(extraKey, NewOwnerPub)); + var withAccount = Authority(NewOwnerPub, 3, 3); + ((JsonArray)withAccount["account_auths"]!).Add(new JsonArray("someapp", 1)); + Assert.False(RecoveryRules.IsRecoveredOwner(withAccount, NewOwnerPub)); + var noAccountList = Authority(NewOwnerPub); + noAccountList.Remove("account_auths"); + Assert.False(RecoveryRules.IsRecoveredOwner(noAccountList, NewOwnerPub)); + } + + [Fact] + public void ReadFailureLogNamesTheMethodAndCauseButNoMessage() + { + var (key, line) = PrivateApi.RecoveryReadFailureLog("get_account_history", + new InvalidOperationException($"no match for {Username}\nwarn: forged line")); + Assert.Equal("warn: recovery chain read failed: get_account_history (InvalidOperationException, other)", line); + Assert.DoesNotContain(Username, line); + Assert.DoesNotContain("\n", line); + Assert.DoesNotContain(Username, key); + Assert.Equal(key, PrivateApi.RecoveryReadFailureLog("get_account_history", + new InvalidOperationException("other text")).Key); + } + + public static IEnumerable FailureCategories() => new[] + { + new object[] { new TaskCanceledException("x"), "timeout" }, + new object[] { new TimeoutException("x"), "timeout" }, + new object[] { new HttpRequestException("x"), "http_error" }, + new object[] { new HiveRpcClient.RpcException("x"), "rpc_error" }, + new object[] { new FormatException("x"), "parse_error" }, + new object[] { new System.Text.Json.JsonException("x"), "parse_error" }, + new object[] { new Exception("x"), "other" }, + }; + + [Theory] + [MemberData(nameof(FailureCategories))] + public void ReadFailureLogCategory(Exception e, string category) + { + var (_, line) = PrivateApi.RecoveryReadFailureLog("get_accounts", e); + Assert.EndsWith($"({e.GetType().Name}, {category})", line); + } + + [Fact] + public void RealChainKeyReplacedOneBlockBeforeTheRecoveryIsRejected() + { + var tr = Head.AddDays(-2); + var history = new JsonArray(Entry(OldOwnerPub, Fmt(tr.AddSeconds(-3)))); + Assert.Null(ValidUntil(history, OldOwnerPub, Head, tr)); + } + + internal static JsonObject Authority(string key, int weight = 1, int threshold = 1) => new() + { + ["weight_threshold"] = threshold, + ["account_auths"] = new JsonArray(), + ["key_auths"] = new JsonArray(new JsonArray(key, weight)), + }; + + internal static JsonArray Op(int index, string timestamp, string name, JsonObject payload) => + new(index, new JsonObject + { + ["trx_id"] = "t" + index, + ["block"] = index, + ["timestamp"] = timestamp, + ["op"] = new JsonArray(name, payload), + }); + + // Shaped like a real hacked account: created with a claimed account, password changed + // once by the owner, profile edits in between, then the owner key taken over. + private const string K0 = "STM8ZSw7FmkxEFtkGpo7Gc9Jbs6bHjEu5XfGjNcKqZaHB3wnSXN6W"; + private const string Taken = "STM7xA2DJ2zG3uNoCzFfQ2BVwXqLHxvVW3rRKfS6kNY9iFMRtCsGq"; + + internal static JsonArray HackedAccountOps() => new( + Op(1, "2024-03-01T10:00:00", "create_claimed_account", new JsonObject + { + ["creator"] = "ecency", ["new_account_name"] = Username, + ["owner"] = Authority(K0), ["active"] = Authority(K0), ["posting"] = Authority(K0), + ["memo_key"] = K0, ["json_metadata"] = "", + }), + Op(40, "2025-06-01T09:00:00", "account_update2", new JsonObject + { + ["account"] = Username, ["json_metadata"] = "", ["posting_json_metadata"] = "{}", + }), + Op(77, "2026-02-14T18:30:00", "account_update", new JsonObject + { + ["account"] = Username, ["owner"] = Authority(OldOwnerPub), ["active"] = Authority(OldOwnerPub), + ["posting"] = Authority(OldOwnerPub), ["memo_key"] = OldOwnerPub, ["json_metadata"] = "", + }), + Op(90, "2026-09-02T07:00:00", "account_update", new JsonObject + { + ["account"] = Username, ["memo_key"] = OldOwnerPub, ["json_metadata"] = "{}", + }), + Op(120, "2026-11-08T10:00:00", "account_update", new JsonObject + { + ["account"] = Username, ["owner"] = Authority(Taken), ["active"] = Authority(Taken), + ["posting"] = Authority(Taken), ["memo_key"] = Taken, ["json_metadata"] = "", + })); + + [Fact] + public void OwnerSinceIsTheOpThatSetTheProvenKey() + { + var validUntil = new DateTime(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc); + Assert.Equal(new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(HackedAccountOps(), Username, OldOwnerPub, validUntil)); + Assert.Equal(new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(HackedAccountOps(), Username, K0, new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc))); + } + + [Fact] + public void OwnerSinceIgnoresOpsAtOrAfterValidUntil() + { + // The key set again later (after it was replaced) must not move owner_since forward. + var ops = HackedAccountOps(); + ops.Add(Op(130, "2026-11-09T10:00:00", "account_update", new JsonObject + { + ["account"] = Username, ["owner"] = Authority(OldOwnerPub), ["json_metadata"] = "", + })); + var validUntil = new DateTime(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc); + Assert.Equal(new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, validUntil)); + Assert.Null(RecoveryRules.OwnerSince(HackedAccountOps(), Username, OldOwnerPub, + new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc))); + } + + [Fact] + public void OwnerSinceFromARecoveryAndAnAccountCreate() + { + var ops = new JsonArray( + Op(1, "2023-01-01T00:00:00", "account_create", new JsonObject + { + ["creator"] = "ecency", ["new_account_name"] = Username, ["fee"] = "3.000 HIVE", + ["owner"] = Authority(K0), ["active"] = Authority(K0), ["posting"] = Authority(K0), + ["memo_key"] = K0, ["json_metadata"] = "", + }), + Op(9, "2025-05-05T05:05:05", "recover_account", new JsonObject + { + ["account_to_recover"] = Username, + ["new_owner_authority"] = Authority(OldOwnerPub), + ["recent_owner_authority"] = Authority(K0), + ["extensions"] = new JsonArray(), + })); + var until = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(new DateTime(2025, 5, 5, 5, 5, 5, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, until)); + // K0 was replaced by the recovery, so its span is closed by then. + Assert.Null(RecoveryRules.OwnerSince(ops, Username, K0, until)); + Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, K0, new DateTime(2025, 5, 5, 5, 5, 5, DateTimeKind.Utc))); + Assert.Null(RecoveryRules.OwnerSince(ops, Username, Taken, until)); + Assert.Null(RecoveryRules.OwnerSince(new JsonArray(), Username, K0, until)); + } + + private static readonly DateTime Nov8 = new(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc); + + private static JsonArray OwnerUpdate(int index, string time, string key, string account = Username) => + Op(index, time, "account_update", new JsonObject + { + ["account"] = account, ["owner"] = Authority(key), ["memo_key"] = key, ["json_metadata"] = "", + }); + + [Fact] + public void OwnerSinceStaysAtTheFirstAddWhenALaterUpdateKeepsTheKey() + { + var ops = HackedAccountOps(); + ops.Insert(3, OwnerUpdate(85, "2026-08-01T00:00:00", OldOwnerPub)); + Assert.Equal(new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Nov8)); + } + + [Fact] + public void OwnerSinceIsTheLatestReAdd() + { + var ops = HackedAccountOps(); + ops.Insert(3, OwnerUpdate(85, "2026-06-01T00:00:00", K0)); + ops.Insert(4, OwnerUpdate(86, "2026-07-01T00:00:00", OldOwnerPub)); + ops.Insert(5, OwnerUpdate(87, "2026-08-01T00:00:00", OldOwnerPub)); + Assert.Equal(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Nov8)); + } + + [Fact] + public void OwnerSinceIsUnknownWhenTheEarliestSeenOpAlreadyHoldsTheKey() + { + // The read did not reach back to where the key entered the owner authority. + var ops = new JsonArray( + OwnerUpdate(77, "2026-02-14T18:30:00", OldOwnerPub), + OwnerUpdate(85, "2026-08-01T00:00:00", OldOwnerPub)); + Assert.Null(RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Nov8)); + } + + [Fact] + public void OwnerSinceIsTheAccountCreationWhenItSetTheKey() + { + var ops = HackedAccountOps(); + Assert.Equal(new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, K0, new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc))); + } + + [Fact] + public void OwnerSinceIgnoresOwnerUpdatesOfOtherAccounts() + { + var ops = HackedAccountOps(); + ops.Insert(3, OwnerUpdate(85, "2026-06-01T00:00:00", K0, account: "someoneelse")); + Assert.Equal(new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Nov8)); + } + + [Fact] + public void OwnerSinceFromADelegatedAccountCreation() + { + var ops = new JsonArray( + Op(1, "2018-04-01T12:00:00", "account_create_with_delegation", new JsonObject + { + ["fee"] = "0.500 HIVE", ["delegation"] = "30000.000000 VESTS", ["creator"] = "ecency", + ["new_account_name"] = Username, + ["owner"] = Authority(K0), ["active"] = Authority(K0), ["posting"] = Authority(K0), + ["memo_key"] = K0, ["json_metadata"] = "", ["extensions"] = new JsonArray(), + }), + Op(2, "2019-01-01T00:00:00", "account_create_with_delegation", new JsonObject + { + ["creator"] = Username, ["new_account_name"] = "childaccount", ["owner"] = Authority(OldOwnerPub), + })); + Assert.Equal(new DateTime(2018, 4, 1, 12, 0, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(ops, Username, K0, Nov8)); + Assert.Null(RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Nov8)); + } + + [Fact] + public void OwnerOpsFilterSelectsExactlyTheOwnerSettingOps() + { + // Hive operation ids: account_create 9, account_update 10, create_claimed_account 23, + // recover_account 25, account_create_with_delegation 41, account_update2 43. All are + // below 64, so the high filter word stays 0. + var bits = Enumerable.Range(0, 64).Where(i => (RecoveryRules.OwnerOpsFilter >> i & 1) == 1).ToArray(); + Assert.Equal(new[] { 9, 10, 23, 25, 41, 43 }, bits); + } + + [Fact] + public void OwnerSinceIgnoresAccountsTheUserCreated() + { + var ops = new JsonArray( + Op(1, "2023-01-01T00:00:00", "account_create", new JsonObject + { + ["creator"] = "ecency", ["new_account_name"] = Username, ["fee"] = "3.000 HIVE", + ["owner"] = Authority(K0), ["active"] = Authority(K0), ["posting"] = Authority(K0), + ["memo_key"] = K0, ["json_metadata"] = "", + }), + Op(5, "2025-01-01T00:00:00", "create_claimed_account", new JsonObject + { + ["creator"] = Username, ["new_account_name"] = "childaccount", + ["owner"] = Authority(K0), ["active"] = Authority(K0), ["posting"] = Authority(K0), + ["memo_key"] = K0, ["json_metadata"] = "", + })); + var until = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, Username, K0, until)); + Assert.Null(RecoveryRules.OwnerSince(new JsonArray(ops[1]!.DeepClone()), Username, K0, until)); + } + + public static IEnumerable UnreadableOwnerOps() => new[] + { + new object[] { new JsonArray(Op(1, "2026-01-01T00:00:00", "transfer", new JsonObject())) }, + new object[] { new JsonArray(Op(1, "not a time", "account_update", new JsonObject { ["account"] = Username })) }, + new object[] { new JsonArray(Op(1, "2026-01-01T00:00:00", "account_create", new JsonObject { ["memo_key"] = K0 })) }, + new object[] { new JsonArray(Op(1, "2026-01-01T00:00:00", "recover_account", new JsonObject { ["account_to_recover"] = Username })) }, + new object[] { new JsonArray(Op(1, "2026-01-01T00:00:00", "account_update", new JsonObject { ["owner"] = "x" })) }, + new object[] { new JsonArray(new JsonArray(1, new JsonObject { ["timestamp"] = "2026-01-01T00:00:00", ["op"] = 3 })) }, + new object[] { new JsonObject() }, + }; + + [Theory] + [MemberData(nameof(UnreadableOwnerOps))] + public void UnreadableOwnerOpsAreAReadFailure(JsonNode ops) + { + Assert.ThrowsAny(() => RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Head)); + } + + [Fact] + public void TurnstileActionIsBound() + { + var ok = JsonNode.Parse("""{"success":true,"action":"recover"}"""); + var other = JsonNode.Parse("""{"success":true,"action":"signup"}"""); + var failed = JsonNode.Parse("""{"success":false,"action":"recover"}"""); + Assert.True(PrivateApi.TurnstileAccepted(ok, "recover")); + Assert.False(PrivateApi.TurnstileAccepted(other, "recover")); + Assert.False(PrivateApi.TurnstileAccepted(failed, "recover")); + // Callers without an action keep the original success-only check. + Assert.True(PrivateApi.TurnstileAccepted(other, null)); + Assert.False(PrivateApi.TurnstileAccepted(null, null)); + } +} + +/// Recovery endpoints with chain, captcha and the private API replaced by fakes. +[Collection("recovery")] +public class RecoveryHandlerTests : IDisposable +{ + private const string RequestId = "3f2b1c9e-8d7a-4b6c-9e5f-1a2b3c4d5e6f"; + private const string Old = RecoveryRulesTests.OldOwnerPub; + private const string New = RecoveryRulesTests.NewOwnerPub; + private const string OtherPub = "STM8ZSw7FmkxEFtkGpo7Gc9Jbs6bHjEu5XfGjNcKqZaHB3wnSXN6W"; + + private readonly List<(string Endpoint, HttpMethod Method, JsonNode? Payload)> _privateApi = new(); + private readonly List _rpc = new(); + private Func _answer = (_, _) => T.JsonResponse(200, "{}"); + private bool _captchaRejected; + private bool _rpcDown; + private string? _failingMethod; + + // chain state + private string _recoveryAccount = "ecency"; + private bool _accountExists = true; + private JsonArray _ownerHistory = new(RecoveryRulesTests.Entry(Old, "2026-11-08T10:00:00")); + private JsonNode? _lastRecovery = "1970-01-01T00:00:00"; + private JsonNode _ownerOps = RecoveryRulesTests.HackedAccountOps(); + private JsonObject _owner = RecoveryRulesTests.Authority(OtherPub); + private readonly List _historyFilters = new(); + private const double OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 41) | (1L << 43); + private const string HeadTime = "2026-11-10T12:00:00"; + + public RecoveryHandlerTests() + { + PrivateApi.RecoveryCaptchaRejected = (_, _) => Task.FromResult(_captchaRejected); + PrivateApi.RecoveryUpstream = (endpoint, method, payload) => + { + _privateApi.Add((endpoint, method, payload?.DeepClone())); + return Task.FromResult(_answer(endpoint, method)); + }; + PrivateApi.RecoveryRpc = (method, @params) => + { + _rpc.Add(method); + if (_rpcDown || method == _failingMethod) throw new Exception("all nodes failed"); + JsonNode? result = method switch + { + "get_accounts" => _accountExists + ? new JsonArray(new JsonObject + { + ["name"] = RecoveryRulesTests.Username, + ["recovery_account"] = _recoveryAccount, + ["owner"] = _owner.DeepClone(), + ["last_account_recovery"] = _lastRecovery?.DeepClone(), + }) + : new JsonArray(), + "get_owner_history" => _ownerHistory.DeepClone(), + "get_account_history" => HistoryFor(@params), + "get_dynamic_global_properties" => new JsonObject { ["time"] = HeadTime }, + _ => throw new InvalidOperationException(method), + }; + return Task.FromResult(result); + }; + } + + public void Dispose() => PrivateApi.ResetRecoverySeams(); + + private JsonNode HistoryFor(JsonArray @params) + { + var filter = JsVal.AsNumber(@params[3]) ?? -1; + _historyFilters.Add(filter); + Assert.Equal(RecoveryRulesTests.Username, @params[0]!.GetValue()); + Assert.Equal(-1, JsVal.AsNumber(@params[1])); + Assert.Equal(1000, JsVal.AsNumber(@params[2])); + return filter switch + { + OwnerOpsFilter => _ownerOps.DeepClone(), + _ => throw new InvalidOperationException("unexpected filter " + filter), + }; + } + + private static Microsoft.AspNetCore.Http.HttpContext StartPost(string json, string ip = "203.0.113.9") + { + var ctx = T.Post("/private-api/recovery/start", json); + if (ip.Length > 0) ctx.Request.Headers["x-real-ip"] = ip; + return ctx; + } + + private static string Body(Microsoft.AspNetCore.Http.HttpContext ctx) => T.Body(ctx); + + private static string ProveBody(string? sig = null, string newPub = New, string proven = Old, + string username = RecoveryRulesTests.Username, string id = RequestId) => + new JsonObject + { + ["request_id"] = id, + ["username"] = username, + ["new_owner_pub"] = newPub, + ["proven_key_pub"] = proven, + ["signature"] = sig ?? RecoveryRulesTests.Signature, + }.ToJsonString(); + + private void RowIs(string status, string username = RecoveryRulesTests.Username, string? newOwnerPub = null) + { + var inner = _answer; + _answer = (endpoint, method) => + { + if (method == HttpMethod.Get && endpoint == $"recovery-request/{RequestId}") + { + var row = new JsonObject + { + ["username"] = username, + ["nonce"] = RecoveryRulesTests.Nonce, + ["status"] = status, + }; + if (newOwnerPub != null) row["new_owner_pub"] = newOwnerPub; + return T.JsonResponse(200, row.ToJsonString()); + } + return inner(endpoint, method); + }; + } + + // ---- start ---- + + [Theory] + [InlineData("""{"captcha_token":"t"}""")] + [InlineData("""{"username":"A","captcha_token":"t"}""")] + [InlineData("""{"username":"bad name!","captcha_token":"t"}""")] + [InlineData("""{"username":12,"captcha_token":"t"}""")] + public async Task StartRejectsABadUsernameBeforeAnyCall(string json) + { + var ctx = StartPost(json); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_username"}""", Body(ctx)); + Assert.Empty(_rpc); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StartRefusesAFailedCaptchaBeforeTheChain() + { + _captchaRejected = true; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(403, ctx.Response.StatusCode); + Assert.Equal("""{"error":"captcha"}""", Body(ctx)); + Assert.Empty(_rpc); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StartUnknownAccount() + { + _accountExists = false; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(404, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_found"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StartOtherRecoveryAccount() + { + _recoveryAccount = "someoneelse"; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_ecency_recovery"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StartWithoutARecentOwnerChange() + { + _ownerHistory = new JsonArray(); + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"no_recent_owner_change"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StartWhenOnlyChangeWasARecovery() + { + _lastRecovery = "2026-11-08T10:00:00"; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"no_recent_owner_change"}""", Body(ctx)); + } + + [Fact] + public async Task StartChainDownIsUnavailable() + { + _rpcDown = true; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StartForwardsUsernameAndIpAndPipes() + { + _answer = (_, _) => T.JsonResponse(200, $$"""{"request_id":"{{RequestId}}","nonce":"{{RecoveryRulesTests.Nonce}}"}"""); + var ctx = StartPost("""{"username":" RecoveryTest ","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_privateApi); + Assert.Equal("recovery-request/start", call.Endpoint); + Assert.Equal(HttpMethod.Post, call.Method); + Assert.Equal("""{"username":"recoverytest","ip":"203.0.113.9"}""", call.Payload!.ToJsonString()); + Assert.Contains("request_id", Body(ctx)); + Assert.DoesNotContain("get_account_history", _rpc); + } + + [Fact] + public async Task StartPipesPrivateApiErrors() + { + _answer = (_, _) => T.JsonResponse(429, """{"error":"rate_limited"}"""); + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(429, ctx.Response.StatusCode); + Assert.Equal("""{"error":"rate_limited"}""", Body(ctx)); + } + + [Fact] + public async Task StartPassesInProgressWithItsRetryTimeThrough() + { + const string answer = """{"error":"in_progress","retry_after":"2026-11-11T10:15:00Z"}"""; + _answer = (_, _) => T.JsonResponse(409, answer); + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal(answer, Body(ctx)); + } + + // ---- prove ---- + + [Theory] + [InlineData("not-a-uuid", New, Old, "0")] + [InlineData(RequestId, "STMbogus", Old, "0")] + [InlineData(RequestId, New, "STMbogus", "0")] + public async Task ProveValidatesInputBeforeAnyCall(string id, string newPub, string proven, string _) + { + var ctx = T.Post("/private-api/recovery/prove", ProveBody(newPub: newPub, proven: proven, id: id)); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); + Assert.Empty(_privateApi); + Assert.Empty(_rpc); + } + + [Fact] + public async Task ProveRejectsNewKeyEqualToProvenKey() + { + var ctx = T.Post("/private-api/recovery/prove", ProveBody(newPub: Old)); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task ProveUnknownRequest() + { + _answer = (_, _) => T.JsonResponse(404, """{"error":"not_found"}"""); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(404, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_found"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Theory] + [InlineData("proven", RecoveryRulesTests.Username)] + [InlineData("started", "someoneelse")] + public async Task ProveNeedsAStartedRowForTheSameUser(string status, string rowUser) + { + RowIs(status, rowUser); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal("""{"error":"bad_state"}""", Body(ctx)); + Assert.Single(_privateApi); + Assert.DoesNotContain(RecoveryRulesTests.Nonce, Body(ctx)); + } + + [Fact] + public async Task ProveWithAWrongSignature() + { + RowIs("started"); + var key = HiveCrypto.FromLogin("x", "y", "owner"); + var sig = HiveCrypto.Sign(key, HiveCrypto.Sha256Utf8("something else")); + var ctx = T.Post("/private-api/recovery/prove", ProveBody(sig: sig)); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_proof"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task ProveWithAKeyFromBeforeTheLastRecovery() + { + RowIs("started"); + _lastRecovery = "2026-11-08T10:00:00"; + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"key_not_recent"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task ProveRejectsNewKeyThatIsACurrentOwnerKey() + { + RowIs("started"); + _owner = RecoveryRulesTests.Authority(New); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task ProveOtherRecoveryAccount() + { + RowIs("started"); + _recoveryAccount = "someoneelse"; + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_ecency_recovery"}""", Body(ctx)); + } + + [Fact] + public async Task ProveForwardsTheValidityAndNeverTheNonce() + { + RowIs("started"); + var inner = _answer; + _answer = (endpoint, method) => method == HttpMethod.Post + ? T.JsonResponse(200, """{"emails":["o****9@gmail.com"]}""") + : inner(endpoint, method); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + Assert.Equal("""{"emails":["o****9@gmail.com"]}""", Body(ctx)); + Assert.Equal(2, _privateApi.Count); + var call = _privateApi[1]; + Assert.Equal($"recovery-request/{RequestId}/prove", call.Endpoint); + Assert.Equal(HttpMethod.Post, call.Method); + Assert.Equal( + $$"""{"new_owner_pub":"{{New}}","proven_key_pub":"{{Old}}","proven_key_valid_until":"2026-11-08T10:00:00","signature":"{{RecoveryRulesTests.Signature}}","owner_since":"2026-02-14T18:30:00"}""", + call.Payload!.ToJsonString()); + Assert.Equal(new[] { OwnerOpsFilter }, _historyFilters.ToArray()); + } + + // ---- verify-code ---- + + [Theory] + [InlineData("""{"request_id":"nope","code":"123456"}""")] + [InlineData("""{"request_id":"3f2b1c9e-8d7a-4b6c-9e5f-1a2b3c4d5e6f","code":"12345"}""")] + [InlineData("""{"request_id":"3f2b1c9e-8d7a-4b6c-9e5f-1a2b3c4d5e6f","code":123456}""")] + public async Task VerifyValidatesInput(string json) + { + var ctx = T.Post("/private-api/recovery/verify-code", json); + await PrivateApi.RecoveryVerifyCode(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task VerifyForwardsTheCode() + { + _answer = (_, _) => T.JsonResponse(400, """{"error":"invalid_code","attempts_left":3}"""); + var ctx = T.Post("/private-api/recovery/verify-code", $$"""{"request_id":"{{RequestId}}","code":"123456"}"""); + await PrivateApi.RecoveryVerifyCode(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_code","attempts_left":3}""", Body(ctx)); + var call = Assert.Single(_privateApi); + Assert.Equal($"recovery-request/{RequestId}/verify", call.Endpoint); + Assert.Equal(HttpMethod.Post, call.Method); + Assert.Equal("""{"code":"123456"}""", call.Payload!.ToJsonString()); + } + + // ---- status ---- + + [Fact] + public async Task StatusValidatesInput() + { + var ctx = T.Get("/private-api/recovery/status", "request_id=../x"); + await PrivateApi.RecoveryStatus(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task StatusForwardsTheSafeRoute() + { + _answer = (_, _) => T.JsonResponse(200, """{"status":"requested","tx_id":"ab","error":null}"""); + var ctx = T.Get("/private-api/recovery/status", $"request_id={RequestId}"); + await PrivateApi.RecoveryStatus(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_privateApi); + Assert.Equal($"recovery-request/{RequestId}/status", call.Endpoint); + Assert.Equal(HttpMethod.Get, call.Method); + } + + // ---- complete ---- + + [Fact] + public async Task CompleteNeedsARequestedRow() + { + RowIs("approved", newOwnerPub: New); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal("""{"error":"bad_state"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task CompleteNeedsTheOwnerToBeTheNewKeyOnly() + { + RowIs("requested", newOwnerPub: New); + _owner = RecoveryRulesTests.Authority(New); + ((JsonArray)_owner["key_auths"]!).Add(new JsonArray(OtherPub, 1)); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_recovered"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(3, 3)] + public async Task CompleteForwards(int weight, int threshold) + { + RowIs("requested", newOwnerPub: New); + _owner = RecoveryRulesTests.Authority(New, weight, threshold); + var inner = _answer; + _answer = (endpoint, method) => method == HttpMethod.Post + ? T.JsonResponse(200, """{"status":"completed"}""") + : inner(endpoint, method); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + Assert.Equal("""{"status":"completed"}""", Body(ctx)); + Assert.Equal($"recovery-request/{RequestId}/complete", _privateApi[1].Endpoint); + } + + // ---- round 2 ---- + + [Fact] + public async Task StartWithoutAClientIpIsRefused() + { + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}""", ip: ""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); + Assert.Empty(_rpc); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task ProveOnAnExpiredRowIsNotFound() + { + RowIs("expired"); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(404, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_found"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task CompleteOnAnExpiredRowIsNotFound() + { + RowIs("expired", newOwnerPub: New); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(404, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_found"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task CompleteOnAnUnknownRowIsNotFound() + { + _answer = (_, _) => T.JsonResponse(404, """{"error":"not_found"}"""); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(404, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_found"}""", Body(ctx)); + } + + [Fact] + public async Task StartWithAnUnreadableLastRecoveryIsUnavailable() + { + _lastRecovery = "not a time"; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task ProveWithAnUnreadableLastRecoveryIsUnavailable() + { + RowIs("started"); + _lastRecovery = null; + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Single(_privateApi); + } + + private async Task ProveExpectingOwnerSince(string? expected) + { + RowIs("started"); + var inner = _answer; + _answer = (endpoint, method) => method == HttpMethod.Post + ? T.JsonResponse(200, """{"emails":["o****9@gmail.com"]}""") + : inner(endpoint, method); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var payload = Assert.IsType(_privateApi[1].Payload); + Assert.True(payload.ContainsKey("owner_since")); + Assert.Equal(expected, payload["owner_since"]?.GetValue()); + Assert.Equal("2026-11-08T10:00:00", payload["proven_key_valid_until"]!.GetValue()); + return payload; + } + + [Fact] + public async Task ProveSendsANullOwnerSinceWhenOwnerOpsAreUnreadable() + { + _ownerOps = new JsonArray(RecoveryRulesTests.Op(1, "2026-01-01T00:00:00", "transfer", new JsonObject())); + await ProveExpectingOwnerSince(null); + } + + [Fact] + public async Task ProveSendsANullOwnerSinceWhenTheHistoryReadFails() + { + // A long history can end the filtered scan with an error instead of an answer. + _failingMethod = "get_account_history"; + await ProveExpectingOwnerSince(null); + } + + [Fact] + public async Task ProveIgnoresAnAccountTheUserCreatedForOwnerSince() + { + _ownerOps = new JsonArray(RecoveryRulesTests.Op(5, "2026-03-01T00:00:00", "create_claimed_account", + new JsonObject + { + ["creator"] = RecoveryRulesTests.Username, ["new_account_name"] = "childaccount", + ["owner"] = RecoveryRulesTests.Authority(Old), + })); + await ProveExpectingOwnerSince(null); + } + + [Fact] + public async Task ProveForwardsANullOwnerSinceWhenNoOpSetTheKey() + { + RowIs("started"); + _ownerOps = new JsonArray(); + var inner = _answer; + _answer = (endpoint, method) => method == HttpMethod.Post + ? T.JsonResponse(200, """{"emails":[]}""") + : inner(endpoint, method); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var payload = Assert.IsType(_privateApi[1].Payload); + Assert.True(payload.ContainsKey("owner_since")); + Assert.Null(payload["owner_since"]); + Assert.Equal(RecoveryRulesTests.Signature, payload["signature"]!.GetValue()); + } + + [Theory] + [InlineData(1, 2)] + [InlineData(2, 3)] + [InlineData(0, 1)] + public async Task CompleteNeedsTheNewKeyToMeetTheThreshold(int weight, int threshold) + { + RowIs("requested", newOwnerPub: New); + _owner = RecoveryRulesTests.Authority(New, weight, threshold); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_recovered"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + // ---- round 3 ---- + + [Fact] + public async Task StartWhenNoKeyCanSatisfyTheOldOwnerAlone() + { + _ownerHistory = new JsonArray(RecoveryRulesTests.MultiKeyEntry("2026-11-08T10:00:00", 2, (Old, 1), (OtherPub, 1))); + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"key_not_sufficient"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task ProveWithAPartialWeightKey() + { + RowIs("started"); + _ownerHistory = new JsonArray( + RecoveryRulesTests.MultiKeyEntry("2026-11-08T10:00:00", 2, (Old, 1), (OtherPub, 2))); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"key_not_sufficient"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task ProveWithAKeyWeightEqualToTheThreshold() + { + _ownerHistory = new JsonArray(RecoveryRulesTests.Entry(Old, "2026-11-08T10:00:00", weight: 3, threshold: 3)); + await ProveExpectingOwnerSince("2026-02-14T18:30:00"); + } + + [Fact] + public async Task StartWithAnUnreadableOwnerHistoryEntryIsUnavailable() + { + _ownerHistory = new JsonArray( + RecoveryRulesTests.Entry(Old, "2026-11-08T10:00:00"), + RecoveryRulesTests.Entry(OtherPub, "garbled")); + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Equal("5", ctx.Response.Headers.RetryAfter.ToString()); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task ProveWithAnUnreadableOwnerHistoryEntryIsUnavailable() + { + RowIs("started"); + var bad = RecoveryRulesTests.Entry(Old, "2026-11-08T10:00:00"); + bad["previous_owner_authority"]!["weight_threshold"] = "one"; + _ownerHistory = new JsonArray(bad); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Single(_privateApi); + } + + [Theory] + [InlineData(1_000_000)] + [InlineData(128)] + [InlineData(132)] + public async Task ProveRefusesASignatureOfTheWrongLengthBeforeAnyCall(int length) + { + RowIs("started"); + var ctx = T.Post("/private-api/recovery/prove", ProveBody(sig: new string('a', length))); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_proof"}""", Body(ctx)); + Assert.Empty(_privateApi); + Assert.Empty(_rpc); + } + + [Fact] + public async Task ProveRefusesOversizedFieldsBeforeAnyCall() + { + var huge = "STM" + new string('1', 1_000_000); + foreach (var body in new[] { ProveBody(newPub: huge), ProveBody(proven: huge), ProveBody(id: RequestId + huge) }) + { + var ctx = T.Post("/private-api/recovery/prove", body); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); + } + Assert.Empty(_privateApi); + Assert.Empty(_rpc); + } + + [Fact] + public async Task CompleteRefusesAnOwnerWithAnAccountAuth() + { + RowIs("requested", newOwnerPub: New); + _owner = RecoveryRulesTests.Authority(New); + ((JsonArray)_owner["account_auths"]!).Add(new JsonArray("someapp", 1)); + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_recovered"}""", Body(ctx)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task CompleteChainDownIsUnavailable() + { + RowIs("requested", newOwnerPub: New); + _rpcDown = true; + var ctx = T.Post("/private-api/recovery/complete", $$"""{"request_id":"{{RequestId}}"}"""); + await PrivateApi.RecoveryComplete(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Single(_privateApi); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs index 2714583d..54d70de2 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs @@ -65,8 +65,10 @@ public static string SignupClientIp(HttpContext ctx) /// account-create and paid-account-create routes. Returns true ONLY on a confirmed-human /// token; fails CLOSED on a missing secret/token, provider error, or rejection (a network /// blip must never open the gate). The secret is server-side only (config.turnstileSecret). + /// When expectedAction is given, the token must also have been issued for that widget + /// action, so a token solved on one page cannot be spent on another. /// - private static async Task VerifyTurnstile(JsonNode? token, string ip) + private static async Task VerifyTurnstile(JsonNode? token, string ip, string? expectedAction = null) { var secret = Config.TurnstileSecret; var tokenStr = token is JsonValue tv && JsVal.TryGetStringLenient(tv, out string? ts) ? ts : null; @@ -87,12 +89,7 @@ private static async Task VerifyTurnstile(JsonNode? token, string ip) form.Add(new("remoteip", ip)); } - var data = await PostTurnstileForm(form); - return data is JsonObject obj - && obj.TryGetPropertyValue("success", out var success) - && success is JsonValue sv - && sv.TryGetValue(out bool b) - && b; + return TurnstileAccepted(await PostTurnstileForm(form), expectedAction); } catch (Exception err) { @@ -101,6 +98,29 @@ private static async Task VerifyTurnstile(JsonNode? token, string ip) } } + /// + /// A siteverify answer that confirms a human: success true and, when expectedAction is + /// given, the same action the widget was rendered with. + /// + internal static bool TurnstileAccepted(JsonNode? data, string? expectedAction) + { + if (data is not JsonObject obj + || !obj.TryGetPropertyValue("success", out var success) + || success is not JsonValue sv + || !sv.TryGetValue(out bool b) + || !b) + { + return false; + } + if (expectedAction == null) + { + return true; + } + return obj["action"] is JsonValue av + && JsVal.TryGetStringLenient(av, out var action) + && action == expectedAction; + } + /// /// Documented exception to the shared-infra rule: the TS uses a bare axios.post /// with a form-encoded body (baseApiRequest always sends JSON), so this posts the @@ -158,13 +178,13 @@ private static async Task VerifyTurnstile(JsonNode? token, string ip) /// Enforced for every request by default (config.captchaMode "hard"); an operator can set /// "off" as an emergency break-glass, since verification otherwise fails closed. /// - private static async Task AccountCaptchaRejected(JsonNode? token, string ip) + private static async Task AccountCaptchaRejected(JsonNode? token, string ip, string? expectedAction = null) { if (Config.CaptchaMode == "off") { return false; } - return !(await VerifyTurnstile(token, ip)); + return !(await VerifyTurnstile(token, ip, expectedAction)); } public static async Task CreateAccount(HttpContext ctx) diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs new file mode 100644 index 00000000..8d049127 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs @@ -0,0 +1,411 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Self-serve account recovery. The request state lives in the private API; this layer +/// gates each step on the chain (recovery account, owner history, the proven key) and +/// verifies the owner-key proof, so nothing reaches the private API that the chain +/// would not accept. Internal row fields (the nonce in particular) are read here and +/// never returned to the client. +/// +public static partial class PrivateApi +{ + private const string RecoveryAccountName = "ecency"; + private const string RecoveryCaptchaAction = "recover"; + + // Seams for tests: the private API, the chain and the captcha check. + internal static Func> RecoveryUpstream = DefaultRecoveryUpstream; + internal static Func> RecoveryRpc = DefaultRecoveryRpc; + internal static Func> RecoveryCaptchaRejected = DefaultRecoveryCaptchaRejected; + + internal static void ResetRecoverySeams() + { + RecoveryUpstream = DefaultRecoveryUpstream; + RecoveryRpc = DefaultRecoveryRpc; + RecoveryCaptchaRejected = DefaultRecoveryCaptchaRejected; + } + + private static Task DefaultRecoveryUpstream(string endpoint, HttpMethod method, JsonNode? payload) => + ApiClient.ApiRequest(endpoint, method, null, payload); + + private static Task DefaultRecoveryCaptchaRejected(JsonNode? token, string ip) => + AccountCaptchaRejected(token, ip, RecoveryCaptchaAction); + + private static Task DefaultRecoveryRpc(string method, JsonArray @params) => + HiveClients.Default.Call("condenser_api", method, @params, method switch + { + "get_accounts" => HiveRpcClient.IsAccountArray, + "get_dynamic_global_properties" => r => r is JsonObject, + _ => r => r is JsonArray, + }); + + /// The account and the owner-change facts the recovery rules need. + private sealed record RecoveryChainState(JsonObject? Account, List OwnerHistory, + DateTime? LastRecovery, DateTime HeadTime); + + private static async Task ReadRecoveryChain(string username) + { + var account = RecoveryRead("get_accounts", new JsonArray(new JsonArray(username)), username, r => + { + var found = r is JsonArray list && list.Count > 0 ? list[0] as JsonObject : null; + // The latest recovery comes with the account; a field that cannot be read is a read failure. + return (Account: found, LastRecovery: found == null ? null : RecoveryRules.LastAccountRecovery(found)); + }); + var ownerHistory = RecoveryRead("get_owner_history", new JsonArray(username), username, + RecoveryRules.ReadOwnerHistory); + var head = RecoveryRead("get_dynamic_global_properties", new JsonArray(), username, + r => RecoveryRules.ParseChainTime(JsVal.Prop(r, "time")) ?? throw new FormatException("head block time missing")); + await Task.WhenAll(account, ownerHistory, head); + + return new RecoveryChainState(account.Result.Account, ownerHistory.Result, account.Result.LastRecovery, + head.Result); + } + + /// + /// One chain read and the reading of its answer. A failure of either is logged (the method + /// and the cause only) and rethrown. + /// + private static async Task RecoveryRead(string method, JsonArray @params, string username, + Func read) + { + try + { + return read(await RecoveryRpc(method, @params)); + } + catch (Exception e) + { + var (key, line) = RecoveryReadFailureLog(method, e); + LogRecoveryFailure(key, line); + throw; + } + } + + /// + /// The log line for a failed recovery chain read, and the key it is written once under: + /// the method, the exception type and a fixed category. Never the message, which can carry + /// node-supplied text. + /// + internal static (string Key, string Line) RecoveryReadFailureLog(string method, Exception e) + { + var type = e.GetType().Name; + var category = e switch + { + OperationCanceledException or TimeoutException => "timeout", + HttpRequestException => "http_error", + // The RPC client's own node failure type (unreachable, bad status, bad body). + _ when type == "NodeUnavailableException" => "http_error", + HiveRpcClient.RpcException => "rpc_error", + FormatException or System.Text.Json.JsonException => "parse_error", + _ => "other", + }; + return ($"recovery {method} [{type}]", $"warn: recovery chain read failed: {method} ({type}, {category})"); + } + + // Written once per method and cause type, at most a few lines per process: a node outage + // fails every request the same way and must not log per request. + private const int MaxRecoveryLogLines = 16; + private static int _recoveryLogLines; + private static readonly ConcurrentDictionary RecoveryLogSeen = new(); + + private static void LogRecoveryFailure(string key, string line) + { + if (Volatile.Read(ref _recoveryLogLines) >= MaxRecoveryLogLines || !RecoveryLogSeen.TryAdd(key, 0)) return; + if (Interlocked.Increment(ref _recoveryLogLines) > MaxRecoveryLogLines) return; + Console.Error.WriteLine(line); + } + + private static Task SendRecoveryError(HttpContext ctx, int status, string error) => + ctx.SendJson(status, new JsonObject { ["error"] = error }); + + private static async Task SendRecoveryUnavailable(HttpContext ctx) + { + ctx.Response.Headers.RetryAfter = ServerErrors.RetryAfterSeconds.ToString(); + await SendRecoveryError(ctx, 503, "unavailable"); + } + + /// Chain read for a recovery step; answers 503 and returns null when it fails. + private static async Task TryReadRecoveryChain(HttpContext ctx, string username) + { + try + { + return await ReadRecoveryChain(username); + } + catch (Exception) + { + // Already logged by RecoveryRead. + await SendRecoveryUnavailable(ctx); + return null; + } + } + + private static bool IsEcencyRecovered(JsonObject account) => + JsVal.Prop(account, "recovery_account") is JsonValue rv + && JsVal.TryGetStringLenient(rv, out var ra) + && ra == RecoveryAccountName; + + /// + /// The internal row for a request. Returns null after answering the client: 404 when + /// the private API does not know it, the private API's own answer for any other + /// non-200, and 504/500 like Pipe on a transport failure. + /// + private static async Task FetchRecoveryRow(HttpContext ctx, string requestId) + { + UpstreamResponse resp; + try + { + resp = await RecoveryUpstream($"recovery-request/{requestId}", HttpMethod.Get, null); + } + catch (Exception e) + { + await Upstream.Pipe(Task.FromException(e), ctx); + return null; + } + + // An expired row is gone as far as the client is concerned. + if (resp.Status == 404 || (resp.Status == 200 && resp.Json is JsonObject r && r.Str("status") == "expired")) + { + await SendRecoveryError(ctx, 404, "not_found"); + return null; + } + if (resp.Status != 200 || resp.Json is not JsonObject row) + { + await Upstream.SendLikeExpress(ctx, resp.Status, resp.Json, resp.RawText); + return null; + } + return row; + } + + private static string? NormalizedUsername(JsonObject body) + { + var username = body.Str("username")?.Trim().ToLowerInvariant(); + return HiveNames.IsAccountName(username) ? username : null; + } + + public static async Task RecoveryStart(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = NormalizedUsername(body); + if (username == null) + { + await SendRecoveryError(ctx, 400, "invalid_username"); + return; + } + + // The private API limits requests per client address, so one is always required. + var ip = SignupClientIp(ctx); + if (ip.Length == 0) + { + await SendRecoveryError(ctx, 400, "invalid_request"); + return; + } + + // Captcha before any chain read, so unsolved requests cost nothing upstream. + if (await RecoveryCaptchaRejected(body.Field("captcha_token"), ip)) + { + await SendRecoveryError(ctx, 403, "captcha"); + return; + } + + var chain = await TryReadRecoveryChain(ctx, username); + if (chain == null) + { + return; + } + if (chain.Account == null) + { + await SendRecoveryError(ctx, 404, "not_found"); + return; + } + if (!IsEcencyRecovered(chain.Account)) + { + await SendRecoveryError(ctx, 422, "not_ecency_recovery"); + return; + } + switch (RecoveryRules.CheckOwnerHistory(chain.OwnerHistory, chain.HeadTime, chain.LastRecovery)) + { + case RecoveryRules.Verdict.NoRecentOwnerChange: + await SendRecoveryError(ctx, 422, "no_recent_owner_change"); + return; + case RecoveryRules.Verdict.KeyNotSufficient: + await SendRecoveryError(ctx, 422, "key_not_sufficient"); + return; + } + + var payload = new JsonObject { ["username"] = username, ["ip"] = ip }; + await Upstream.Pipe(RecoveryUpstream("recovery-request/start", HttpMethod.Post, payload), ctx); + } + + public static async Task RecoveryProve(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var requestId = body.Str("request_id"); + var username = NormalizedUsername(body); + var newOwnerPub = body.Str("new_owner_pub"); + var provenKeyPub = body.Str("proven_key_pub"); + var signature = body.Str("signature"); + + if (!RecoveryRules.IsRequestId(requestId) || username == null + || !HiveCrypto.IsPublicKey(newOwnerPub) || !HiveCrypto.IsPublicKey(provenKeyPub) + || newOwnerPub == provenKeyPub) + { + await SendRecoveryError(ctx, 400, "invalid_request"); + return; + } + // Checked before any other call; a proof of another length is never decoded. + if (signature is not { Length: HiveCrypto.SignatureHexLength }) + { + await SendRecoveryError(ctx, 400, "invalid_proof"); + return; + } + + var row = await FetchRecoveryRow(ctx, requestId!); + if (row == null) + { + return; + } + var nonce = row.Str("nonce"); + if (row.Str("status") != "started" || row.Str("username") != username || string.IsNullOrEmpty(nonce)) + { + await SendRecoveryError(ctx, 409, "bad_state"); + return; + } + + if (!RecoveryRules.ProofMatches(username, newOwnerPub!, nonce, signature, provenKeyPub!)) + { + await SendRecoveryError(ctx, 400, "invalid_proof"); + return; + } + + var chain = await TryReadRecoveryChain(ctx, username); + if (chain == null) + { + return; + } + if (chain.Account == null) + { + await SendRecoveryError(ctx, 404, "not_found"); + return; + } + if (!IsEcencyRecovered(chain.Account)) + { + await SendRecoveryError(ctx, 422, "not_ecency_recovery"); + return; + } + if (RecoveryRules.AuthorityKeys(JsVal.Prop(chain.Account, "owner")).Contains(newOwnerPub!)) + { + await SendRecoveryError(ctx, 400, "invalid_request"); + return; + } + + var (verdict, validUntil) = RecoveryRules.CheckProvenKey( + chain.OwnerHistory, provenKeyPub!, chain.HeadTime, chain.LastRecovery); + if (verdict == RecoveryRules.Verdict.KeyNotSufficient) + { + await SendRecoveryError(ctx, 422, "key_not_sufficient"); + return; + } + if (validUntil == null) + { + await SendRecoveryError(ctx, 422, "key_not_recent"); + return; + } + + // Only narrows which saved emails may be used, so a failed or unreadable read sends + // null (signup email only) instead of failing the request. The filtered read scans a + // bounded slice of recent history; an older key-setting op is simply not found. + DateTime? ownerSince; + try + { + ownerSince = await RecoveryRead("get_account_history", + new JsonArray(username, -1, 1000, RecoveryRules.OwnerOpsFilter, 0), username, + r => RecoveryRules.OwnerSince(r, username, provenKeyPub!, validUntil.Value)); + } + catch (Exception) + { + ownerSince = null; + } + + var payload = new JsonObject + { + ["new_owner_pub"] = newOwnerPub, + ["proven_key_pub"] = provenKeyPub, + ["proven_key_valid_until"] = RecoveryRules.FormatChainTime(validUntil.Value), + ["signature"] = signature, + ["owner_since"] = ownerSince == null ? null : RecoveryRules.FormatChainTime(ownerSince.Value), + }; + await Upstream.Pipe(RecoveryUpstream($"recovery-request/{requestId}/prove", HttpMethod.Post, payload), ctx); + } + + public static async Task RecoveryVerifyCode(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var requestId = body.Str("request_id"); + var code = body.Field("code") is JsonValue cv && JsVal.TryGetStringLenient(cv, out var c) ? c : null; + if (!RecoveryRules.IsRequestId(requestId) || !RecoveryRules.IsCode(code)) + { + await SendRecoveryError(ctx, 400, "invalid_request"); + return; + } + + var payload = new JsonObject { ["code"] = code }; + await Upstream.Pipe(RecoveryUpstream($"recovery-request/{requestId}/verify", HttpMethod.Post, payload), ctx); + } + + public static async Task RecoveryStatus(HttpContext ctx) + { + var requestId = ctx.Request.Query["request_id"].ToString(); + if (!RecoveryRules.IsRequestId(requestId)) + { + await SendRecoveryError(ctx, 400, "invalid_request"); + return; + } + + await Upstream.Pipe(RecoveryUpstream($"recovery-request/{requestId}/status", HttpMethod.Get, null), ctx); + } + + public static async Task RecoveryComplete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var requestId = body.Str("request_id"); + if (!RecoveryRules.IsRequestId(requestId)) + { + await SendRecoveryError(ctx, 400, "invalid_request"); + return; + } + + var row = await FetchRecoveryRow(ctx, requestId!); + if (row == null) + { + return; + } + var username = row.Str("username"); + var newOwnerPub = row.Str("new_owner_pub"); + if (row.Str("status") != "requested" || !HiveNames.IsAccountName(username) || string.IsNullOrEmpty(newOwnerPub)) + { + await SendRecoveryError(ctx, 409, "bad_state"); + return; + } + + JsonNode? account; + try + { + account = await RecoveryRead("get_accounts", new JsonArray(new JsonArray(username)), username!, + r => r is JsonArray list && list.Count > 0 ? list[0] : null); + } + catch (Exception) + { + await SendRecoveryUnavailable(ctx); + return; + } + if (!RecoveryRules.IsRecoveredOwner(JsVal.Prop(account, "owner"), newOwnerPub)) + { + await SendRecoveryError(ctx, 409, "not_recovered"); + return; + } + + await Upstream.Pipe(RecoveryUpstream($"recovery-request/{requestId}/complete", HttpMethod.Post, null), ctx); + } +} diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 54b401dd..f684855b 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -205,6 +205,13 @@ public static void Map(WebApplication app) app.MapPost("/private-api/curation-desk/application-vote", PrivateApi.CurationDeskApplicationVote); app.MapPost("/private-api/curation-desk/application-window", PrivateApi.CurationDeskApplicationWindow); + // ---- Self-serve account recovery (see PrivateApi.Recovery.cs) ---- + app.MapPost("/private-api/recovery/start", PrivateApi.RecoveryStart); + app.MapPost("/private-api/recovery/prove", PrivateApi.RecoveryProve); + app.MapPost("/private-api/recovery/verify-code", PrivateApi.RecoveryVerifyCode); + app.MapGet("/private-api/recovery/status", PrivateApi.RecoveryStatus); + app.MapPost("/private-api/recovery/complete", PrivateApi.RecoveryComplete); + // ---- SSR RPC cache (internal, header-gated; see SsrRpc.cs) ---- app.MapPost("/private-api/ssr/rpc", SsrRpc.Rpc); app.MapGet("/private-api/ssr/stats", SsrRpc.Stats); diff --git a/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs b/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs index d8adeff9..9e74b46d 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs @@ -122,6 +122,9 @@ private static bool IsCanonical(ReadOnlySpan c) => // ---- recovery ------------------------------------------------------- + /// A compact recoverable signature: 65 bytes as hex. + public const int SignatureHexLength = 130; + /// /// dhive Signature.fromString(sig).recover(digest).toString(): recover the /// signer's public key from a 65-byte hex signature. Returns null when the @@ -129,6 +132,12 @@ private static bool IsCanonical(ReadOnlySpan c) => /// public static string? RecoverPublicKey(string signatureHex, ReadOnlySpan digest32) { + // Length first, so an oversized input is never decoded. + if (signatureHex.Length != SignatureHexLength) + { + return null; + } + byte[] raw; try { @@ -164,6 +173,65 @@ private static bool IsCanonical(ReadOnlySpan c) => return PublicKeyToString(pubKey); } + /// + /// True for a well-formed STM public key: base58 of a 33-byte compressed point on the + /// curve followed by the first 4 bytes of its ripemd160 checksum. + /// + public static bool IsPublicKey(string? key) + { + // 37 bytes are at most 51 base58 characters; longer input is never decoded. + if (key == null || key.Length > PublicKeyPrefix.Length + 51 + || !key.StartsWith(PublicKeyPrefix, StringComparison.Ordinal)) + { + return false; + } + + var raw = Base58Decode(key.AsSpan(PublicKeyPrefix.Length)); + if (raw == null || raw.Length != 37) + { + return false; + } + + var checksum = Ripemd160(raw.AsSpan(0, 33)); + if (!checksum.AsSpan(0, 4).SequenceEqual(raw.AsSpan(33, 4))) + { + return false; + } + + return ECPubKey.TryCreate(raw.AsSpan(0, 33), Context.Instance, out _, out var pub) && pub != null; + } + + /// Base58 decode; null on a character outside the alphabet. + private static byte[]? Base58Decode(ReadOnlySpan text) + { + if (text.Length == 0 || text.Length > 128) + { + return null; + } + + var value = System.Numerics.BigInteger.Zero; + foreach (var c in text) + { + var digit = Base58Alphabet.IndexOf(c); + if (digit < 0) + { + return null; + } + value = value * 58 + digit; + } + + var leadingZeros = 0; + while (leadingZeros < text.Length && text[leadingZeros] == Base58Alphabet[0]) + { + leadingZeros++; + } + + var body = value.IsZero ? Array.Empty() : value.ToByteArray(isUnsigned: true, isBigEndian: true); + var result = new byte[leadingZeros + body.Length]; + body.CopyTo(result, leadingZeros); + return result; + } + // ---- hashing / encoding ---------------------------------------------- public static byte[] Sha256Utf8(string message) => diff --git a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs new file mode 100644 index 00000000..155ee901 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs @@ -0,0 +1,346 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace EcencyApi.Infrastructure; + +/// +/// Pure rules for self-serve account recovery, kept free of network access so they +/// can be tested directly. A proven owner key is usable when it appears in the +/// account's owner history, its replacement is still inside the chain's 30-day +/// recovery window, it was replaced after the account's most recent recovery, and it +/// meets that old authority's threshold on its own. +/// +public static partial class RecoveryRules +{ + /// The chain's owner-authority recovery window. + public static readonly TimeSpan RecoveryWindow = TimeSpan.FromDays(30); + + /// The chain's timestamp format (UTC, no zone suffix). + public const string ChainTimeFormat = "yyyy-MM-ddTHH:mm:ss"; + + [GeneratedRegex(@"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z")] + private static partial Regex RequestIdPattern(); + + [GeneratedRegex(@"\A[0-9]{6}\z")] + private static partial Regex CodePattern(); + + public static bool IsRequestId(string? id) => id is { Length: 36 } && RequestIdPattern().IsMatch(id); + + public static bool IsCode(string? code) => code is { Length: 6 } && CodePattern().IsMatch(code); + + /// The message the proven key signs; binds the account, the new key and the request. + public static string Challenge(string username, string newOwnerPub, string nonce) => + $"ecency-recovery:{username}:{newOwnerPub}:{nonce}"; + + /// True when the signature over the challenge recovers to the proven key. + public static bool ProofMatches(string username, string newOwnerPub, string nonce, string signature, + string provenKeyPub) + { + var digest = HiveCrypto.Sha256Utf8(Challenge(username, newOwnerPub, nonce)); + var recovered = HiveCrypto.RecoverPublicKey(signature, digest); + return recovered != null && string.Equals(recovered, provenKeyPub, StringComparison.Ordinal); + } + + public static DateTime? ParseChainTime(JsonNode? node) + { + if (node is not JsonValue v || !JsVal.TryGetStringLenient(v, out var s)) + { + return null; + } + if (s.EndsWith('Z')) + { + s = s[..^1]; + } + return DateTime.TryParseExact(s, ChainTimeFormat, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var t) + ? t + : null; + } + + public static string FormatChainTime(DateTime t) => + t.ToString(ChainTimeFormat, CultureInfo.InvariantCulture); + + /// Keys listed in an authority object's key_auths. + public static List AuthorityKeys(JsonNode? authority) + { + var keys = new List(); + if (JsVal.Prop(authority, "key_auths") is not JsonArray auths) + { + return keys; + } + foreach (var auth in auths) + { + if (auth is JsonArray pair && pair.Count > 0 && pair[0] is JsonValue kv + && JsVal.TryGetStringLenient(kv, out var key)) + { + keys.Add(key); + } + } + return keys; + } + + /// + /// True when the authority is the shape a finished recovery leaves: exactly one key, the + /// given one, whose weight alone meets the threshold, and no account auths. + /// + public static bool IsRecoveredOwner(JsonNode? authority, string key) + { + if (JsVal.Prop(authority, "account_auths") is not JsonArray { Count: 0 } + || JsVal.AsNumber(JsVal.Prop(authority, "weight_threshold")) is not { } threshold || threshold < 1 + || JsVal.Prop(authority, "key_auths") is not JsonArray { Count: 1 } auths) + { + return false; + } + return auths[0] is JsonArray { Count: 2 } pair + && pair[0] is JsonValue kv && JsVal.TryGetStringLenient(kv, out var k) + && string.Equals(k, key, StringComparison.Ordinal) + && JsVal.AsNumber(pair[1]) is { } weight && weight >= threshold; + } + + /// + /// The account's latest recovery from its last_account_recovery field, or null when it was + /// never recovered (the chain's 1970 placeholder). Throws when the field cannot be read. + /// + public static DateTime? LastAccountRecovery(JsonNode? account) + { + var t = ParseChainTime(JsVal.Prop(account, "last_account_recovery")) + ?? throw new FormatException("unreadable last_account_recovery"); + return t <= DateTime.UnixEpoch ? null : t; + } + + /// + /// One item of an account-history answer: [index, {timestamp, op: [name, payload]}]. + /// Throws on anything else. The answers read here decide whether a key may be used, so an + /// item that cannot be read must fail the request rather than be skipped. + /// + private static (DateTime Time, string Name, JsonObject Payload) ReadHistoryItem(JsonNode? item) + { + var entry = item is JsonArray pair && pair.Count > 1 ? pair[1] : null; + if (JsVal.Prop(entry, "op") is not JsonArray op || op.Count != 2 + || op[0] is not JsonValue nv || !JsVal.TryGetStringLenient(nv, out var name) + || op[1] is not JsonObject payload) + { + throw new FormatException("unreadable account history item"); + } + var time = ParseChainTime(JsVal.Prop(entry, "timestamp")) + ?? throw new FormatException("unreadable account history timestamp"); + const string suffix = "_operation"; + if (name.EndsWith(suffix, StringComparison.Ordinal)) + { + name = name[..^suffix.Length]; + } + return (time, name, payload); + } + + private static JsonArray HistoryItems(JsonNode? history) => + history as JsonArray ?? throw new FormatException("account history is not a list"); + + /// + /// Operation ids account_create 9, account_update 10, create_claimed_account 23, + /// recover_account 25, account_create_with_delegation 41, account_update2 43 (all in the + /// low filter word). + /// + public const long OwnerOpsFilter = + (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 41) | (1L << 43); + + /// + /// When the proven key most recently entered the account's owner authority, from an + /// answer filtered to : the owner-setting ops of this account + /// before validUntil are walked in order, and the key's span starts where it is present + /// and the previous owner did not hold it. A later update that keeps the key does not move + /// it. Null when the key is not an owner key at validUntil, or when the answer does not + /// reach back to where the span started (the earliest op seen already holds the key and + /// is not the account's creation). Throws when the answer cannot be read. + /// + public static DateTime? OwnerSince(JsonNode? history, string username, string provenKeyPub, DateTime validUntil) + { + var changes = new List<(DateTime Time, bool Creation, bool HoldsKey)>(); + foreach (var item in HistoryItems(history)) + { + var (time, name, payload) = ReadHistoryItem(item); + var (subject, field, required) = name switch + { + "account_create" or "create_claimed_account" or "account_create_with_delegation" => + ("new_account_name", "owner", true), + "account_update" or "account_update2" => ("account", "owner", false), + "recover_account" => ("account_to_recover", "new_owner_authority", true), + _ => throw new FormatException("unexpected operation in filtered history"), + }; + // An account's history also holds the accounts it created. + if (StringField(payload, subject) != username) + { + continue; + } + var authority = JsVal.Prop(payload, field); + if (authority == null && !required) + { + continue; + } + if (authority is not JsonObject || JsVal.Prop(authority, "key_auths") is not JsonArray) + { + throw new FormatException("unreadable owner authority"); + } + if (time < validUntil) + { + changes.Add((time, required && field == "owner", AuthorityKeys(authority).Contains(provenKeyPub))); + } + } + + // null start inside an open span means it began before the answer reaches back. + var open = false; + DateTime? start = null; + bool? previousHeldKey = null; + foreach (var change in changes.OrderBy(c => c.Time)) + { + if (!change.HoldsKey) + { + open = false; + start = null; + } + else if (!open) + { + open = true; + start = change.Creation || previousHeldKey == false ? change.Time : null; + } + previousHeldKey = change.HoldsKey; + } + return open ? start : null; + } + + private static string StringField(JsonObject payload, string name) => + JsVal.Prop(payload, name) is JsonValue v && JsVal.TryGetStringLenient(v, out var s) + ? s + : throw new FormatException("unreadable account name in history"); + + /// An owner authority as the recovery rules need it. + public sealed record OwnerAuthority(double Threshold, IReadOnlyList<(string Key, double Weight)> Keys) + { + /// The key's weight, or null when the authority does not list it. + public double? WeightOf(string key) + { + foreach (var (k, w) in Keys) + { + if (string.Equals(k, key, StringComparison.Ordinal)) + { + return w; + } + } + return null; + } + + /// True when the key alone meets the threshold. + public bool IsSatisfiedBy(string key) => WeightOf(key) is { } w && w >= Threshold; + + public bool HasSufficientKey => Keys.Any(k => IsSatisfiedBy(k.Key)); + } + + /// One owner_history entry: the authority that was replaced and until when it was valid. + public sealed record OwnerHistoryEntry(DateTime LastValid, OwnerAuthority Previous); + + /// + /// A get_owner_history answer. Throws on anything unreadable: a skipped entry could turn a + /// read problem into a refusal. + /// + public static List ReadOwnerHistory(JsonNode? history) + { + var list = new List(); + if (history is not JsonArray entries) + { + throw new FormatException("owner history is not a list"); + } + foreach (var entry in entries) + { + var lastValid = ParseChainTime(JsVal.Prop(entry, "last_valid_time")) + ?? throw new FormatException("unreadable owner history time"); + list.Add(new OwnerHistoryEntry(lastValid, ReadAuthority(JsVal.Prop(entry, "previous_owner_authority")))); + } + return list; + } + + private static OwnerAuthority ReadAuthority(JsonNode? authority) + { + if (JsVal.Prop(authority, "weight_threshold") is not JsonValue tv || !IsWeight(JsVal.AsNumber(tv)) + || JsVal.Prop(authority, "key_auths") is not JsonArray auths) + { + throw new FormatException("unreadable owner authority"); + } + var keys = new List<(string, double)>(); + foreach (var auth in auths) + { + if (auth is not JsonArray { Count: 2 } pair || pair[0] is not JsonValue kv + || !JsVal.TryGetStringLenient(kv, out var key) || !IsWeight(JsVal.AsNumber(pair[1]))) + { + throw new FormatException("unreadable owner key"); + } + keys.Add((key, JsVal.AsNumber(pair[1])!.Value)); + } + return new OwnerAuthority(JsVal.AsNumber(tv)!.Value, keys); + } + + private static bool IsWeight(double? n) => n is { } d && d >= 0 && double.IsInteger(d); + + /// + /// Owner-history entries still usable for recovery: inside the window measured from + /// head block time, and strictly newer than the latest recovery (the key a recovery + /// replaced carries exactly the recovery's time and must not count). + /// + private static IEnumerable UsableEntries( + IEnumerable ownerHistory, DateTime headTime, DateTime? lastRecovery) => + ownerHistory.Where(e => e.LastValid + RecoveryWindow > headTime + && (lastRecovery == null || e.LastValid > lastRecovery.Value)); + + public enum Verdict + { + Ok, + NoRecentOwnerChange, + KeyNotRecent, + KeyNotSufficient, + } + + /// + /// Whether any key could still recover the account: a usable entry must exist, and one of + /// them must hold a key that meets its threshold alone. + /// + public static Verdict CheckOwnerHistory(IEnumerable ownerHistory, DateTime headTime, + DateTime? lastRecovery) + { + var usable = UsableEntries(ownerHistory, headTime, lastRecovery).ToList(); + if (usable.Count == 0) + { + return Verdict.NoRecentOwnerChange; + } + return usable.Any(e => e.Previous.HasSufficientKey) ? Verdict.Ok : Verdict.KeyNotSufficient; + } + + /// + /// The proven key's verdict and, when usable, the latest last_valid_time among usable + /// entries in which the key alone meets the threshold. + /// + public static (Verdict Verdict, DateTime? ValidUntil) CheckProvenKey(IEnumerable ownerHistory, + string provenKeyPub, DateTime headTime, DateTime? lastRecovery) + { + DateTime? best = null; + var insufficient = false; + foreach (var entry in UsableEntries(ownerHistory, headTime, lastRecovery)) + { + if (entry.Previous.WeightOf(provenKeyPub) == null) + { + continue; + } + if (!entry.Previous.IsSatisfiedBy(provenKeyPub)) + { + insufficient = true; + } + else if (best == null || entry.LastValid > best) + { + best = entry.LastValid; + } + } + if (best != null) + { + return (Verdict.Ok, best); + } + return (insufficient ? Verdict.KeyNotSufficient : Verdict.KeyNotRecent, null); + } +} diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index 781040c3..ae72c22d 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -276,6 +276,20 @@ def norm_body(text): for case in ("min", "pop", "badcode") ] +RECOVERY_DIVERGENCE = ( + "Self-serve account recovery routes added after the port. The reference build has " + "no such route and answers 404 (POST) or the unmatched-GET template page; this one " + "validates the request (400), checks the captcha and the chain, and otherwise " + "proxies. Deterministic and additive -- no behavior the reference ever had is " + "changing." +) + +RECOVERY_ROUTES = ["/private-api/recovery/status::get"] + [ + f"/private-api/recovery/{route}::{case}" + for route in ("start", "prove", "verify-code", "complete") + for case in ("min", "pop", "badcode") +] + # Cases where the C# port intentionally differs from Node (Node bugs the port fixes). KNOWN_DIVERGENCES = { "/auth-api/hs-token-refresh::min": @@ -359,6 +373,8 @@ def norm_body(text): # Curation desk routes, added after the port: six public reads and eight signed # writes. Same shape as the entries above; one entry per generated case. **{case: CURATION_DESK_DIVERGENCE for case in CURATION_DESK_ROUTES}, + # Self-serve account recovery routes, added after the port. Same shape as above. + **{case: RECOVERY_DIVERGENCE for case in RECOVERY_ROUTES}, } # Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list