From a9e473e7a3d84b585f80e17b7a27294c7176281f Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 10 Nov 2026 19:32:50 +0000 Subject: [PATCH 1/5] Add self-serve account recovery endpoints --- dotnet/EcencyApi.Tests/RecoveryTests.cs | 661 ++++++++++++++++++ .../EcencyApi/Handlers/PrivateApi.Accounts.cs | 38 +- .../EcencyApi/Handlers/PrivateApi.Recovery.cs | 318 +++++++++ dotnet/EcencyApi/Handlers/Routes.cs | 7 + dotnet/EcencyApi/Infrastructure/HiveCrypto.cs | 57 ++ .../EcencyApi/Infrastructure/RecoveryRules.cs | 176 +++++ dotnet/parity/driver.py | 16 + 7 files changed, 1264 insertions(+), 9 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/RecoveryTests.cs create mode 100644 dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs create mode 100644 dotnet/EcencyApi/Infrastructure/RecoveryRules.cs diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs new file mode 100644 index 00000000..946f8079 --- /dev/null +++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs @@ -0,0 +1,661 @@ +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) => new() + { + ["id"] = 1, + ["account"] = Username, + ["previous_owner_authority"] = new JsonObject + { + ["weight_threshold"] = 1, + ["account_auths"] = new JsonArray(), + ["key_auths"] = new JsonArray(new JsonArray(key, 1)), + }, + ["last_valid_time"] = lastValid, + }; + + internal static JsonArray RecoverOp(string timestamp, int index = 7) => + new(index, new JsonObject + { + ["trx_id"] = "abc", + ["block"] = 1, + ["timestamp"] = timestamp, + ["op"] = new JsonArray("recover_account", new JsonObject { ["account_to_recover"] = Username }), + }); + + private static string Fmt(DateTime t) => t.ToString("yyyy-MM-ddTHH:mm:ss"); + + [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(RecoveryRules.ProvenKeyValidUntil(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, RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, tr)); + } + + [Fact] + public void NoRecoveryMeansNoLineageConstraint() + { + var lvt = Head.AddDays(-5); + var history = new JsonArray(Entry(OldOwnerPub, Fmt(lvt))); + Assert.Equal(lvt, RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, null)); + } + + [Fact] + public void WindowEdgeIsExclusive() + { + var atEdge = new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-30)))); + Assert.Null(RecoveryRules.ProvenKeyValidUntil(atEdge, OldOwnerPub, Head, null)); + + var inside = Head.AddDays(-30).AddSeconds(1); + var justInside = new JsonArray(Entry(OldOwnerPub, Fmt(inside))); + Assert.Equal(inside, RecoveryRules.ProvenKeyValidUntil(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, RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, null)); + } + + [Fact] + public void UnknownKeyIsRejected() + { + var history = new JsonArray(Entry(NewOwnerPub, Fmt(Head.AddDays(-1)))); + Assert.Null(RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, null)); + } + + [Fact] + public void RecentOwnerChangeRespectsWindowAndLineage() + { + Assert.False(RecoveryRules.HasRecentOwnerChange(new JsonArray(), Head, null)); + Assert.False(RecoveryRules.HasRecentOwnerChange( + new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-31)))), Head, null)); + var tr = Head.AddDays(-1); + Assert.False(RecoveryRules.HasRecentOwnerChange( + new JsonArray(Entry(OldOwnerPub, Fmt(tr))), Head, tr)); + Assert.True(RecoveryRules.HasRecentOwnerChange( + new JsonArray(Entry(OldOwnerPub, Fmt(tr.AddSeconds(3)))), Head, tr)); + } + + [Fact] + public void LatestRecoveryTimeIsTheMaximum() + { + var history = new JsonArray( + RecoverOp("2026-10-01T00:00:00", 3), + RecoverOp("2026-11-01T08:30:15", 9), + RecoverOp("2026-10-20T00:00:00", 5)); + Assert.Equal(new DateTime(2026, 11, 1, 8, 30, 15, DateTimeKind.Utc), + RecoveryRules.LatestRecoverAccountTime(history)); + Assert.Null(RecoveryRules.LatestRecoverAccountTime(new JsonArray())); + } + + [Fact] + public void LatestRecoveryTimeIgnoresOtherOps() + { + var other = new JsonArray(4, new JsonObject + { + ["timestamp"] = "2026-11-09T00:00:00", + ["op"] = new JsonArray("transfer", new JsonObject()), + }); + Assert.Null(RecoveryRules.LatestRecoverAccountTime(new JsonArray(other))); + } + + [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 onboard 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 HackerPub = "STM8ZSw7FmkxEFtkGpo7Gc9Jbs6bHjEu5XfGjNcKqZaHB3wnSXN6W"; + + private readonly List<(string Endpoint, HttpMethod Method, JsonNode? Payload)> _onboard = new(); + private readonly List _rpc = new(); + private Func _answer = (_, _) => T.JsonResponse(200, "{}"); + private bool _captchaRejected; + private bool _rpcDown; + + // chain state + private string _recoveryAccount = "ecency"; + private bool _accountExists = true; + private JsonArray _ownerKeys = new(new JsonArray(HackerPub, 1)); + private JsonArray _ownerHistory = new(RecoveryRulesTests.Entry(Old, "2026-11-08T10:00:00")); + private JsonArray _accountHistory = new(); + private const string HeadTime = "2026-11-10T12:00:00"; + + public RecoveryHandlerTests() + { + PrivateApi.RecoveryCaptchaRejected = (_, _) => Task.FromResult(_captchaRejected); + PrivateApi.RecoveryUpstream = (endpoint, method, payload) => + { + _onboard.Add((endpoint, method, payload?.DeepClone())); + return Task.FromResult(_answer(endpoint, method)); + }; + PrivateApi.RecoveryRpc = (method, @params) => + { + _rpc.Add(method); + if (_rpcDown) throw new Exception("all nodes failed"); + JsonNode? result = method switch + { + "get_accounts" => _accountExists + ? new JsonArray(new JsonObject + { + ["name"] = RecoveryRulesTests.Username, + ["recovery_account"] = _recoveryAccount, + ["owner"] = new JsonObject + { + ["weight_threshold"] = 1, + ["account_auths"] = new JsonArray(), + ["key_auths"] = _ownerKeys.DeepClone(), + }, + }) + : new JsonArray(), + "get_owner_history" => _ownerHistory.DeepClone(), + "get_account_history" => _accountHistory.DeepClone(), + "get_dynamic_global_properties" => new JsonObject { ["time"] = HeadTime }, + _ => throw new InvalidOperationException(method), + }; + return Task.FromResult(result); + }; + } + + public void Dispose() => PrivateApi.ResetRecoverySeams(); + + 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 = T.Post("/private-api/recovery/start", json); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(400, ctx.Response.StatusCode); + Assert.Equal("""{"error":"invalid_username"}""", Body(ctx)); + Assert.Empty(_rpc); + Assert.Empty(_onboard); + } + + [Fact] + public async Task StartRefusesAFailedCaptchaBeforeTheChain() + { + _captchaRejected = true; + var ctx = T.Post("/private-api/recovery/start", """{"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(_onboard); + } + + [Fact] + public async Task StartUnknownAccount() + { + _accountExists = false; + var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(404, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_found"}""", Body(ctx)); + Assert.Empty(_onboard); + } + + [Fact] + public async Task StartOtherRecoveryAccount() + { + _recoveryAccount = "someoneelse"; + var ctx = T.Post("/private-api/recovery/start", """{"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(_onboard); + } + + [Fact] + public async Task StartWithoutARecentOwnerChange() + { + _ownerHistory = new JsonArray(); + var ctx = T.Post("/private-api/recovery/start", """{"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(_onboard); + } + + [Fact] + public async Task StartWhenOnlyChangeWasARecovery() + { + _accountHistory = new JsonArray(RecoveryRulesTests.RecoverOp("2026-11-08T10:00:00")); + var ctx = T.Post("/private-api/recovery/start", """{"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 = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Empty(_onboard); + } + + [Fact] + public async Task StartForwardsUsernameAndIpAndPipes() + { + _answer = (_, _) => T.JsonResponse(200, $$"""{"request_id":"{{RequestId}}","nonce":"{{RecoveryRulesTests.Nonce}}"}"""); + var ctx = T.Post("/private-api/recovery/start", """{"username":" RecoveryTest ","captcha_token":"t"}"""); + ctx.Request.Headers["x-real-ip"] = "203.0.113.9"; + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_onboard); + 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.Contains("get_account_history", _rpc); + } + + [Fact] + public async Task StartPipesOnboardErrors() + { + _answer = (_, _) => T.JsonResponse(429, """{"error":"rate_limited"}"""); + var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(429, ctx.Response.StatusCode); + Assert.Equal("""{"error":"rate_limited"}""", 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(_onboard); + 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(_onboard); + } + + [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(_onboard); + } + + [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(_onboard); + 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(_onboard); + } + + [Fact] + public async Task ProveWithAKeyFromBeforeTheLastRecovery() + { + RowIs("started"); + _accountHistory = new JsonArray(RecoveryRulesTests.RecoverOp("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(_onboard); + } + + [Fact] + public async Task ProveRejectsNewKeyThatIsACurrentOwnerKey() + { + RowIs("started"); + _ownerKeys = new JsonArray(new JsonArray(New, 1)); + 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(_onboard); + } + + [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, _onboard.Count); + var call = _onboard[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"}""", + call.Payload!.ToJsonString()); + } + + // ---- 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(_onboard); + } + + [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(_onboard); + 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(_onboard); + } + + [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(_onboard); + 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(_onboard); + } + + [Fact] + public async Task CompleteNeedsTheOwnerToBeTheNewKeyOnly() + { + RowIs("requested", newOwnerPub: New); + _ownerKeys = new JsonArray(new JsonArray(New, 1), new JsonArray(HackerPub, 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(_onboard); + } + + [Fact] + public async Task CompleteForwards() + { + RowIs("requested", newOwnerPub: New); + _ownerKeys = new JsonArray(new JsonArray(New, 1)); + 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", _onboard[1].Endpoint); + } +} 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..1acead35 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs @@ -0,0 +1,318 @@ +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, JsonNode? OwnerHistory, DateTime? LastRecovery, + DateTime HeadTime); + + /// recover_account is operation 25; the low filter word selects it alone. + private const int RecoverAccountOpFilter = 1 << 25; + + private static async Task ReadRecoveryChain(string username) + { + var accounts = RecoveryRpc("get_accounts", new JsonArray(new JsonArray(username))); + var ownerHistory = RecoveryRpc("get_owner_history", new JsonArray(username)); + var history = RecoveryRpc("get_account_history", + new JsonArray(username, -1, 1000, RecoverAccountOpFilter, 0)); + var props = RecoveryRpc("get_dynamic_global_properties", new JsonArray()); + await Task.WhenAll(accounts, ownerHistory, history, props); + + var head = RecoveryRules.ParseChainTime(JsVal.Prop(props.Result, "time")) + ?? throw new InvalidOperationException("head block time missing"); + var account = accounts.Result is JsonArray list && list.Count > 0 ? list[0] as JsonObject : null; + return new RecoveryChainState(account, ownerHistory.Result, + RecoveryRules.LatestRecoverAccountTime(history.Result), head); + } + + 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) + { + // Quiet on purpose: a node outage would otherwise log per request. + 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; + } + + if (resp.Status == 404) + { + 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; + } + + // Captcha before any chain read, so unsolved requests cost nothing upstream. + var ip = SignupClientIp(ctx); + 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; + } + if (!RecoveryRules.HasRecentOwnerChange(chain.OwnerHistory, chain.HeadTime, chain.LastRecovery)) + { + await SendRecoveryError(ctx, 422, "no_recent_owner_change"); + 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; + } + + 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 (signature == null || !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 validUntil = RecoveryRules.ProvenKeyValidUntil( + chain.OwnerHistory, provenKeyPub!, chain.HeadTime, chain.LastRecovery); + if (validUntil == null) + { + await SendRecoveryError(ctx, 422, "key_not_recent"); + return; + } + + var payload = new JsonObject + { + ["new_owner_pub"] = newOwnerPub, + ["proven_key_pub"] = provenKeyPub, + ["proven_key_valid_until"] = RecoveryRules.FormatChainTime(validUntil.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? accounts; + try + { + accounts = await RecoveryRpc("get_accounts", new JsonArray(new JsonArray(username))); + } + catch (Exception) + { + await SendRecoveryUnavailable(ctx); + return; + } + var account = accounts is JsonArray list && list.Count > 0 ? list[0] : null; + if (!RecoveryRules.IsSingleKeyAuthority(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..7a586d03 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs @@ -164,6 +164,63 @@ 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) + { + if (key == null || !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..ffbcc8bb --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs @@ -0,0 +1,176 @@ +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, and it was replaced after the account's most recent recovery. +/// +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 != null && RequestIdPattern().IsMatch(id); + + public static bool IsCode(string? code) => code != null && 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 holds exactly one key, the given one, with weight 1. + /// + public static bool IsSingleKeyAuthority(JsonNode? authority, string key) + { + if (JsVal.Prop(authority, "key_auths") is not JsonArray auths || auths.Count != 1) + { + return false; + } + return auths[0] is JsonArray pair && pair.Count == 2 + && pair[0] is JsonValue kv && JsVal.TryGetStringLenient(kv, out var k) + && string.Equals(k, key, StringComparison.Ordinal) + && pair[1] is JsonValue wv && JsVal.AsNumber(wv) == 1; + } + + /// + /// Timestamp of the latest recover_account in an account-history answer, or null when + /// there is none. + /// + public static DateTime? LatestRecoverAccountTime(JsonNode? history) + { + DateTime? latest = null; + if (history is not JsonArray items) + { + return null; + } + foreach (var item in items) + { + var entry = item is JsonArray pair && pair.Count > 1 ? pair[1] : null; + if (JsVal.Prop(entry, "op") is not JsonArray op || op.Count == 0 + || op[0] is not JsonValue nv || !JsVal.TryGetStringLenient(nv, out var name) + || name is not ("recover_account" or "recover_account_operation")) + { + continue; + } + var t = ParseChainTime(JsVal.Prop(entry, "timestamp")); + if (t != null && (latest == null || t > latest)) + { + latest = t; + } + } + return latest; + } + + /// + /// 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<(JsonNode Entry, DateTime LastValid)> UsableEntries( + JsonNode? ownerHistory, DateTime headTime, DateTime? lastRecovery) + { + if (ownerHistory is not JsonArray entries) + { + yield break; + } + foreach (var entry in entries) + { + if (entry == null) + { + continue; + } + var lastValid = ParseChainTime(JsVal.Prop(entry, "last_valid_time")); + if (lastValid == null + || lastValid.Value + RecoveryWindow <= headTime + || (lastRecovery != null && lastValid.Value <= lastRecovery.Value)) + { + continue; + } + yield return (entry, lastValid.Value); + } + } + + public static bool HasRecentOwnerChange(JsonNode? ownerHistory, DateTime headTime, DateTime? lastRecovery) => + UsableEntries(ownerHistory, headTime, lastRecovery).Any(); + + /// + /// The latest last_valid_time among usable entries whose previous owner authority holds + /// the proven key, or null when the key cannot be used. + /// + public static DateTime? ProvenKeyValidUntil(JsonNode? ownerHistory, string provenKeyPub, DateTime headTime, + DateTime? lastRecovery) + { + DateTime? best = null; + foreach (var (entry, lastValid) in UsableEntries(ownerHistory, headTime, lastRecovery)) + { + if (AuthorityKeys(JsVal.Prop(entry, "previous_owner_authority")).Contains(provenKeyPub) + && (best == null || lastValid > best)) + { + best = lastValid; + } + } + return best; + } +} 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 From 37c30ba64fdf626521cfd6ab373cb33a380230f3 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 10 Nov 2026 20:05:22 +0000 Subject: [PATCH 2/5] Tighten self-serve account recovery checks and forward the proof --- dotnet/EcencyApi.Tests/RecoveryTests.cs | 380 +++++++++++++++--- .../EcencyApi/Handlers/PrivateApi.Recovery.cs | 27 +- .../EcencyApi/Infrastructure/RecoveryRules.cs | 90 ++++- 3 files changed, 426 insertions(+), 71 deletions(-) diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs index 946f8079..32f0a6c4 100644 --- a/dotnet/EcencyApi.Tests/RecoveryTests.cs +++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs @@ -219,14 +219,147 @@ public void LatestRecoveryTimeIsTheMaximum() } [Fact] - public void LatestRecoveryTimeIgnoresOtherOps() + public void RealChainKeyReplacedOneBlockBeforeTheRecoveryIsRejected() { - var other = new JsonArray(4, new JsonObject + var tr = Head.AddDays(-2); + var history = new JsonArray(Entry(OldOwnerPub, Fmt(tr.AddSeconds(-3)))); + Assert.Null(RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, tr)); + } + + public static IEnumerable UnreadableRecoverHistory() => new[] + { + // another op in an answer filtered to recover_account: the filter was not applied + new object[] { new JsonArray(new JsonArray(4, new JsonObject + { ["timestamp"] = "2026-11-09T00:00:00", ["op"] = new JsonArray("transfer", new JsonObject()) })) }, + new object[] { new JsonArray(new JsonArray(4, new JsonObject + { ["timestamp"] = "yesterday", ["op"] = new JsonArray("recover_account", new JsonObject()) })) }, + new object[] { new JsonArray(new JsonArray(4, new JsonObject + { ["timestamp"] = "2026-11-09T00:00:00", ["op"] = "recover_account" })) }, + new object[] { new JsonArray(new JsonArray(4)) }, + new object[] { new JsonArray(JsonValue.Create(4)) }, + new object[] { new JsonObject() }, + }; + + [Theory] + [MemberData(nameof(UnreadableRecoverHistory))] + public void UnreadableRecoverHistoryIsAReadFailure(JsonNode history) + { + Assert.ThrowsAny(() => RecoveryRules.LatestRecoverAccountTime(history)); + } + + 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 { - ["timestamp"] = "2026-11-09T00:00:00", - ["op"] = new JsonArray("transfer", new JsonObject()), + ["trx_id"] = "t" + index, + ["block"] = index, + ["timestamp"] = timestamp, + ["op"] = new JsonArray(name, payload), }); - Assert.Null(RecoveryRules.LatestRecoverAccountTime(new JsonArray(other))); + + // 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"; + + private 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(), OldOwnerPub, validUntil)); + Assert.Equal(new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc), + RecoveryRules.OwnerSince(HackedAccountOps(), 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, OldOwnerPub, validUntil)); + Assert.Null(RecoveryRules.OwnerSince(HackedAccountOps(), 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, OldOwnerPub, until)); + Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, K0, until)); + Assert.Null(RecoveryRules.OwnerSince(ops, Taken, until)); + Assert.Null(RecoveryRules.OwnerSince(new JsonArray(), 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, OldOwnerPub, Head)); } [Fact] @@ -244,16 +377,16 @@ public void TurnstileActionIsBound() } } -/// Recovery endpoints with chain, captcha and onboard replaced by fakes. +/// 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 HackerPub = "STM8ZSw7FmkxEFtkGpo7Gc9Jbs6bHjEu5XfGjNcKqZaHB3wnSXN6W"; + private const string OtherPub = "STM8ZSw7FmkxEFtkGpo7Gc9Jbs6bHjEu5XfGjNcKqZaHB3wnSXN6W"; - private readonly List<(string Endpoint, HttpMethod Method, JsonNode? Payload)> _onboard = new(); + 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; @@ -262,9 +395,14 @@ public class RecoveryHandlerTests : IDisposable // chain state private string _recoveryAccount = "ecency"; private bool _accountExists = true; - private JsonArray _ownerKeys = new(new JsonArray(HackerPub, 1)); private JsonArray _ownerHistory = new(RecoveryRulesTests.Entry(Old, "2026-11-08T10:00:00")); private JsonArray _accountHistory = new(); + private JsonNode _ownerOps = new JsonArray(RecoveryRulesTests.Op(77, "2026-02-14T18:30:00", "account_update", + new JsonObject { ["account"] = RecoveryRulesTests.Username, ["owner"] = RecoveryRulesTests.Authority(Old) })); + private JsonObject _owner = RecoveryRulesTests.Authority(OtherPub); + private readonly List _historyFilters = new(); + private const double RecoverFilter = 1L << 25; + private const double OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 43); private const string HeadTime = "2026-11-10T12:00:00"; public RecoveryHandlerTests() @@ -272,7 +410,7 @@ public RecoveryHandlerTests() PrivateApi.RecoveryCaptchaRejected = (_, _) => Task.FromResult(_captchaRejected); PrivateApi.RecoveryUpstream = (endpoint, method, payload) => { - _onboard.Add((endpoint, method, payload?.DeepClone())); + _privateApi.Add((endpoint, method, payload?.DeepClone())); return Task.FromResult(_answer(endpoint, method)); }; PrivateApi.RecoveryRpc = (method, @params) => @@ -286,16 +424,11 @@ public RecoveryHandlerTests() { ["name"] = RecoveryRulesTests.Username, ["recovery_account"] = _recoveryAccount, - ["owner"] = new JsonObject - { - ["weight_threshold"] = 1, - ["account_auths"] = new JsonArray(), - ["key_auths"] = _ownerKeys.DeepClone(), - }, + ["owner"] = _owner.DeepClone(), }) : new JsonArray(), "get_owner_history" => _ownerHistory.DeepClone(), - "get_account_history" => _accountHistory.DeepClone(), + "get_account_history" => HistoryFor(@params), "get_dynamic_global_properties" => new JsonObject { ["time"] = HeadTime }, _ => throw new InvalidOperationException(method), }; @@ -305,6 +438,28 @@ public RecoveryHandlerTests() 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 + { + RecoverFilter => _accountHistory.DeepClone(), + 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, @@ -347,64 +502,64 @@ private void RowIs(string status, string username = RecoveryRulesTests.Username, [InlineData("""{"username":12,"captcha_token":"t"}""")] public async Task StartRejectsABadUsernameBeforeAnyCall(string json) { - var ctx = T.Post("/private-api/recovery/start", 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(_onboard); + Assert.Empty(_privateApi); } [Fact] public async Task StartRefusesAFailedCaptchaBeforeTheChain() { _captchaRejected = true; - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + 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(_onboard); + Assert.Empty(_privateApi); } [Fact] public async Task StartUnknownAccount() { _accountExists = false; - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + 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(_onboard); + Assert.Empty(_privateApi); } [Fact] public async Task StartOtherRecoveryAccount() { _recoveryAccount = "someoneelse"; - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + 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(_onboard); + Assert.Empty(_privateApi); } [Fact] public async Task StartWithoutARecentOwnerChange() { _ownerHistory = new JsonArray(); - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + 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(_onboard); + Assert.Empty(_privateApi); } [Fact] public async Task StartWhenOnlyChangeWasARecovery() { _accountHistory = new JsonArray(RecoveryRulesTests.RecoverOp("2026-11-08T10:00:00")); - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + 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)); @@ -414,21 +569,20 @@ public async Task StartWhenOnlyChangeWasARecovery() public async Task StartChainDownIsUnavailable() { _rpcDown = true; - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); await PrivateApi.RecoveryStart(ctx); Assert.Equal(503, ctx.Response.StatusCode); - Assert.Empty(_onboard); + Assert.Empty(_privateApi); } [Fact] public async Task StartForwardsUsernameAndIpAndPipes() { _answer = (_, _) => T.JsonResponse(200, $$"""{"request_id":"{{RequestId}}","nonce":"{{RecoveryRulesTests.Nonce}}"}"""); - var ctx = T.Post("/private-api/recovery/start", """{"username":" RecoveryTest ","captcha_token":"t"}"""); - ctx.Request.Headers["x-real-ip"] = "203.0.113.9"; + var ctx = StartPost("""{"username":" RecoveryTest ","captcha_token":"t"}"""); await PrivateApi.RecoveryStart(ctx); Assert.Equal(200, ctx.Response.StatusCode); - var call = Assert.Single(_onboard); + 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()); @@ -437,10 +591,10 @@ public async Task StartForwardsUsernameAndIpAndPipes() } [Fact] - public async Task StartPipesOnboardErrors() + public async Task StartPipesPrivateApiErrors() { _answer = (_, _) => T.JsonResponse(429, """{"error":"rate_limited"}"""); - var ctx = T.Post("/private-api/recovery/start", """{"username":"recoverytest","captcha_token":"t"}"""); + 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)); @@ -458,7 +612,7 @@ public async Task ProveValidatesInputBeforeAnyCall(string id, string newPub, str await PrivateApi.RecoveryProve(ctx); Assert.Equal(400, ctx.Response.StatusCode); Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); - Assert.Empty(_onboard); + Assert.Empty(_privateApi); Assert.Empty(_rpc); } @@ -469,7 +623,7 @@ public async Task ProveRejectsNewKeyEqualToProvenKey() await PrivateApi.RecoveryProve(ctx); Assert.Equal(400, ctx.Response.StatusCode); Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); - Assert.Empty(_onboard); + Assert.Empty(_privateApi); } [Fact] @@ -480,7 +634,7 @@ public async Task ProveUnknownRequest() await PrivateApi.RecoveryProve(ctx); Assert.Equal(404, ctx.Response.StatusCode); Assert.Equal("""{"error":"not_found"}""", Body(ctx)); - Assert.Single(_onboard); + Assert.Single(_privateApi); } [Theory] @@ -493,7 +647,7 @@ public async Task ProveNeedsAStartedRowForTheSameUser(string status, string rowU await PrivateApi.RecoveryProve(ctx); Assert.Equal(409, ctx.Response.StatusCode); Assert.Equal("""{"error":"bad_state"}""", Body(ctx)); - Assert.Single(_onboard); + Assert.Single(_privateApi); Assert.DoesNotContain(RecoveryRulesTests.Nonce, Body(ctx)); } @@ -507,7 +661,7 @@ public async Task ProveWithAWrongSignature() await PrivateApi.RecoveryProve(ctx); Assert.Equal(400, ctx.Response.StatusCode); Assert.Equal("""{"error":"invalid_proof"}""", Body(ctx)); - Assert.Single(_onboard); + Assert.Single(_privateApi); } [Fact] @@ -519,19 +673,19 @@ public async Task ProveWithAKeyFromBeforeTheLastRecovery() await PrivateApi.RecoveryProve(ctx); Assert.Equal(422, ctx.Response.StatusCode); Assert.Equal("""{"error":"key_not_recent"}""", Body(ctx)); - Assert.Single(_onboard); + Assert.Single(_privateApi); } [Fact] public async Task ProveRejectsNewKeyThatIsACurrentOwnerKey() { RowIs("started"); - _ownerKeys = new JsonArray(new JsonArray(New, 1)); + _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(_onboard); + Assert.Single(_privateApi); } [Fact] @@ -557,13 +711,14 @@ public async Task ProveForwardsTheValidityAndNeverTheNonce() await PrivateApi.RecoveryProve(ctx); Assert.Equal(200, ctx.Response.StatusCode); Assert.Equal("""{"emails":["o****9@gmail.com"]}""", Body(ctx)); - Assert.Equal(2, _onboard.Count); - var call = _onboard[1]; + 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"}""", + $$"""{"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[] { RecoverFilter, OwnerOpsFilter }, _historyFilters.OrderBy(f => f).ToArray()); } // ---- verify-code ---- @@ -578,7 +733,7 @@ public async Task VerifyValidatesInput(string json) await PrivateApi.RecoveryVerifyCode(ctx); Assert.Equal(400, ctx.Response.StatusCode); Assert.Equal("""{"error":"invalid_request"}""", Body(ctx)); - Assert.Empty(_onboard); + Assert.Empty(_privateApi); } [Fact] @@ -589,7 +744,7 @@ public async Task VerifyForwardsTheCode() await PrivateApi.RecoveryVerifyCode(ctx); Assert.Equal(400, ctx.Response.StatusCode); Assert.Equal("""{"error":"invalid_code","attempts_left":3}""", Body(ctx)); - var call = Assert.Single(_onboard); + 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()); @@ -603,7 +758,7 @@ 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(_onboard); + Assert.Empty(_privateApi); } [Fact] @@ -613,7 +768,7 @@ public async Task StatusForwardsTheSafeRoute() 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(_onboard); + var call = Assert.Single(_privateApi); Assert.Equal($"recovery-request/{RequestId}/status", call.Endpoint); Assert.Equal(HttpMethod.Get, call.Method); } @@ -628,26 +783,28 @@ public async Task CompleteNeedsARequestedRow() await PrivateApi.RecoveryComplete(ctx); Assert.Equal(409, ctx.Response.StatusCode); Assert.Equal("""{"error":"bad_state"}""", Body(ctx)); - Assert.Single(_onboard); + Assert.Single(_privateApi); } [Fact] public async Task CompleteNeedsTheOwnerToBeTheNewKeyOnly() { RowIs("requested", newOwnerPub: New); - _ownerKeys = new JsonArray(new JsonArray(New, 1), new JsonArray(HackerPub, 1)); + _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(_onboard); + Assert.Single(_privateApi); } [Fact] public async Task CompleteForwards() { RowIs("requested", newOwnerPub: New); - _ownerKeys = new JsonArray(new JsonArray(New, 1)); + _owner = RecoveryRulesTests.Authority(New); + ((JsonArray)_owner["account_auths"]!).Add(new JsonArray("someapp", 1)); var inner = _answer; _answer = (endpoint, method) => method == HttpMethod.Post ? T.JsonResponse(200, """{"status":"completed"}""") @@ -656,6 +813,119 @@ public async Task CompleteForwards() await PrivateApi.RecoveryComplete(ctx); Assert.Equal(200, ctx.Response.StatusCode); Assert.Equal("""{"status":"completed"}""", Body(ctx)); - Assert.Equal($"recovery-request/{RequestId}/complete", _onboard[1].Endpoint); + 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 StartWithAnUnreadableRecoveryHistoryIsUnavailable() + { + _accountHistory = new JsonArray(new JsonArray(4, new JsonObject + { ["timestamp"] = "2026-11-09T00:00:00", ["op"] = new JsonArray("transfer", new JsonObject()) })); + 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 ProveWithAnUnreadableRecoveryHistoryIsUnavailable() + { + RowIs("started"); + _accountHistory = new JsonArray(new JsonArray(4, new JsonObject + { ["timestamp"] = "bad", ["op"] = new JsonArray("recover_account", new JsonObject()) })); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Single(_privateApi); + } + + [Fact] + public async Task ProveWithUnreadableOwnerOpsIsUnavailable() + { + RowIs("started"); + _ownerOps = new JsonArray(RecoveryRulesTests.Op(1, "2026-01-01T00:00:00", "transfer", new JsonObject())); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(503, ctx.Response.StatusCode); + Assert.Single(_privateApi); + } + + [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(2, 1)] + [InlineData(1, 2)] + [InlineData(2, 2)] + [InlineData(0, 1)] + public async Task CompleteNeedsWeightAndThresholdOfOne(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); } } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs index 1acead35..adedda89 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs @@ -111,7 +111,8 @@ private static bool IsEcencyRecovered(JsonObject account) => return null; } - if (resp.Status == 404) + // 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; @@ -140,8 +141,15 @@ public static async Task RecoveryStart(HttpContext ctx) return; } - // Captcha before any chain read, so unsolved requests cost nothing upstream. + // 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"); @@ -237,11 +245,26 @@ public static async Task RecoveryProve(HttpContext ctx) return; } + DateTime? ownerSince; + try + { + var ownerOps = await RecoveryRpc("get_account_history", + new JsonArray(username, -1, 1000, RecoveryRules.OwnerOpsFilter, 0)); + ownerSince = RecoveryRules.OwnerSince(ownerOps, provenKeyPub!, validUntil.Value); + } + catch (Exception) + { + await SendRecoveryUnavailable(ctx); + return; + } + 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); } diff --git a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs index ffbcc8bb..fc4e28b1 100644 --- a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs +++ b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs @@ -80,11 +80,13 @@ public static List AuthorityKeys(JsonNode? authority) } /// - /// True when the authority holds exactly one key, the given one, with weight 1. + /// True when the authority's threshold is 1 and it holds exactly one key, the given one, + /// with weight 1. Account auths are not looked at. /// public static bool IsSingleKeyAuthority(JsonNode? authority, string key) { - if (JsVal.Prop(authority, "key_auths") is not JsonArray auths || auths.Count != 1) + if (JsVal.AsNumber(JsVal.Prop(authority, "weight_threshold")) != 1 + || JsVal.Prop(authority, "key_auths") is not JsonArray auths || auths.Count != 1) { return false; } @@ -95,29 +97,89 @@ public static bool IsSingleKeyAuthority(JsonNode? authority, string key) } /// - /// Timestamp of the latest recover_account in an account-history answer, or null when - /// there is none. + /// 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"); + + /// + /// Timestamp of the latest recover_account in an answer filtered to that operation, or + /// null when there is none. Throws when the answer cannot be read. /// public static DateTime? LatestRecoverAccountTime(JsonNode? history) { DateTime? latest = null; - if (history is not JsonArray items) + foreach (var item in HistoryItems(history)) { - return null; + var (time, name, _) = ReadHistoryItem(item); + if (name != "recover_account") + { + throw new FormatException("unexpected operation in filtered history"); + } + if (latest == null || time > latest) + { + latest = time; + } } - foreach (var item in items) + return latest; + } + + /// account_create, account_update, create_claimed_account, recover_account, account_update2. + public const long OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 43); + + /// + /// When the proven key became an owner key: the time of the latest operation before + /// validUntil whose new owner authority holds the key, from an answer filtered to + /// . Null when no such operation is in the answer. Throws when + /// the answer cannot be read. + /// + public static DateTime? OwnerSince(JsonNode? history, string provenKeyPub, DateTime validUntil) + { + DateTime? latest = null; + foreach (var item in HistoryItems(history)) { - var entry = item is JsonArray pair && pair.Count > 1 ? pair[1] : null; - if (JsVal.Prop(entry, "op") is not JsonArray op || op.Count == 0 - || op[0] is not JsonValue nv || !JsVal.TryGetStringLenient(nv, out var name) - || name is not ("recover_account" or "recover_account_operation")) + var (time, name, payload) = ReadHistoryItem(item); + var (field, required) = name switch + { + "account_create" or "create_claimed_account" => ("owner", true), + "account_update" or "account_update2" => ("owner", false), + "recover_account" => ("new_owner_authority", true), + _ => throw new FormatException("unexpected operation in filtered history"), + }; + var authority = JsVal.Prop(payload, field); + if (authority == null && !required) { continue; } - var t = ParseChainTime(JsVal.Prop(entry, "timestamp")); - if (t != null && (latest == null || t > latest)) + if (authority is not JsonObject || JsVal.Prop(authority, "key_auths") is not JsonArray) + { + throw new FormatException("unreadable owner authority"); + } + if (time < validUntil && AuthorityKeys(authority).Contains(provenKeyPub) + && (latest == null || time > latest)) { - latest = t; + latest = time; } } return latest; From d1e8f1ff6b8a5ead6acfce9831002db88320b40a Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 11 Nov 2026 08:13:41 +0000 Subject: [PATCH 3/5] Recovery: key weight, account recovery time from the account, bounded inputs - A proven key must meet the old owner threshold on its own (key_not_sufficient at start and prove). - The latest recovery time comes from the account's last_account_recovery field instead of a filtered history scan. - owner_since only counts the account's own creation, and falls back to null when the history read fails or cannot be read. - Completion accepts an owner of exactly the new key meeting its threshold and no account auths. - Unreadable owner history entries are a read failure (503), never skipped. - Signatures, public keys, request ids and codes are length checked before any decoding. - Failed chain reads log the method and cause once, without account details. --- dotnet/EcencyApi.Tests/RecoveryTests.cs | 481 ++++++++++++++---- .../EcencyApi/Handlers/PrivateApi.Recovery.cs | 124 +++-- dotnet/EcencyApi/Infrastructure/HiveCrypto.cs | 13 +- .../EcencyApi/Infrastructure/RecoveryRules.cs | 213 +++++--- 4 files changed, 644 insertions(+), 187 deletions(-) diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs index 32f0a6c4..bcdded44 100644 --- a/dotnet/EcencyApi.Tests/RecoveryTests.cs +++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs @@ -23,30 +23,42 @@ public class RecoveryRulesTests private static readonly DateTime Head = new(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); - internal static JsonObject Entry(string key, string lastValid) => new() + 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) { - ["id"] = 1, - ["account"] = Username, - ["previous_owner_authority"] = new JsonObject + var auths = new JsonArray(); + foreach (var (key, weight) in keys) { - ["weight_threshold"] = 1, - ["account_auths"] = new JsonArray(), - ["key_auths"] = new JsonArray(new JsonArray(key, 1)), - }, - ["last_valid_time"] = lastValid, - }; - - internal static JsonArray RecoverOp(string timestamp, int index = 7) => - new(index, new JsonObject + auths.Add(new JsonArray(key, weight)); + } + return new JsonObject { - ["trx_id"] = "abc", - ["block"] = 1, - ["timestamp"] = timestamp, - ["op"] = new JsonArray("recover_account", new JsonObject { ["account_to_recover"] = Username }), - }); + ["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() { @@ -143,7 +155,7 @@ 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(RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, tr)); + Assert.Null(ValidUntil(history, OldOwnerPub, Head, tr)); } [Fact] @@ -152,7 +164,7 @@ public void KeyNewerThanTheLastRecoveryIsAccepted() var tr = Head.AddDays(-2); var lvt = tr.AddSeconds(3); var history = new JsonArray(Entry(OldOwnerPub, Fmt(lvt))); - Assert.Equal(lvt, RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, tr)); + Assert.Equal(lvt, ValidUntil(history, OldOwnerPub, Head, tr)); } [Fact] @@ -160,18 +172,18 @@ public void NoRecoveryMeansNoLineageConstraint() { var lvt = Head.AddDays(-5); var history = new JsonArray(Entry(OldOwnerPub, Fmt(lvt))); - Assert.Equal(lvt, RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, null)); + Assert.Equal(lvt, ValidUntil(history, OldOwnerPub, Head, null)); } [Fact] public void WindowEdgeIsExclusive() { var atEdge = new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-30)))); - Assert.Null(RecoveryRules.ProvenKeyValidUntil(atEdge, OldOwnerPub, Head, null)); + 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, RecoveryRules.ProvenKeyValidUntil(justInside, OldOwnerPub, Head, null)); + Assert.Equal(inside, ValidUntil(justInside, OldOwnerPub, Head, null)); } [Fact] @@ -183,68 +195,181 @@ public void SeveralMatchesPickTheLatest() Entry(OldOwnerPub, Fmt(older)), Entry(NewOwnerPub, Fmt(Head.AddHours(-1))), Entry(OldOwnerPub, Fmt(newer))); - Assert.Equal(newer, RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, null)); + Assert.Equal(newer, ValidUntil(history, OldOwnerPub, Head, null)); } [Fact] public void UnknownKeyIsRejected() { var history = new JsonArray(Entry(NewOwnerPub, Fmt(Head.AddDays(-1)))); - Assert.Null(RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, null)); + Assert.Null(ValidUntil(history, OldOwnerPub, Head, null)); } [Fact] public void RecentOwnerChangeRespectsWindowAndLineage() { - Assert.False(RecoveryRules.HasRecentOwnerChange(new JsonArray(), Head, null)); - Assert.False(RecoveryRules.HasRecentOwnerChange( + 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(RecoveryRules.HasRecentOwnerChange( + Assert.False(HasRecentOwnerChange( new JsonArray(Entry(OldOwnerPub, Fmt(tr))), Head, tr)); - Assert.True(RecoveryRules.HasRecentOwnerChange( + Assert.True(HasRecentOwnerChange( new JsonArray(Entry(OldOwnerPub, Fmt(tr.AddSeconds(3)))), Head, tr)); } [Fact] - public void LatestRecoveryTimeIsTheMaximum() + 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( - RecoverOp("2026-10-01T00:00:00", 3), - RecoverOp("2026-11-01T08:30:15", 9), - RecoverOp("2026-10-20T00:00:00", 5)); - Assert.Equal(new DateTime(2026, 11, 1, 8, 30, 15, DateTimeKind.Utc), - RecoveryRules.LatestRecoverAccountTime(history)); - Assert.Null(RecoveryRules.LatestRecoverAccountTime(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 RealChainKeyReplacedOneBlockBeforeTheRecoveryIsRejected() + public void AKeyOnlyInAnOldEntryIsNotRecentEvenIfSufficient() { - var tr = Head.AddDays(-2); - var history = new JsonArray(Entry(OldOwnerPub, Fmt(tr.AddSeconds(-3)))); - Assert.Null(RecoveryRules.ProvenKeyValidUntil(history, OldOwnerPub, Head, tr)); + var history = new JsonArray(Entry(OldOwnerPub, Fmt(Head.AddDays(-40)))); + Assert.Equal(RecoveryRules.Verdict.KeyNotRecent, ProvenVerdict(history, OldOwnerPub)); } - public static IEnumerable UnreadableRecoverHistory() => new[] + public static IEnumerable UnreadableOwnerHistory() { - // another op in an answer filtered to recover_account: the filter was not applied - new object[] { new JsonArray(new JsonArray(4, new JsonObject - { ["timestamp"] = "2026-11-09T00:00:00", ["op"] = new JsonArray("transfer", new JsonObject()) })) }, - new object[] { new JsonArray(new JsonArray(4, new JsonObject - { ["timestamp"] = "yesterday", ["op"] = new JsonArray("recover_account", new JsonObject()) })) }, - new object[] { new JsonArray(new JsonArray(4, new JsonObject - { ["timestamp"] = "2026-11-09T00:00:00", ["op"] = "recover_account" })) }, - new object[] { new JsonArray(new JsonArray(4)) }, - new object[] { new JsonArray(JsonValue.Create(4)) }, - new object[] { new JsonObject() }, - }; + 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(UnreadableRecoverHistory))] - public void UnreadableRecoverHistoryIsAReadFailure(JsonNode history) + [MemberData(nameof(UnreadableOwnerHistory))] + public void UnreadableOwnerHistoryIsAReadFailure(JsonNode history) { - Assert.ThrowsAny(() => RecoveryRules.LatestRecoverAccountTime(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 ReadFailureLogNamesTheMethodButNotTheAccount() + { + var (key, line) = PrivateApi.RecoveryReadFailureLog("get_account_history", + new InvalidOperationException($"could not find filtered operation for {Username}"), Username); + Assert.Contains("get_account_history", line); + Assert.Contains("InvalidOperationException", line); + Assert.DoesNotContain(Username, line); + Assert.DoesNotContain(Username, key); + Assert.Equal(key, PrivateApi.RecoveryReadFailureLog("get_account_history", + new InvalidOperationException("other"), Username).Key); + } + + [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() @@ -299,9 +424,9 @@ 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(), OldOwnerPub, validUntil)); + RecoveryRules.OwnerSince(HackedAccountOps(), Username, OldOwnerPub, validUntil)); Assert.Equal(new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc), - RecoveryRules.OwnerSince(HackedAccountOps(), K0, new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc))); + RecoveryRules.OwnerSince(HackedAccountOps(), Username, K0, new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc))); } [Fact] @@ -315,8 +440,8 @@ public void OwnerSinceIgnoresOpsAtOrAfterValidUntil() })); 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, OldOwnerPub, validUntil)); - Assert.Null(RecoveryRules.OwnerSince(HackedAccountOps(), OldOwnerPub, + RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, validUntil)); + Assert.Null(RecoveryRules.OwnerSince(HackedAccountOps(), Username, OldOwnerPub, new DateTime(2026, 2, 14, 18, 30, 0, DateTimeKind.Utc))); } @@ -338,10 +463,31 @@ public void OwnerSinceFromARecoveryAndAnAccountCreate() ["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, OldOwnerPub, until)); - Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, K0, until)); - Assert.Null(RecoveryRules.OwnerSince(ops, Taken, until)); - Assert.Null(RecoveryRules.OwnerSince(new JsonArray(), K0, until)); + Assert.Equal(new DateTime(2025, 5, 5, 5, 5, 5, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, until)); + Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, Username, K0, until)); + Assert.Null(RecoveryRules.OwnerSince(ops, Username, Taken, until)); + Assert.Null(RecoveryRules.OwnerSince(new JsonArray(), Username, K0, until)); + } + + [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[] @@ -359,7 +505,7 @@ public static IEnumerable UnreadableOwnerOps() => new[] [MemberData(nameof(UnreadableOwnerOps))] public void UnreadableOwnerOpsAreAReadFailure(JsonNode ops) { - Assert.ThrowsAny(() => RecoveryRules.OwnerSince(ops, OldOwnerPub, Head)); + Assert.ThrowsAny(() => RecoveryRules.OwnerSince(ops, Username, OldOwnerPub, Head)); } [Fact] @@ -391,17 +537,17 @@ public class RecoveryHandlerTests : IDisposable 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 JsonArray _accountHistory = new(); + private JsonNode? _lastRecovery = "1970-01-01T00:00:00"; private JsonNode _ownerOps = new JsonArray(RecoveryRulesTests.Op(77, "2026-02-14T18:30:00", "account_update", new JsonObject { ["account"] = RecoveryRulesTests.Username, ["owner"] = RecoveryRulesTests.Authority(Old) })); private JsonObject _owner = RecoveryRulesTests.Authority(OtherPub); private readonly List _historyFilters = new(); - private const double RecoverFilter = 1L << 25; private const double OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 43); private const string HeadTime = "2026-11-10T12:00:00"; @@ -416,7 +562,7 @@ public RecoveryHandlerTests() PrivateApi.RecoveryRpc = (method, @params) => { _rpc.Add(method); - if (_rpcDown) throw new Exception("all nodes failed"); + if (_rpcDown || method == _failingMethod) throw new Exception("all nodes failed"); JsonNode? result = method switch { "get_accounts" => _accountExists @@ -425,6 +571,7 @@ public RecoveryHandlerTests() ["name"] = RecoveryRulesTests.Username, ["recovery_account"] = _recoveryAccount, ["owner"] = _owner.DeepClone(), + ["last_account_recovery"] = _lastRecovery?.DeepClone(), }) : new JsonArray(), "get_owner_history" => _ownerHistory.DeepClone(), @@ -447,7 +594,6 @@ private JsonNode HistoryFor(JsonArray @params) Assert.Equal(1000, JsVal.AsNumber(@params[2])); return filter switch { - RecoverFilter => _accountHistory.DeepClone(), OwnerOpsFilter => _ownerOps.DeepClone(), _ => throw new InvalidOperationException("unexpected filter " + filter), }; @@ -558,7 +704,7 @@ public async Task StartWithoutARecentOwnerChange() [Fact] public async Task StartWhenOnlyChangeWasARecovery() { - _accountHistory = new JsonArray(RecoveryRulesTests.RecoverOp("2026-11-08T10:00:00")); + _lastRecovery = "2026-11-08T10:00:00"; var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); await PrivateApi.RecoveryStart(ctx); Assert.Equal(422, ctx.Response.StatusCode); @@ -587,7 +733,7 @@ public async Task StartForwardsUsernameAndIpAndPipes() 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.Contains("get_account_history", _rpc); + Assert.DoesNotContain("get_account_history", _rpc); } [Fact] @@ -600,6 +746,17 @@ public async Task StartPipesPrivateApiErrors() 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] @@ -668,7 +825,7 @@ public async Task ProveWithAWrongSignature() public async Task ProveWithAKeyFromBeforeTheLastRecovery() { RowIs("started"); - _accountHistory = new JsonArray(RecoveryRulesTests.RecoverOp("2026-11-08T10:00:00")); + _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); @@ -718,7 +875,7 @@ public async Task ProveForwardsTheValidityAndNeverTheNonce() 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[] { RecoverFilter, OwnerOpsFilter }, _historyFilters.OrderBy(f => f).ToArray()); + Assert.Equal(new[] { OwnerOpsFilter }, _historyFilters.ToArray()); } // ---- verify-code ---- @@ -799,12 +956,13 @@ public async Task CompleteNeedsTheOwnerToBeTheNewKeyOnly() Assert.Single(_privateApi); } - [Fact] - public async Task CompleteForwards() + [Theory] + [InlineData(1, 1)] + [InlineData(3, 3)] + public async Task CompleteForwards(int weight, int threshold) { RowIs("requested", newOwnerPub: New); - _owner = RecoveryRulesTests.Authority(New); - ((JsonArray)_owner["account_auths"]!).Add(new JsonArray("someapp", 1)); + _owner = RecoveryRulesTests.Authority(New, weight, threshold); var inner = _answer; _answer = (endpoint, method) => method == HttpMethod.Post ? T.JsonResponse(200, """{"status":"completed"}""") @@ -862,10 +1020,9 @@ public async Task CompleteOnAnUnknownRowIsNotFound() } [Fact] - public async Task StartWithAnUnreadableRecoveryHistoryIsUnavailable() + public async Task StartWithAnUnreadableLastRecoveryIsUnavailable() { - _accountHistory = new JsonArray(new JsonArray(4, new JsonObject - { ["timestamp"] = "2026-11-09T00:00:00", ["op"] = new JsonArray("transfer", new JsonObject()) })); + _lastRecovery = "not a time"; var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); await PrivateApi.RecoveryStart(ctx); Assert.Equal(503, ctx.Response.StatusCode); @@ -873,26 +1030,58 @@ public async Task StartWithAnUnreadableRecoveryHistoryIsUnavailable() } [Fact] - public async Task ProveWithAnUnreadableRecoveryHistoryIsUnavailable() + public async Task ProveWithAnUnreadableLastRecoveryIsUnavailable() { RowIs("started"); - _accountHistory = new JsonArray(new JsonArray(4, new JsonObject - { ["timestamp"] = "bad", ["op"] = new JsonArray("recover_account", new JsonObject()) })); + _lastRecovery = null; var ctx = T.Post("/private-api/recovery/prove", ProveBody()); await PrivateApi.RecoveryProve(ctx); Assert.Equal(503, ctx.Response.StatusCode); Assert.Single(_privateApi); } - [Fact] - public async Task ProveWithUnreadableOwnerOpsIsUnavailable() + private async Task ProveExpectingOwnerSince(string? expected) { RowIs("started"); - _ownerOps = new JsonArray(RecoveryRulesTests.Op(1, "2026-01-01T00:00:00", "transfer", new JsonObject())); + 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(503, ctx.Response.StatusCode); - Assert.Single(_privateApi); + 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] @@ -914,11 +1103,10 @@ public async Task ProveForwardsANullOwnerSinceWhenNoOpSetTheKey() } [Theory] - [InlineData(2, 1)] [InlineData(1, 2)] - [InlineData(2, 2)] + [InlineData(2, 3)] [InlineData(0, 1)] - public async Task CompleteNeedsWeightAndThresholdOfOne(int weight, int threshold) + public async Task CompleteNeedsTheNewKeyToMeetTheThreshold(int weight, int threshold) { RowIs("requested", newOwnerPub: New); _owner = RecoveryRulesTests.Authority(New, weight, threshold); @@ -928,4 +1116,117 @@ public async Task CompleteNeedsWeightAndThresholdOfOne(int weight, int threshold 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.Recovery.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs index adedda89..63f8aaff 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json.Nodes; using EcencyApi.Infrastructure; @@ -42,26 +43,68 @@ private static Task DefaultRecoveryCaptchaRejected(JsonNode? token, string }); /// The account and the owner-change facts the recovery rules need. - private sealed record RecoveryChainState(JsonObject? Account, JsonNode? OwnerHistory, DateTime? LastRecovery, - DateTime HeadTime); - - /// recover_account is operation 25; the low filter word selects it alone. - private const int RecoverAccountOpFilter = 1 << 25; + private sealed record RecoveryChainState(JsonObject? Account, List OwnerHistory, + DateTime? LastRecovery, DateTime HeadTime); private static async Task ReadRecoveryChain(string username) { - var accounts = RecoveryRpc("get_accounts", new JsonArray(new JsonArray(username))); - var ownerHistory = RecoveryRpc("get_owner_history", new JsonArray(username)); - var history = RecoveryRpc("get_account_history", - new JsonArray(username, -1, 1000, RecoverAccountOpFilter, 0)); - var props = RecoveryRpc("get_dynamic_global_properties", new JsonArray()); - await Task.WhenAll(accounts, ownerHistory, history, props); - - var head = RecoveryRules.ParseChainTime(JsVal.Prop(props.Result, "time")) - ?? throw new InvalidOperationException("head block time missing"); - var account = accounts.Result is JsonArray list && list.Count > 0 ? list[0] as JsonObject : null; - return new RecoveryChainState(account, ownerHistory.Result, - RecoveryRules.LatestRecoverAccountTime(history.Result), head); + 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, username); + LogRecoveryFailure(key, line); + throw; + } + } + + /// + /// The log line for a failed recovery chain read, and the key it is written once under: + /// the method and the cause, never the account, keys or client. + /// + internal static (string Key, string Line) RecoveryReadFailureLog(string method, Exception e, string username) + { + var type = e.GetType().Name; + var message = e.Message.Replace(username, "", StringComparison.OrdinalIgnoreCase); + return ($"recovery {method} [{type}]", $"warn: recovery chain read failed: {method} ({type}: {message})"); + } + + // 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) => @@ -82,7 +125,7 @@ private static async Task SendRecoveryUnavailable(HttpContext ctx) } catch (Exception) { - // Quiet on purpose: a node outage would otherwise log per request. + // Already logged by RecoveryRead. await SendRecoveryUnavailable(ctx); return null; } @@ -171,10 +214,14 @@ public static async Task RecoveryStart(HttpContext ctx) await SendRecoveryError(ctx, 422, "not_ecency_recovery"); return; } - if (!RecoveryRules.HasRecentOwnerChange(chain.OwnerHistory, chain.HeadTime, chain.LastRecovery)) + switch (RecoveryRules.CheckOwnerHistory(chain.OwnerHistory, chain.HeadTime, chain.LastRecovery)) { - await SendRecoveryError(ctx, 422, "no_recent_owner_change"); - return; + 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 }; @@ -197,6 +244,12 @@ public static async Task RecoveryProve(HttpContext ctx) 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) @@ -210,7 +263,7 @@ public static async Task RecoveryProve(HttpContext ctx) return; } - if (signature == null || !RecoveryRules.ProofMatches(username, newOwnerPub!, nonce, signature, provenKeyPub!)) + if (!RecoveryRules.ProofMatches(username, newOwnerPub!, nonce, signature, provenKeyPub!)) { await SendRecoveryError(ctx, 400, "invalid_proof"); return; @@ -237,25 +290,32 @@ public static async Task RecoveryProve(HttpContext ctx) return; } - var validUntil = RecoveryRules.ProvenKeyValidUntil( + 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 { - var ownerOps = await RecoveryRpc("get_account_history", - new JsonArray(username, -1, 1000, RecoveryRules.OwnerOpsFilter, 0)); - ownerSince = RecoveryRules.OwnerSince(ownerOps, provenKeyPub!, validUntil.Value); + 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) { - await SendRecoveryUnavailable(ctx); - return; + ownerSince = null; } var payload = new JsonObject @@ -319,18 +379,18 @@ public static async Task RecoveryComplete(HttpContext ctx) return; } - JsonNode? accounts; + JsonNode? account; try { - accounts = await RecoveryRpc("get_accounts", new JsonArray(new JsonArray(username))); + 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; } - var account = accounts is JsonArray list && list.Count > 0 ? list[0] : null; - if (!RecoveryRules.IsSingleKeyAuthority(JsVal.Prop(account, "owner"), newOwnerPub)) + if (!RecoveryRules.IsRecoveredOwner(JsVal.Prop(account, "owner"), newOwnerPub)) { await SendRecoveryError(ctx, 409, "not_recovered"); return; diff --git a/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs b/dotnet/EcencyApi/Infrastructure/HiveCrypto.cs index 7a586d03..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 { @@ -170,7 +179,9 @@ private static bool IsCanonical(ReadOnlySpan c) => /// public static bool IsPublicKey(string? key) { - if (key == null || !key.StartsWith(PublicKeyPrefix, StringComparison.Ordinal)) + // 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; } diff --git a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs index fc4e28b1..4f4e5b9d 100644 --- a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs +++ b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs @@ -8,7 +8,8 @@ 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, and it was replaced after the account's most recent recovery. +/// 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 { @@ -24,9 +25,9 @@ public static partial class RecoveryRules [GeneratedRegex(@"\A[0-9]{6}\z")] private static partial Regex CodePattern(); - public static bool IsRequestId(string? id) => id != null && RequestIdPattern().IsMatch(id); + public static bool IsRequestId(string? id) => id is { Length: 36 } && RequestIdPattern().IsMatch(id); - public static bool IsCode(string? code) => code != null && CodePattern().IsMatch(code); + 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) => @@ -80,20 +81,32 @@ public static List AuthorityKeys(JsonNode? authority) } /// - /// True when the authority's threshold is 1 and it holds exactly one key, the given one, - /// with weight 1. Account auths are not looked at. + /// 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 IsSingleKeyAuthority(JsonNode? authority, string key) + public static bool IsRecoveredOwner(JsonNode? authority, string key) { - if (JsVal.AsNumber(JsVal.Prop(authority, "weight_threshold")) != 1 - || JsVal.Prop(authority, "key_auths") is not JsonArray auths || auths.Count != 1) + 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 pair && pair.Count == 2 + 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) - && pair[1] is JsonValue wv && JsVal.AsNumber(wv) == 1; + && 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; } /// @@ -123,38 +136,17 @@ private static (DateTime Time, string Name, JsonObject Payload) ReadHistoryItem( private static JsonArray HistoryItems(JsonNode? history) => history as JsonArray ?? throw new FormatException("account history is not a list"); - /// - /// Timestamp of the latest recover_account in an answer filtered to that operation, or - /// null when there is none. Throws when the answer cannot be read. - /// - public static DateTime? LatestRecoverAccountTime(JsonNode? history) - { - DateTime? latest = null; - foreach (var item in HistoryItems(history)) - { - var (time, name, _) = ReadHistoryItem(item); - if (name != "recover_account") - { - throw new FormatException("unexpected operation in filtered history"); - } - if (latest == null || time > latest) - { - latest = time; - } - } - return latest; - } - /// account_create, account_update, create_claimed_account, recover_account, account_update2. public const long OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 43); /// - /// When the proven key became an owner key: the time of the latest operation before - /// validUntil whose new owner authority holds the key, from an answer filtered to - /// . Null when no such operation is in the answer. Throws when - /// the answer cannot be read. + /// When the proven key became an owner key of the account: the time of the latest + /// operation before validUntil whose new owner authority holds the key, from an answer + /// filtered to . Accounts the account created are not its own + /// owner changes. Null when no such operation is in the answer. Throws when the answer + /// cannot be read. /// - public static DateTime? OwnerSince(JsonNode? history, string provenKeyPub, DateTime validUntil) + public static DateTime? OwnerSince(JsonNode? history, string username, string provenKeyPub, DateTime validUntil) { DateTime? latest = null; foreach (var item in HistoryItems(history)) @@ -167,6 +159,10 @@ private static JsonArray HistoryItems(JsonNode? history) => "recover_account" => ("new_owner_authority", true), _ => throw new FormatException("unexpected operation in filtered history"), }; + if (field == "owner" && required && !CreatesAccount(payload, username)) + { + continue; + } var authority = JsVal.Prop(payload, field); if (authority == null && !required) { @@ -185,54 +181,143 @@ private static JsonArray HistoryItems(JsonNode? history) => return latest; } + private static bool CreatesAccount(JsonObject payload, string username) + { + if (JsVal.Prop(payload, "new_account_name") is not JsonValue nv || !JsVal.TryGetStringLenient(nv, out var name)) + { + throw new FormatException("unreadable new account name"); + } + return name == username; + } + + /// 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); + /// - /// 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). + /// A get_owner_history answer. Throws on anything unreadable: a skipped entry could turn a + /// read problem into a refusal. /// - private static IEnumerable<(JsonNode Entry, DateTime LastValid)> UsableEntries( - JsonNode? ownerHistory, DateTime headTime, DateTime? lastRecovery) + public static List ReadOwnerHistory(JsonNode? history) { - if (ownerHistory is not JsonArray entries) + var list = new List(); + if (history is not JsonArray entries) { - yield break; + throw new FormatException("owner history is not a list"); } foreach (var entry in entries) { - if (entry == null) - { - continue; - } - var lastValid = ParseChainTime(JsVal.Prop(entry, "last_valid_time")); - if (lastValid == null - || lastValid.Value + RecoveryWindow <= headTime - || (lastRecovery != null && lastValid.Value <= lastRecovery.Value)) + 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]))) { - continue; + throw new FormatException("unreadable owner key"); } - yield return (entry, lastValid.Value); + keys.Add((key, JsVal.AsNumber(pair[1])!.Value)); } + return new OwnerAuthority(JsVal.AsNumber(tv)!.Value, keys); } - public static bool HasRecentOwnerChange(JsonNode? ownerHistory, DateTime headTime, DateTime? lastRecovery) => - UsableEntries(ownerHistory, headTime, lastRecovery).Any(); + 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, + } /// - /// The latest last_valid_time among usable entries whose previous owner authority holds - /// the proven key, or null when the key cannot be used. + /// 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 DateTime? ProvenKeyValidUntil(JsonNode? ownerHistory, string provenKeyPub, DateTime headTime, + 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; - foreach (var (entry, lastValid) in UsableEntries(ownerHistory, headTime, lastRecovery)) + var insufficient = false; + foreach (var entry in UsableEntries(ownerHistory, headTime, lastRecovery)) { - if (AuthorityKeys(JsVal.Prop(entry, "previous_owner_authority")).Contains(provenKeyPub) - && (best == null || lastValid > best)) + if (entry.Previous.WeightOf(provenKeyPub) == null) + { + continue; + } + if (!entry.Previous.IsSatisfiedBy(provenKeyPub)) { - best = lastValid; + insufficient = true; } + else if (best == null || entry.LastValid > best) + { + best = entry.LastValid; + } + } + if (best != null) + { + return (Verdict.Ok, best); } - return best; + return (insufficient ? Verdict.KeyNotSufficient : Verdict.KeyNotRecent, null); } } From 11adbeadc1a54bfa7cdda8e6b93907027bc16041 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 11 Nov 2026 08:44:00 +0000 Subject: [PATCH 4/5] Recovery: owner_since is when the proven key last entered the owner authority A later owner update that keeps the key no longer moves it forward. When the history read does not reach back to where the key was added, it is unknown (null). Only owner changes of the account itself count. --- dotnet/EcencyApi.Tests/RecoveryTests.cs | 65 +++++++++++++++++-- .../EcencyApi/Infrastructure/RecoveryRules.cs | 59 +++++++++++------ 2 files changed, 99 insertions(+), 25 deletions(-) diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs index bcdded44..58e99768 100644 --- a/dotnet/EcencyApi.Tests/RecoveryTests.cs +++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs @@ -393,7 +393,7 @@ internal static JsonArray Op(int index, string timestamp, string name, JsonObjec private const string K0 = "STM8ZSw7FmkxEFtkGpo7Gc9Jbs6bHjEu5XfGjNcKqZaHB3wnSXN6W"; private const string Taken = "STM7xA2DJ2zG3uNoCzFfQ2BVwXqLHxvVW3rRKfS6kNY9iFMRtCsGq"; - private static JsonArray HackedAccountOps() => new( + internal static JsonArray HackedAccountOps() => new( Op(1, "2024-03-01T10:00:00", "create_claimed_account", new JsonObject { ["creator"] = "ecency", ["new_account_name"] = Username, @@ -464,11 +464,69 @@ public void OwnerSinceFromARecoveryAndAnAccountCreate() })); 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)); - Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), RecoveryRules.OwnerSince(ops, Username, K0, 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 OwnerSinceIgnoresAccountsTheUserCreated() { @@ -544,8 +602,7 @@ public class RecoveryHandlerTests : IDisposable 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 = new JsonArray(RecoveryRulesTests.Op(77, "2026-02-14T18:30:00", "account_update", - new JsonObject { ["account"] = RecoveryRulesTests.Username, ["owner"] = RecoveryRulesTests.Authority(Old) })); + 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 << 43); diff --git a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs index 4f4e5b9d..c495ad49 100644 --- a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs +++ b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs @@ -140,26 +140,29 @@ private static JsonArray HistoryItems(JsonNode? history) => public const long OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 43); /// - /// When the proven key became an owner key of the account: the time of the latest - /// operation before validUntil whose new owner authority holds the key, from an answer - /// filtered to . Accounts the account created are not its own - /// owner changes. Null when no such operation is in the answer. Throws when the answer - /// cannot be read. + /// 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) { - DateTime? latest = null; + var changes = new List<(DateTime Time, bool Creation, bool HoldsKey)>(); foreach (var item in HistoryItems(history)) { var (time, name, payload) = ReadHistoryItem(item); - var (field, required) = name switch + var (subject, field, required) = name switch { - "account_create" or "create_claimed_account" => ("owner", true), - "account_update" or "account_update2" => ("owner", false), - "recover_account" => ("new_owner_authority", true), + "account_create" or "create_claimed_account" => ("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"), }; - if (field == "owner" && required && !CreatesAccount(payload, username)) + // An account's history also holds the accounts it created. + if (StringField(payload, subject) != username) { continue; } @@ -172,24 +175,38 @@ private static JsonArray HistoryItems(JsonNode? history) => { throw new FormatException("unreadable owner authority"); } - if (time < validUntil && AuthorityKeys(authority).Contains(provenKeyPub) - && (latest == null || time > latest)) + if (time < validUntil) { - latest = time; + changes.Add((time, required && field == "owner", AuthorityKeys(authority).Contains(provenKeyPub))); } } - return latest; - } - private static bool CreatesAccount(JsonObject payload, string username) - { - if (JsVal.Prop(payload, "new_account_name") is not JsonValue nv || !JsVal.TryGetStringLenient(nv, out var name)) + // 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)) { - throw new FormatException("unreadable new account name"); + if (!change.HoldsKey) + { + open = false; + start = null; + } + else if (!open) + { + open = true; + start = change.Creation || previousHeldKey == false ? change.Time : null; + } + previousHeldKey = change.HoldsKey; } - return name == username; + 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) { From 7dbe47f7c315a33cb6ca010820ab715d5ee7c505 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 11 Nov 2026 09:07:30 +0000 Subject: [PATCH 5/5] Recovery: log read failures by category only, count delegated account creation The chain read failure log carries the method, the exception type and a fixed category, never the exception message. owner_since also reads account_create_with_delegation, and the history filter includes it. --- dotnet/EcencyApi.Tests/RecoveryTests.cs | 61 +++++++++++++++++-- .../EcencyApi/Handlers/PrivateApi.Recovery.cs | 20 ++++-- .../EcencyApi/Infrastructure/RecoveryRules.cs | 12 +++- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs index 58e99768..0082b855 100644 --- a/dotnet/EcencyApi.Tests/RecoveryTests.cs +++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs @@ -352,16 +352,35 @@ public void RecoveredOwnerHoldsOnlyTheNewKeyAndNoAccounts() } [Fact] - public void ReadFailureLogNamesTheMethodButNotTheAccount() + public void ReadFailureLogNamesTheMethodAndCauseButNoMessage() { var (key, line) = PrivateApi.RecoveryReadFailureLog("get_account_history", - new InvalidOperationException($"could not find filtered operation for {Username}"), Username); - Assert.Contains("get_account_history", line); - Assert.Contains("InvalidOperationException", line); + 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"), Username).Key); + 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] @@ -527,6 +546,36 @@ public void OwnerSinceIgnoresOwnerUpdatesOfOtherAccounts() 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() { @@ -605,7 +654,7 @@ public class RecoveryHandlerTests : IDisposable 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 << 43); + 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() diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs index 63f8aaff..8d049127 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs @@ -77,7 +77,7 @@ private static async Task RecoveryRead(string method, JsonArray @params, s } catch (Exception e) { - var (key, line) = RecoveryReadFailureLog(method, e, username); + var (key, line) = RecoveryReadFailureLog(method, e); LogRecoveryFailure(key, line); throw; } @@ -85,13 +85,23 @@ private static async Task RecoveryRead(string method, JsonArray @params, s /// /// The log line for a failed recovery chain read, and the key it is written once under: - /// the method and the cause, never the account, keys or client. + /// 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, string username) + internal static (string Key, string Line) RecoveryReadFailureLog(string method, Exception e) { var type = e.GetType().Name; - var message = e.Message.Replace(username, "", StringComparison.OrdinalIgnoreCase); - return ($"recovery {method} [{type}]", $"warn: recovery chain read failed: {method} ({type}: {message})"); + 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 diff --git a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs index c495ad49..155ee901 100644 --- a/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs +++ b/dotnet/EcencyApi/Infrastructure/RecoveryRules.cs @@ -136,8 +136,13 @@ private static (DateTime Time, string Name, JsonObject Payload) ReadHistoryItem( private static JsonArray HistoryItems(JsonNode? history) => history as JsonArray ?? throw new FormatException("account history is not a list"); - /// account_create, account_update, create_claimed_account, recover_account, account_update2. - public const long OwnerOpsFilter = (1L << 9) | (1L << 10) | (1L << 23) | (1L << 25) | (1L << 43); + /// + /// 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 @@ -156,7 +161,8 @@ private static JsonArray HistoryItems(JsonNode? history) => var (time, name, payload) = ReadHistoryItem(item); var (subject, field, required) = name switch { - "account_create" or "create_claimed_account" => ("new_account_name", "owner", true), + "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"),