diff --git a/README.md b/README.md index 97df0699..0a1db91f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ docker run -it --rm -p 4000:4000 \ | `DESK_MEMO_BYTES` | byte budget of each curation desk memo store (fresh and last-good), LRU beyond it (default 64 MiB) | | `TURNSTILE_SECRET` | Cloudflare Turnstile secret for account-create captcha | | `CAPTCHA_MODE` | `hard` (default) or `off` (operator break-glass) | +| `RECOVERY_ACCOUNTS` | comma-separated recovery accounts the self-serve account recovery serves, e.g. `ecency,appreciator` (default `ecency`; invalid names are ignored) | | `BLOCKSTREAM_CLIENT_ID` / `BLOCKSTREAM_CLIENT_SECRET` | optional Blockstream Enterprise esplora auth (BTC fallback) | | `HELIUS_API_KEY` | optional Helius API key added as an extra Solana RPC fallback | | `ETH_RPC_URLS` / `BNB_RPC_URLS` / `SOL_RPC_URLS` / `BTC_ESPLORA_URLS` | optional comma-separated endpoint lists overriding the built-in chain provider pools | diff --git a/dotnet/EcencyApi.Tests/RecoveryTests.cs b/dotnet/EcencyApi.Tests/RecoveryTests.cs index 0082b855..4fdf013b 100644 --- a/dotnet/EcencyApi.Tests/RecoveryTests.cs +++ b/dotnet/EcencyApi.Tests/RecoveryTests.cs @@ -659,6 +659,7 @@ public class RecoveryHandlerTests : IDisposable public RecoveryHandlerTests() { + RecoveryAccountsAre("ecency"); PrivateApi.RecoveryCaptchaRejected = (_, _) => Task.FromResult(_captchaRejected); PrivateApi.RecoveryUpstream = (endpoint, method, payload) => { @@ -691,6 +692,9 @@ public RecoveryHandlerTests() public void Dispose() => PrivateApi.ResetRecoverySeams(); + private static void RecoveryAccountsAre(params string[] names) => + PrivateApi.RecoveryAccounts = new HashSet(names); + private JsonNode HistoryFor(JsonArray @params) { var filter = JsVal.AsNumber(@params[3]) ?? -1; @@ -725,7 +729,8 @@ private static string ProveBody(string? sig = null, string newPub = New, string ["signature"] = sig ?? RecoveryRulesTests.Signature, }.ToJsonString(); - private void RowIs(string status, string username = RecoveryRulesTests.Username, string? newOwnerPub = null) + private void RowIs(string status, string username = RecoveryRulesTests.Username, string? newOwnerPub = null, + string? recoveryAccount = null) { var inner = _answer; _answer = (endpoint, method) => @@ -739,6 +744,7 @@ private void RowIs(string status, string username = RecoveryRulesTests.Username, ["status"] = status, }; if (newOwnerPub != null) row["new_owner_pub"] = newOwnerPub; + if (recoveryAccount != null) row["recovery_account"] = recoveryAccount; return T.JsonResponse(200, row.ToJsonString()); } return inner(endpoint, method); @@ -837,7 +843,8 @@ public async Task StartForwardsUsernameAndIpAndPipes() var call = Assert.Single(_privateApi); Assert.Equal("recovery-request/start", call.Endpoint); Assert.Equal(HttpMethod.Post, call.Method); - Assert.Equal("""{"username":"recoverytest","ip":"203.0.113.9"}""", call.Payload!.ToJsonString()); + Assert.Equal("""{"username":"recoverytest","ip":"203.0.113.9","recovery_account":"ecency"}""", + call.Payload!.ToJsonString()); Assert.Contains("request_id", Body(ctx)); Assert.DoesNotContain("get_account_history", _rpc); } @@ -1146,9 +1153,9 @@ public async Task ProveWithAnUnreadableLastRecoveryIsUnavailable() Assert.Single(_privateApi); } - private async Task ProveExpectingOwnerSince(string? expected) + private async Task ProveExpectingOwnerSince(string? expected, string? rowRecoveryAccount = null) { - RowIs("started"); + RowIs("started", recoveryAccount: rowRecoveryAccount); var inner = _answer; _answer = (endpoint, method) => method == HttpMethod.Post ? T.JsonResponse(200, """{"emails":["o****9@gmail.com"]}""") @@ -1335,4 +1342,90 @@ public async Task CompleteChainDownIsUnavailable() Assert.Equal(503, ctx.Response.StatusCode); Assert.Single(_privateApi); } + + // ---- partner recovery accounts ---- + + [Fact] + public async Task StartForAPartnerRecoveryAccountForwardsIt() + { + RecoveryAccountsAre("ecency", "appreciator"); + _recoveryAccount = "appreciator"; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_privateApi); + Assert.Equal("""{"username":"recoverytest","ip":"203.0.113.9","recovery_account":"appreciator"}""", + call.Payload!.ToJsonString()); + } + + [Fact] + public async Task StartRefusesARecoveryAccountOutsideTheList() + { + RecoveryAccountsAre("ecency"); + _recoveryAccount = "appreciator"; + var ctx = StartPost("""{"username":"recoverytest","captcha_token":"t"}"""); + await PrivateApi.RecoveryStart(ctx); + Assert.Equal(422, ctx.Response.StatusCode); + Assert.Equal("""{"error":"not_ecency_recovery"}""", Body(ctx)); + Assert.Empty(_privateApi); + } + + [Fact] + public async Task ProveRefusesARecoveryAccountOutsideTheList() + { + RecoveryAccountsAre("appreciator"); + RowIs("started"); + 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)); + Assert.Single(_privateApi); + } + + [Fact] + public async Task ProveWhenTheRowMatchesThePartnerRecoveryAccount() + { + RecoveryAccountsAre("ecency", "appreciator"); + _recoveryAccount = "appreciator"; + await ProveExpectingOwnerSince("2026-02-14T18:30:00", rowRecoveryAccount: "appreciator"); + } + + [Fact] + public async Task ProveWithoutARowRecoveryAccountMeansEcency() + { + await ProveExpectingOwnerSince("2026-02-14T18:30:00"); + } + + [Theory] + [InlineData("ecency", "appreciator")] + [InlineData("appreciator", "ecency")] + [InlineData(null, "appreciator")] + public async Task ProveRefusesARecoveryAccountChangedSinceStart(string? rowAccount, string chainAccount) + { + RecoveryAccountsAre("ecency", "appreciator"); + _recoveryAccount = chainAccount; + RowIs("started", recoveryAccount: rowAccount); + var ctx = T.Post("/private-api/recovery/prove", ProveBody()); + await PrivateApi.RecoveryProve(ctx); + Assert.Equal(409, ctx.Response.StatusCode); + Assert.Equal("""{"error":"recovery_account_changed"}""", Body(ctx)); + Assert.Single(_privateApi); + } +} + +public class RecoveryAccountsConfigTests +{ + [Theory] + [InlineData(null, "ecency")] + [InlineData("", "ecency")] + [InlineData(" ", "ecency")] + [InlineData("ecency,appreciator", "appreciator,ecency")] + [InlineData(" Ecency , APPRECIATOR ,", "appreciator,ecency")] + [InlineData("appreciator", "appreciator")] + [InlineData("appreciator,bad name!,x,-dash", "appreciator")] + [InlineData("bad name!,x", "ecency")] + public void ParsesTheAllowlist(string? value, string expected) + { + Assert.Equal(expected, string.Join(",", Config.ParseRecoveryAccounts(value).Order())); + } } diff --git a/dotnet/EcencyApi/Config.cs b/dotnet/EcencyApi/Config.cs index b8e609d3..af613269 100644 --- a/dotnet/EcencyApi/Config.cs +++ b/dotnet/EcencyApi/Config.cs @@ -1,3 +1,5 @@ +using EcencyApi.Infrastructure; + namespace EcencyApi; /// @@ -104,6 +106,31 @@ public static class Config public static bool SsrCallClasses { get; } = Env("SSR_RPC_CALL_CLASSES")?.Trim().ToLowerInvariant() is not ("0" or "false" or "off"); + /// + /// Accounts whose recovered users the self-serve recovery accepts (an account's on-chain + /// recovery_account must be one of them), comma-separated, e.g. "ecency,appreciator". + /// Invalid names are ignored; unset, empty or nothing valid means "ecency". + /// + public static IReadOnlySet RecoveryAccounts { get; } = ParseRecoveryAccounts(Env("RECOVERY_ACCOUNTS")); + + internal static IReadOnlySet ParseRecoveryAccounts(string? value) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var part in (value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var name = part.ToLowerInvariant(); + if (HiveNames.IsAccountName(name)) + { + names.Add(name); + } + } + if (names.Count == 0) + { + names.Add("ecency"); + } + return names; + } + private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string? Env(string name) => Environment.GetEnvironmentVariable(name); diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs index 8d049127..149d386a 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs @@ -13,19 +13,20 @@ namespace EcencyApi.Handlers; /// 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 IReadOnlySet RecoveryAccounts = Config.RecoveryAccounts; internal static void ResetRecoverySeams() { RecoveryUpstream = DefaultRecoveryUpstream; RecoveryRpc = DefaultRecoveryRpc; RecoveryCaptchaRejected = DefaultRecoveryCaptchaRejected; + RecoveryAccounts = Config.RecoveryAccounts; } private static Task DefaultRecoveryUpstream(string endpoint, HttpMethod method, JsonNode? payload) => @@ -141,10 +142,16 @@ private static async Task SendRecoveryUnavailable(HttpContext ctx) } } - private static bool IsEcencyRecovered(JsonObject account) => + /// + /// The account's on-chain recovery account when it is one this service recovers for + /// (), else null. + /// + private static string? AllowedRecoveryAccount(JsonObject account) => JsVal.Prop(account, "recovery_account") is JsonValue rv && JsVal.TryGetStringLenient(rv, out var ra) - && ra == RecoveryAccountName; + && RecoveryAccounts.Contains(ra) + ? ra + : null; /// /// The internal row for a request. Returns null after answering the client: 404 when @@ -219,7 +226,9 @@ public static async Task RecoveryStart(HttpContext ctx) await SendRecoveryError(ctx, 404, "not_found"); return; } - if (!IsEcencyRecovered(chain.Account)) + // The code keeps its original name; clients map it to the manual help screen. + var recoveryAccount = AllowedRecoveryAccount(chain.Account); + if (recoveryAccount == null) { await SendRecoveryError(ctx, 422, "not_ecency_recovery"); return; @@ -234,7 +243,8 @@ public static async Task RecoveryStart(HttpContext ctx) return; } - var payload = new JsonObject { ["username"] = username, ["ip"] = ip }; + // The private API routes the request to the signer of this recovery account. + var payload = new JsonObject { ["username"] = username, ["ip"] = ip, ["recovery_account"] = recoveryAccount }; await Upstream.Pipe(RecoveryUpstream("recovery-request/start", HttpMethod.Post, payload), ctx); } @@ -289,11 +299,19 @@ public static async Task RecoveryProve(HttpContext ctx) await SendRecoveryError(ctx, 404, "not_found"); return; } - if (!IsEcencyRecovered(chain.Account)) + var recoveryAccount = AllowedRecoveryAccount(chain.Account); + if (recoveryAccount == null) { await SendRecoveryError(ctx, 422, "not_ecency_recovery"); return; } + // The request belongs to the signer of the recovery account it started with; a change + // since then would leave it with the wrong one. Rows from before the field are ecency's. + if (recoveryAccount != (row.Str("recovery_account") ?? "ecency")) + { + await SendRecoveryError(ctx, 409, "recovery_account_changed"); + return; + } if (RecoveryRules.AuthorityKeys(JsVal.Prop(chain.Account, "owner")).Contains(newOwnerPub!)) { await SendRecoveryError(ctx, 400, "invalid_request"); diff --git a/dotnet/README.md b/dotnet/README.md index 9aac7546..2d01f943 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -216,6 +216,7 @@ the same defaults, as the Node service (`Config.cs` mirrors `src/config.ts`): | `STRIPE_INTERNAL_SECRET` | shared secret for the Stripe money endpoints; unset = those routes fail closed with 503 | unset | | `TURNSTILE_SECRET` | Cloudflare Turnstile secret for account-create captcha | unset | | `CAPTCHA_MODE` | `hard` (default) or `off` (operator break-glass) | `hard` | +| `RECOVERY_ACCOUNTS` | comma-separated recovery accounts the self-serve account recovery serves, e.g. `ecency,appreciator`; invalid names are ignored | `ecency` | | `BLOCKSTREAM_CLIENT_ID` / `BLOCKSTREAM_CLIENT_SECRET` | optional Blockstream enterprise esplora auth | unset | | `HELIUS_API_KEY` | optional extra Solana RPC provider | unset | | `ETH_RPC_URLS` / `BNB_RPC_URLS` / `SOL_RPC_URLS` / `BTC_ESPLORA_URLS` | comma-separated overrides for the chain provider pools | built-in pools | diff --git a/dotnet/docker-compose.yml b/dotnet/docker-compose.yml index 696e6bfe..e4e83bf4 100644 --- a/dotnet/docker-compose.yml +++ b/dotnet/docker-compose.yml @@ -20,6 +20,8 @@ services: - STRIPE_INTERNAL_SECRET - TURNSTILE_SECRET - CAPTCHA_MODE + # Self-serve account recovery: recovery accounts it serves (default ecency). + - RECOVERY_ACCOUNTS - BLOCKSTREAM_CLIENT_ID - BLOCKSTREAM_CLIENT_SECRET - HELIUS_API_KEY