Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
101 changes: 97 additions & 4 deletions dotnet/EcencyApi.Tests/RecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@

public RecoveryHandlerTests()
{
RecoveryAccountsAre("ecency");
PrivateApi.RecoveryCaptchaRejected = (_, _) => Task.FromResult(_captchaRejected);
PrivateApi.RecoveryUpstream = (endpoint, method, payload) =>
{
Expand All @@ -685,12 +686,15 @@
"get_dynamic_global_properties" => new JsonObject { ["time"] = HeadTime },
_ => throw new InvalidOperationException(method),
};
return Task.FromResult(result);

Check warning on line 689 in dotnet/EcencyApi.Tests/RecoveryTests.cs

View workflow job for this annotation

GitHub Actions / test

Nullability of reference types in value of type 'Task<JsonNode>' doesn't match target type 'Task<JsonNode?>'.
};
}

public void Dispose() => PrivateApi.ResetRecoverySeams();

private static void RecoveryAccountsAre(params string[] names) =>
PrivateApi.RecoveryAccounts = new HashSet<string>(names);

private JsonNode HistoryFor(JsonArray @params)
{
var filter = JsVal.AsNumber(@params[3]) ?? -1;
Expand Down Expand Up @@ -725,7 +729,8 @@
["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) =>
Expand All @@ -739,6 +744,7 @@
["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);
Expand Down Expand Up @@ -837,7 +843,8 @@
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);
}
Expand Down Expand Up @@ -1146,9 +1153,9 @@
Assert.Single(_privateApi);
}

private async Task<JsonObject> ProveExpectingOwnerSince(string? expected)
private async Task<JsonObject> 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"]}""")
Expand Down Expand Up @@ -1335,4 +1342,90 @@
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()));
}
}
27 changes: 27 additions & 0 deletions dotnet/EcencyApi/Config.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using EcencyApi.Infrastructure;

namespace EcencyApi;

/// <summary>
Expand Down Expand Up @@ -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");

/// <summary>
/// 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".
/// </summary>
public static IReadOnlySet<string> RecoveryAccounts { get; } = ParseRecoveryAccounts(Env("RECOVERY_ACCOUNTS"));

internal static IReadOnlySet<string> ParseRecoveryAccounts(string? value)
{
var names = new HashSet<string>(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);
Expand Down
30 changes: 24 additions & 6 deletions dotnet/EcencyApi/Handlers/PrivateApi.Recovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,20 @@ namespace EcencyApi.Handlers;
/// </summary>
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<string, HttpMethod, JsonNode?, Task<UpstreamResponse>> RecoveryUpstream = DefaultRecoveryUpstream;
internal static Func<string, JsonArray, Task<JsonNode?>> RecoveryRpc = DefaultRecoveryRpc;
internal static Func<JsonNode?, string, Task<bool>> RecoveryCaptchaRejected = DefaultRecoveryCaptchaRejected;
internal static IReadOnlySet<string> RecoveryAccounts = Config.RecoveryAccounts;

internal static void ResetRecoverySeams()
{
RecoveryUpstream = DefaultRecoveryUpstream;
RecoveryRpc = DefaultRecoveryRpc;
RecoveryCaptchaRejected = DefaultRecoveryCaptchaRejected;
RecoveryAccounts = Config.RecoveryAccounts;
}

private static Task<UpstreamResponse> DefaultRecoveryUpstream(string endpoint, HttpMethod method, JsonNode? payload) =>
Expand Down Expand Up @@ -141,10 +142,16 @@ private static async Task SendRecoveryUnavailable(HttpContext ctx)
}
}

private static bool IsEcencyRecovered(JsonObject account) =>
/// <summary>
/// The account's on-chain recovery account when it is one this service recovers for
/// (<see cref="Config.RecoveryAccounts"/>), else null.
/// </summary>
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;

/// <summary>
/// The internal row for a request. Returns null after answering the client: 404 when
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions dotnet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading