From e518e938dca2100b1da124935f5788a265cac187 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 8 Nov 2026 10:15:57 +0200 Subject: [PATCH 1/3] Answer 503, not 401, when a session code's keys could not be read --- CLAUDE.md | 7 + .../TokenCheckUnavailableTests.cs | 205 ++++++++++++++++++ dotnet/EcencyApi/Handlers/PrivateApi.Core.cs | 74 ++++++- .../EcencyApi/Infrastructure/ServerErrors.cs | 51 +++++ dotnet/EcencyApi/Program.cs | 24 +- 5 files changed, 329 insertions(+), 32 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs create mode 100644 dotnet/EcencyApi/Infrastructure/ServerErrors.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7df35c21..97cc8b09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,3 +127,10 @@ without a type. Handlers using `RequireAuthedUsername` differ from those calling `ValidateCode` directly only in that the former sends the 401 for you - the validation is the same, so it is never the explanation for one route failing while another passes. + +A 401 from `ValidateCode` is always a verdict from the chain's answer (no such +account, a key that does not match, a malformed or non-session code). When the +keys could not be read at all (node error, timeout, an unusable answer), it +throws `TokenCheckUnavailableException` and `ServerErrors` answers 503 +"Service Unavailable" with `Retry-After`, so clients do not take a node outage +for a refused session. Do not catch that exception in a handler. diff --git a/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs new file mode 100644 index 00000000..ecf2c7d5 --- /dev/null +++ b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs @@ -0,0 +1,205 @@ +using System.Text; +using System.Text.Json.Nodes; +using EcencyApi.Handlers; +using EcencyApi.Infrastructure; +using Microsoft.AspNetCore.Http; +using NBitcoin.Secp256k1; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// A session code whose keys could not be read is a server error, not a refused +/// session: the request answers 503 through the pipeline and the handler goes no +/// further. A code the chain does answer for (unknown account, wrong key) is +/// still a 401. Clients sign the user out on 401, so a node outage answered as +/// 401 signed everyone out. +/// +[Collection("session-token")] +public class TokenCheckUnavailableTests : IDisposable +{ + private static readonly ECPrivKey Key = HiveCrypto.FromLogin("alice", "test-password"); + private static readonly string Pub = HiveCrypto.PublicKeyFromLogin("alice", "test-password"); + private static readonly ECPrivKey HsKey = HiveCrypto.FromLogin("hivesigner", "test-password"); + private static readonly string HsPub = HiveCrypto.PublicKeyFromLogin("hivesigner", "test-password"); + private static readonly ECPrivKey OtherKey = HiveCrypto.FromLogin("mallory", "test-password"); + + public TokenCheckUnavailableTests() => PrivateApi.ResetHivesignerCache(); + + public void Dispose() + { + PrivateApi.ValidationAccounts = names => HiveClients.Default.GetAccounts(names); + PrivateApi.ResetHivesignerCache(); + } + + private static JsonObject Account(string name, string pub) => new() + { + ["name"] = name, + ["posting"] = new JsonObject { ["key_auths"] = new JsonArray(new JsonArray(pub, 1)) }, + }; + + private static JsonArray Known(string? name) => + new(Account(name!, name == "hivesigner" ? HsPub : Pub)); + + private static string Code(ECPrivKey key) + { + var message = new JsonObject + { + ["signed_message"] = new JsonObject { ["type"] = "code", ["app"] = "ecency.app" }, + ["authors"] = new JsonArray("alice"), + ["timestamp"] = 1726650000, + }; + var digest = HiveCrypto.Sha256Utf8(JsJson.Stringify(message)); + message["signatures"] = new JsonArray(HiveCrypto.Sign(key, digest)); + return B64u.Encode(JsJson.Stringify(message)); + } + + private static async Task<(int Status, string Body, string RetryAfter)> Run( + RequestDelegate handler, JsonObject body) + { + var ctx = new DefaultHttpContext(); + var bytes = Encoding.UTF8.GetBytes(body.ToJsonString()); + ctx.Request.Method = "POST"; + ctx.Request.ContentType = "application/json"; + ctx.Request.Body = new MemoryStream(bytes); + ctx.Request.ContentLength = bytes.Length; + ctx.Response.Body = new MemoryStream(); + + await ServerErrors.Handle(ctx, () => handler(ctx)); + + ctx.Response.Body.Position = 0; + var text = await new StreamReader(ctx.Response.Body).ReadToEndAsync(); + return (ctx.Response.StatusCode, text, ctx.Response.Headers.RetryAfter.ToString()); + } + + private static Task<(int Status, string Body, string RetryAfter)> Unread(ECPrivKey key) => + Run(PrivateApi.UnreadNotifications, new JsonObject { ["code"] = Code(key) }); + + // A spend: reaching the upstream would charge Points. + private static Task<(int Status, string Body, string RetryAfter)> StreakFreezeBuy(ECPrivKey key) => + Run(PrivateApi.StreakFreezeBuy, + new JsonObject { ["code"] = Code(key), ["idempotency_key"] = "k-1" }); + + private static void AssertUnavailable((int Status, string Body, string RetryAfter) r) + { + Assert.Equal(503, r.Status); + // Only the 503 was written: the handler stopped before any upstream call. + Assert.Equal("Service Unavailable", r.Body); + Assert.Equal(ServerErrors.RetryAfterSeconds.ToString(), r.RetryAfter); + } + + [Fact] + public async Task ANodeFailureIsUnavailableNotUnauthorized() + { + PrivateApi.ValidationAccounts = _ => throw new HttpRequestException("node down"); + + AssertUnavailable(await Unread(Key)); + AssertUnavailable(await StreakFreezeBuy(Key)); + } + + [Fact] + public async Task ATimeoutIsUnavailable() + { + PrivateApi.ValidationAccounts = _ => throw new TaskCanceledException("timed out"); + + AssertUnavailable(await Unread(Key)); + } + + [Fact] + public async Task ANonIterableAnswerIsUnavailable() + { + PrivateApi.ValidationAccounts = _ => Task.FromResult(null); + + AssertUnavailable(await Unread(Key)); + AssertUnavailable(await StreakFreezeBuy(Key)); + } + + [Fact] + public async Task AnAccountWithoutPostingKeysIsAGarbageAnswer() + { + PrivateApi.ValidationAccounts = _ => + Task.FromResult(new JsonArray(new JsonObject { ["name"] = "alice" })); + + AssertUnavailable(await Unread(Key)); + } + + [Fact] + public async Task AFailedHivesignerReadWithNothingCachedIsUnavailable() + { + // alice's own key does not match, so @hivesigner's keys decide; they cannot be read. + PrivateApi.ValidationAccounts = names => + names.Single() == "hivesigner" + ? throw new HttpRequestException("node down") + : Task.FromResult(Known("alice")); + + AssertUnavailable(await Unread(HsKey)); + AssertUnavailable(await StreakFreezeBuy(HsKey)); + } + + [Fact] + public async Task AGarbageHivesignerAnswerIsUnavailableAndNotCached() + { + PrivateApi.ValidationAccounts = names => + Task.FromResult(names.Single() == "hivesigner" + ? new JsonArray(new JsonObject { ["name"] = "hivesigner" }) + : Known("alice")); + AssertUnavailable(await Unread(HsKey)); + + // The node recovers: the next check reads @hivesigner again. + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + } + + [Fact] + public async Task AFailedHivesignerRefreshKeepsTheStaleKeys() + { + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + + PrivateApi.ExpireHivesignerCache(); + PrivateApi.ValidationAccounts = names => + names.Single() == "hivesigner" + ? throw new HttpRequestException("node down") + : Task.FromResult(Known("alice")); + + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + } + + [Fact] + public async Task AWrongSignatureIsStillUnauthorized() + { + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + + Assert.Equal((401, "Unauthorized", ""), await Unread(OtherKey)); + Assert.Equal((401, "Unauthorized", ""), await StreakFreezeBuy(OtherKey)); + } + + [Fact] + public async Task AnUnknownAccountIsStillUnauthorized() + { + // The chain answered: there is no such account. + PrivateApi.ValidationAccounts = _ => Task.FromResult(new JsonArray()); + + Assert.Equal((401, "Unauthorized", ""), await Unread(Key)); + Assert.Equal((401, "Unauthorized", ""), await StreakFreezeBuy(Key)); + } + + [Fact] + public async Task AnRpcErrorIsTheNodesAnswerAndStaysUnauthorized() + { + // An RPC-level error is the node answering the call (HiveRpcClient does + // not fail over on it), so it is not an outage. + PrivateApi.ValidationAccounts = _ => throw new HiveRpcClient.RpcException("bad name"); + + Assert.Equal((401, "Unauthorized", ""), await Unread(Key)); + } + + [Fact] + public async Task AMalformedCodeIsStillUnauthorized() + { + PrivateApi.ValidationAccounts = _ => throw new HttpRequestException("never read"); + + Assert.Equal((401, "Unauthorized", ""), + await Run(PrivateApi.UnreadNotifications, new JsonObject { ["code"] = "not-a-token" })); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs index 6471e25e..bc2408a8 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs @@ -149,8 +149,11 @@ timestampNode is JsonValue timestampValue var digest = HiveCrypto.Sha256Utf8(rawMessage); var recoveredPubKey = HiveCrypto.RecoverPublicKey(signature, digest); - var accounts = await ValidationAccounts(new[] { author }) - ?? throw new InvalidOperationException("getAccounts result is not iterable"); + // A read that fails (node error, timeout, unusable answer) is not a + // verdict on the code: it throws TokenCheckUnavailableException, which + // the pipeline answers 503 (ServerErrors) instead of the 401 that tells + // clients their session was refused. Only the chain's answer decides. + var accounts = await ReadValidationAccounts(author); var account = accounts.Count > 0 ? accounts[0] : null; if (account is null) { @@ -158,9 +161,7 @@ timestampNode is JsonValue timestampValue return null; } - // account.posting.key_auths.map(([key]) => key) — unexpected shapes - // throw and land in the catch below, like the TS TypeError would. - var postingPubKeys = MapKeyAuths(account["posting"]!["key_auths"]); + var postingPubKeys = PostingKeys(account); if (recoveredPubKey != null && postingPubKeys.Contains(recoveredPubKey)) { return author; @@ -172,17 +173,19 @@ timestampNode is JsonValue timestampValue { try { - var hsAccounts = await ValidationAccounts(new[] { "hivesigner" }) - ?? throw new InvalidOperationException("getAccounts result is not iterable"); + var hsAccounts = await ReadValidationAccounts("hivesigner"); + var hsAccount = hsAccounts.Count > 0 ? hsAccounts[0] : null; + // An unusable answer is not cached for a day. + if (hsAccount is not null) PostingKeys(hsAccount); // TS caches whatever destructures out — undefined included — // and stamps the time either way. - _hivesignerAccountCache = hsAccounts.Count > 0 ? hsAccounts[0] : null; + _hivesignerAccountCache = hsAccount; _hivesignerCacheTime = currentTime; } - catch (Exception err) + catch (TokenCheckUnavailableException) when (_hivesignerAccountCache is not null) { - Console.Error.WriteLine($"Failed to fetch hivesigner account {err}"); - return null; + // A failed refresh keeps the keys already held (they change + // rarely); the time is not stamped, so the next check retries. } } @@ -199,13 +202,57 @@ timestampNode is JsonValue timestampValue $"Posting key mismatch {recoveredPubKey} [{string.Join(", ", postingPubKeys)}] [{string.Join(", ", hsPostingKeys)}]"); return null; } - catch (Exception err) + catch (Exception err) when (err is not TokenCheckUnavailableException) { Console.Error.WriteLine($"Token validation error {err}"); return null; } } + /// + /// The accounts read for ValidateCode. An RPC-level error is the node's + /// answer to the call (HiveRpcClient does not fail over on it) and stays a + /// refusal; anything else that fails, and an answer that is not an array, + /// means the keys could not be read. + /// + private static async Task ReadValidationAccounts(string name) + { + JsonArray? accounts; + try + { + accounts = await ValidationAccounts(new[] { name }); + } + catch (Exception err) when (err is not HiveRpcClient.RpcException) + { + throw Unavailable("account read failed", err); + } + + return accounts ?? throw Unavailable("account read answered no array"); + } + + /// + /// account.posting.key_auths.map(([key]) => key). Every account has posting + /// keys, so an account without them is an unusable answer, not a refusal. + /// + private static List PostingKeys(JsonNode account) + { + try + { + return MapKeyAuths(account["posting"]!["key_auths"]); + } + catch (Exception err) + { + throw Unavailable("account answer without posting keys", err); + } + } + + private static TokenCheckUnavailableException Unavailable(string reason, Exception? inner = null) + { + // The reason only, once: a node outage fails every request the same way. + LogOnce($"Token check unavailable: {reason}"); + return new TokenCheckUnavailableException(reason, inner); + } + /// Forgets the cached @hivesigner account (tests). internal static void ResetHivesignerCache() { @@ -213,6 +260,9 @@ internal static void ResetHivesignerCache() _hivesignerCacheTime = 0; } + /// Makes the cached @hivesigner account due for refresh, keeping it (tests). + internal static void ExpireHivesignerCache() => _hivesignerCacheTime = 0; + /// The chain read behind token validation (tests answer it). internal static Func, Task> ValidationAccounts = names => HiveClients.Default.GetAccounts(names); diff --git a/dotnet/EcencyApi/Infrastructure/ServerErrors.cs b/dotnet/EcencyApi/Infrastructure/ServerErrors.cs new file mode 100644 index 00000000..ec18d7c7 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/ServerErrors.cs @@ -0,0 +1,51 @@ +namespace EcencyApi.Infrastructure; + +/// +/// A session code could not be checked because the account keys it is checked +/// against could not be read (node error, timeout, unusable answer). Not a +/// verdict on the code: the request is answered 503, never 401, so a client +/// does not take a passing node problem for a refused session and sign out. +/// +public sealed class TokenCheckUnavailableException : Exception +{ + public TokenCheckUnavailableException(string message, Exception? inner = null) + : base(message, inner) { } +} + +/// The request pipeline's error handling, wrapped around every route. +public static class ServerErrors +{ + /// Seconds a client is asked to wait before retrying a 503. + public const int RetryAfterSeconds = 5; + + public static async Task Handle(HttpContext ctx, Func next) + { + try + { + await next(); + } + catch (TokenCheckUnavailableException) + { + // Quiet on purpose: during a node outage this fires per request, and + // ValidateCode already notes the cause once. + if (!ctx.Response.HasStarted) + { + ctx.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(); + await ctx.SendText(503, "Service Unavailable"); + } + } + catch (Exception e) + { + // The Node app's error middleware sends a plain 500 "Server Error" + // for anything thrown by handlers (including body-parser + // SyntaxErrors), so mirror that exactly. + Console.Error.WriteLine($"Unhandled server error: {e}"); + if (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = 500; + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync("Server Error"); + } + } + } +} diff --git a/dotnet/EcencyApi/Program.cs b/dotnet/EcencyApi/Program.cs index c1f614ec..62d85adc 100644 --- a/dotnet/EcencyApi/Program.cs +++ b/dotnet/EcencyApi/Program.cs @@ -53,26 +53,10 @@ $"PRIVATE_API_AUTH has no {ApiClient.IdentityHeader} header: backend calls that need this service's identity will be refused"); } -// Global error handling — the Node app's error middleware sends a plain -// 500 "Server Error" for anything thrown by handlers (including body-parser -// SyntaxErrors), so mirror that exactly. -app.Use(async (ctx, next) => -{ - try - { - await next(); - } - catch (Exception e) - { - Console.Error.WriteLine($"Unhandled server error: {e}"); - if (!ctx.Response.HasStarted) - { - ctx.Response.StatusCode = 500; - ctx.Response.ContentType = "text/html; charset=utf-8"; - await ctx.Response.WriteAsync("Server Error"); - } - } -}); +// Global error handling: a 503 for a session code that could not be checked, +// and the Node app's plain 500 "Server Error" for anything else a handler +// throws (see ServerErrors). +app.Use(ServerErrors.Handle); // cors() — allow-all, like the Node service app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); From b6e92647df7e674534ec013ed512020fc7368832 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 8 Nov 2026 10:28:46 +0200 Subject: [PATCH 2/3] Bound stale HiveSigner keys, treat unusable key lists and post-refresh-failure mismatches as unavailable, log the cause --- .../TokenCheckUnavailableTests.cs | 102 +++++++++++++++++- dotnet/EcencyApi/Handlers/PrivateApi.Core.cs | 70 +++++++++--- dotnet/parity/driver.py | 8 ++ 3 files changed, 165 insertions(+), 15 deletions(-) diff --git a/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs index ecf2c7d5..b373baf1 100644 --- a/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs +++ b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs @@ -156,7 +156,8 @@ public async Task AFailedHivesignerRefreshKeepsTheStaleKeys() PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); - PrivateApi.ExpireHivesignerCache(); + // Due for refresh, still inside the stale grace. + PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs - 60_000); PrivateApi.ValidationAccounts = names => names.Single() == "hivesigner" ? throw new HttpRequestException("node down") @@ -165,6 +166,105 @@ public async Task AFailedHivesignerRefreshKeepsTheStaleKeys() Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); } + private static Func, Task> HivesignerDown => names => + names.Single() == "hivesigner" + ? throw new HttpRequestException("node down") + : Task.FromResult(Known("alice")); + + [Fact] + public async Task AMismatchAgainstStaleKeysAfterAFailedRefreshIsUnavailable() + { + // Cached @hivesigner keys that no longer sign (HiveSigner rotated its key). + PrivateApi.ValidationAccounts = names => + Task.FromResult(names.Single() == "hivesigner" + ? new JsonArray(Account("hivesigner", Pub.Replace(Pub[^1], Pub[^1] == 'A' ? 'B' : 'A'))) + : Known("alice")); + Assert.Null(await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + + // The refresh that would have read the new key fails: no verdict either way. + PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs - 60_000); + PrivateApi.ValidationAccounts = HivesignerDown; + AssertUnavailable(await Unread(HsKey)); + } + + [Fact] + public async Task StaleHivesignerKeysPastTheGraceAreNotUsed() + { + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + + PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs + 60_000); + PrivateApi.ValidationAccounts = HivesignerDown; + AssertUnavailable(await Unread(HsKey)); + } + + [Theory] + [InlineData("[[123, 1]]")] + [InlineData("[[\"\", 1]]")] + [InlineData("[[null, 1]]")] + public async Task AnAccountKeyThatIsNotAKeyIsAGarbageAnswer(string keyAuths) + { + PrivateApi.ValidationAccounts = _ => Task.FromResult(new JsonArray(new JsonObject + { + ["name"] = "alice", + ["posting"] = new JsonObject { ["key_auths"] = JsonNode.Parse(keyAuths) }, + })); + + AssertUnavailable(await Unread(Key)); + } + + [Theory] + [InlineData("[[123, 1]]")] + [InlineData("[]")] + public async Task AnUnusableHivesignerKeyListIsUnavailableAndNotCached(string keyAuths) + { + PrivateApi.ValidationAccounts = names => + Task.FromResult(names.Single() == "hivesigner" + ? new JsonArray(new JsonObject + { + ["name"] = "hivesigner", + ["posting"] = new JsonObject { ["key_auths"] = JsonNode.Parse(keyAuths) }, + }) + : Known("alice")); + AssertUnavailable(await Unread(HsKey)); + + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + } + + [Fact] + public async Task AnAccountWhosePostingIsHeldByAccountsOnlyIsStillChecked() + { + // No posting keys of its own is a real account shape (account_auths only), + // not a garbage answer: @hivesigner's keys still decide. + PrivateApi.ValidationAccounts = names => + Task.FromResult(names.Single() == "hivesigner" + ? Known("hivesigner") + : new JsonArray(new JsonObject + { + ["name"] = "alice", + ["posting"] = new JsonObject { ["key_auths"] = new JsonArray() }, + })); + + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + Assert.Equal((401, "Unauthorized", ""), await Unread(OtherKey)); + } + + [Fact] + public void TheUnavailableLogNamesTheCauseButNotTheToken() + { + var timeout = PrivateApi.UnavailableLog("account read failed", new TaskCanceledException("timed out")); + var transport = PrivateApi.UnavailableLog("account read failed", new HttpRequestException("refused")); + + Assert.Contains("TaskCanceledException", timeout.Line); + Assert.Contains("timed out", timeout.Line); + Assert.Contains("HttpRequestException", transport.Line); + // One line per reason and cause type, however the messages vary. + Assert.NotEqual(timeout.Key, transport.Key); + Assert.Equal(transport.Key, + PrivateApi.UnavailableLog("account read failed", new HttpRequestException("other")).Key); + } + [Fact] public async Task AWrongSignatureIsStillUnauthorized() { diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs index bc2408a8..3da732d2 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs @@ -168,6 +168,7 @@ timestampNode is JsonValue timestampValue } // Use cached hivesigner account, refresh every 24h + var hsRefreshFailed = false; if (_hivesignerAccountCache is null || currentTime - _hivesignerCacheTime > HiveSignerCacheTtlMs) { @@ -175,17 +176,26 @@ timestampNode is JsonValue timestampValue { var hsAccounts = await ReadValidationAccounts("hivesigner"); var hsAccount = hsAccounts.Count > 0 ? hsAccounts[0] : null; - // An unusable answer is not cached for a day. - if (hsAccount is not null) PostingKeys(hsAccount); + // An unusable answer is not cached for a day. @hivesigner signs + // with its posting key, so an empty key list is unusable too. + if (hsAccount is not null && PostingKeys(hsAccount).Count == 0) + { + throw Unavailable("hivesigner answer without posting keys"); + } // TS caches whatever destructures out — undefined included — // and stamps the time either way. _hivesignerAccountCache = hsAccount; _hivesignerCacheTime = currentTime; } - catch (TokenCheckUnavailableException) when (_hivesignerAccountCache is not null) + catch (TokenCheckUnavailableException) + when (_hivesignerAccountCache is not null + && currentTime - _hivesignerCacheTime <= HiveSignerStaleMaxMs) { // A failed refresh keeps the keys already held (they change - // rarely); the time is not stamped, so the next check retries. + // rarely) for a bounded time; the time is not stamped, so the + // next check retries. They may only accept: a mismatch below + // could be a key the failed read would have shown. + hsRefreshFailed = true; } } @@ -198,6 +208,11 @@ timestampNode is JsonValue timestampValue return author; } + if (hsRefreshFailed) + { + throw Unavailable("hivesigner keys could not be refreshed"); + } + Console.WriteLine( $"Posting key mismatch {recoveredPubKey} [{string.Join(", ", postingPubKeys)}] [{string.Join(", ", hsPostingKeys)}]"); return null; @@ -231,25 +246,34 @@ private static async Task ReadValidationAccounts(string name) } /// - /// account.posting.key_auths.map(([key]) => key). Every account has posting - /// keys, so an account without them is an unusable answer, not a refusal. + /// account.posting.key_auths.map(([key]) => key). Every account has a posting + /// authority, so an answer without a key list, or with an entry that is not a + /// key, is unusable, not a refusal. An empty list is a real account shape + /// (posting held by other accounts only) and is returned as is. /// private static List PostingKeys(JsonNode account) { + List keys; try { - return MapKeyAuths(account["posting"]!["key_auths"]); + keys = MapKeyAuths(account["posting"]!["key_auths"]); } catch (Exception err) { throw Unavailable("account answer without posting keys", err); } + + if (keys.Any(string.IsNullOrEmpty)) + { + throw Unavailable("account answer with a posting key that is not a key"); + } + return keys; } private static TokenCheckUnavailableException Unavailable(string reason, Exception? inner = null) { - // The reason only, once: a node outage fails every request the same way. - LogOnce($"Token check unavailable: {reason}"); + var (key, line) = UnavailableLog(reason, inner); + LogOnce(key, line); return new TokenCheckUnavailableException(reason, inner); } @@ -260,8 +284,24 @@ internal static void ResetHivesignerCache() _hivesignerCacheTime = 0; } - /// Makes the cached @hivesigner account due for refresh, keeping it (tests). - internal static void ExpireHivesignerCache() => _hivesignerCacheTime = 0; + /// Makes the cached @hivesigner account `ageMs` old, keeping it (tests). + internal static void AgeHivesignerCache(long ageMs) => + _hivesignerCacheTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ageMs; + + /// How old cached @hivesigner keys may be when a refresh fails. + internal const long HiveSignerStaleMaxMs = 2 * HiveSignerCacheTtlMs; + + /// + /// The log line for an unavailable check, and the key it is written once under: + /// the cause's type and message only, never the code or the request, once per + /// reason and cause type, since a node outage fails every request the same way. + /// + internal static (string Key, string Line) UnavailableLog(string reason, Exception? inner) + { + var type = inner?.GetType().Name ?? "-"; + var cause = inner is null ? "" : $" ({type}: {inner.Message})"; + return ($"Token check unavailable: {reason} [{type}]", $"Token check unavailable: {reason}{cause}"); + } /// The chain read behind token validation (tests answer it). internal static Func, Task> ValidationAccounts = @@ -291,10 +331,12 @@ signedMessage is JsonObject obj private static int _tokenLogLines; private static readonly ConcurrentDictionary TokenLogSeen = new(); - private static void LogOnce(string line) + private static void LogOnce(string line) => LogOnce(line, line); + + private static void LogOnce(string key, string line) { - if (Volatile.Read(ref _tokenLogLines) >= MaxTokenLogLines || TokenLogSeen.ContainsKey(line)) return; - if (!TokenLogSeen.TryAdd(line, 0)) return; + if (Volatile.Read(ref _tokenLogLines) >= MaxTokenLogLines || TokenLogSeen.ContainsKey(key)) return; + if (!TokenLogSeen.TryAdd(key, 0)) return; if (Interlocked.Increment(ref _tokenLogLines) > MaxTokenLogLines) return; Console.WriteLine(line); } diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index 1f42b3fa..781040c3 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -380,6 +380,14 @@ def norm_body(text): # code, so that difference is covered by AiAssistHandlerTests rather than an entry # here — an entry would skip status, content-type, and body and hide unrelated # regressions on the route. +# +# Also not listed: the 503 "Service Unavailable" (with Retry-After) that every +# route validating a session code answers when the account keys could not be read +# (node error, timeout, unusable answer); the reference build answered 401. It +# happens only when the chain is read and the read fails, and no catalog case +# reaches that read (every catalog code is malformed and refused before it), so +# every case still matches and an entry would only skip whole routes. +# Covered by TokenCheckUnavailableTests instead. def diff(a_name, b_name, loose_name=None): From e25bc4bdbb2b3b0ea5911452e958ff0aa171ebdc Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 8 Nov 2026 11:02:53 +0200 Subject: [PATCH 3/3] Refuse non-account names before the read; any failed read, RPC errors and a missing HiveSigner account included, is unavailable --- .../TokenCheckUnavailableTests.cs | 99 +++++++++++++++++-- dotnet/EcencyApi/Handlers/PrivateApi.Core.cs | 42 +++++--- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs index b373baf1..0627b5c0 100644 --- a/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs +++ b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs @@ -41,12 +41,12 @@ public void Dispose() private static JsonArray Known(string? name) => new(Account(name!, name == "hivesigner" ? HsPub : Pub)); - private static string Code(ECPrivKey key) + private static string Code(ECPrivKey key, string author = "alice") { var message = new JsonObject { ["signed_message"] = new JsonObject { ["type"] = "code", ["app"] = "ecency.app" }, - ["authors"] = new JsonArray("alice"), + ["authors"] = new JsonArray(author), ["timestamp"] = 1726650000, }; var digest = HiveCrypto.Sha256Utf8(JsJson.Stringify(message)); @@ -284,14 +284,99 @@ public async Task AnUnknownAccountIsStillUnauthorized() Assert.Equal((401, "Unauthorized", ""), await StreakFreezeBuy(Key)); } + [Theory] + [InlineData("Internal Error (-32603)")] + [InlineData("Unable to acquire database lock (-32000)")] + public async Task AnRpcErrorForAWellFormedNameIsUnavailable(string message) + { + // A node can answer an internal error for a valid read: no verdict. + PrivateApi.ValidationAccounts = _ => throw new HiveRpcClient.RpcException(message); + + AssertUnavailable(await Unread(Key)); + AssertUnavailable(await StreakFreezeBuy(Key)); + } + [Fact] - public async Task AnRpcErrorIsTheNodesAnswerAndStaysUnauthorized() + public async Task AnRpcErrorReadingHivesignerIsUnavailable() { - // An RPC-level error is the node answering the call (HiveRpcClient does - // not fail over on it), so it is not an outage. - PrivateApi.ValidationAccounts = _ => throw new HiveRpcClient.RpcException("bad name"); + PrivateApi.ValidationAccounts = names => + names.Single() == "hivesigner" + ? throw new HiveRpcClient.RpcException("Internal Error (-32603)") + : Task.FromResult(Known("alice")); - Assert.Equal((401, "Unauthorized", ""), await Unread(Key)); + AssertUnavailable(await Unread(HsKey)); + } + + [Theory] + [InlineData("Alice")] + [InlineData("al ice")] + [InlineData("al_ice")] + [InlineData("ab")] + [InlineData("averyveryverylongname")] + [InlineData("")] + public async Task AMalformedNameIsUnauthorizedWithoutAChainRead(string author) + { + var reads = 0; + PrivateApi.ValidationAccounts = _ => + { + reads++; + throw new HiveRpcClient.RpcException("Internal Error (-32603)"); + }; + + Assert.Equal((401, "Unauthorized", ""), + await Run(PrivateApi.UnreadNotifications, new JsonObject { ["code"] = Code(Key, author) })); + Assert.Equal(0, reads); + } + + [Theory] + [InlineData("a.b-1")] + [InlineData("1ab")] + [InlineData("abc-")] + public async Task ALenientlyWellFormedNameIsRead(string author) + { + // No segment rules: an old name that today's rules refuse is still looked up. + var reads = new List(); + PrivateApi.ValidationAccounts = names => + { + reads.AddRange(names); + return Task.FromResult(new JsonArray()); + }; + + Assert.Null(await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(Key, author) })); + Assert.Equal(new[] { author }, reads); + } + + [Theory] + [InlineData("[]")] + [InlineData("[null]")] + public async Task AHivesignerAnswerWithoutTheAccountIsUnavailableAndNotCached(string answer) + { + PrivateApi.ValidationAccounts = names => + Task.FromResult(names.Single() == "hivesigner" + ? (JsonArray)JsonNode.Parse(answer)! + : Known("alice")); + AssertUnavailable(await Unread(HsKey)); + + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + } + + [Theory] + [InlineData("[]")] + [InlineData("[null]")] + public async Task AHivesignerAnswerWithoutTheAccountKeepsTheCachedKeys(string answer) + { + PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single())); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + + PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs - 60_000); + PrivateApi.ValidationAccounts = names => + Task.FromResult(names.Single() == "hivesigner" + ? (JsonArray)JsonNode.Parse(answer)! + : Known("alice")); + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); + // Still held for the next check. + Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) })); } [Fact] diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs index 3da732d2..f464d33b 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs @@ -152,7 +152,13 @@ timestampNode is JsonValue timestampValue // A read that fails (node error, timeout, unusable answer) is not a // verdict on the code: it throws TokenCheckUnavailableException, which // the pipeline answers 503 (ServerErrors) instead of the 401 that tells - // clients their session was refused. Only the chain's answer decides. + // clients their session was refused. Only the chain's answer decides, + // so a name that can never be an account is refused without a read. + if (!IsAccountNameShape(author)) + { + LogOnce("Token author is not an account name"); + return null; + } var accounts = await ReadValidationAccounts(author); var account = accounts.Count > 0 ? accounts[0] : null; if (account is null) @@ -176,14 +182,19 @@ timestampNode is JsonValue timestampValue { var hsAccounts = await ReadValidationAccounts("hivesigner"); var hsAccount = hsAccounts.Count > 0 ? hsAccounts[0] : null; - // An unusable answer is not cached for a day. @hivesigner signs - // with its posting key, so an empty key list is unusable too. - if (hsAccount is not null && PostingKeys(hsAccount).Count == 0) + // An unusable answer is not cached for a day. @hivesigner exists + // and signs with its posting key, so an answer without the + // account or with an empty key list is unusable too. + if (hsAccount is null) + { + throw Unavailable("hivesigner answer without the account"); + } + if (PostingKeys(hsAccount).Count == 0) { throw Unavailable("hivesigner answer without posting keys"); } - // TS caches whatever destructures out — undefined included — - // and stamps the time either way. + // Only a usable answer is cached (TS cached whatever came back, + // undefined included). _hivesignerAccountCache = hsAccount; _hivesignerCacheTime = currentTime; } @@ -225,10 +236,10 @@ timestampNode is JsonValue timestampValue } /// - /// The accounts read for ValidateCode. An RPC-level error is the node's - /// answer to the call (HiveRpcClient does not fail over on it) and stays a - /// refusal; anything else that fails, and an answer that is not an array, - /// means the keys could not be read. + /// The accounts read for ValidateCode, for a name of account shape. Any + /// failure, RPC-level errors included (a node can answer an internal error + /// for a valid read), and an answer that is not an array, means the keys + /// could not be read. /// private static async Task ReadValidationAccounts(string name) { @@ -237,7 +248,7 @@ private static async Task ReadValidationAccounts(string name) { accounts = await ValidationAccounts(new[] { name }); } - catch (Exception err) when (err is not HiveRpcClient.RpcException) + catch (Exception err) { throw Unavailable("account read failed", err); } @@ -245,6 +256,15 @@ private static async Task ReadValidationAccounts(string name) return accounts ?? throw Unavailable("account read answered no array"); } + /// + /// A shape every existing Hive account name has: 3 to 16 characters of a-z, + /// 0-9, '.' and '-'. Deliberately looser than HiveNames.IsAccountName (no + /// segment rules), so no older account is ever refused by it. + /// + internal static bool IsAccountNameShape(string name) => + name.Length is >= 3 and <= 16 + && name.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '-'); + /// /// account.posting.key_auths.map(([key]) => key). Every account has a posting /// authority, so an answer without a key list, or with an entry that is not a