diff --git a/CLAUDE.md b/CLAUDE.md index 69eda89a..7df35c21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,12 @@ Two cheap checks before assuming a token problem: 100% 401 while its siblings are at 100% success is not an authentication bug in the usual sense. +`ValidateCode` accepts only a token issued to the Ecency app +(`signed_message.app` is `ecency.app`) and typed `code` or `posting`. Anything +else is refused before the account is read, however valid its signature: a +client signed in as another HiveSigner app, a `login` proof, a signed message +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. diff --git a/dotnet/EcencyApi.Tests/SessionTokenTests.cs b/dotnet/EcencyApi.Tests/SessionTokenTests.cs new file mode 100644 index 00000000..7467f25d --- /dev/null +++ b/dotnet/EcencyApi.Tests/SessionTokenTests.cs @@ -0,0 +1,105 @@ +using System.Text.Json.Nodes; +using EcencyApi.Handlers; +using EcencyApi.Infrastructure; +using NBitcoin.Secp256k1; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// ValidateCode end to end, with real signatures: only a code or posting token +/// issued to the Ecency app is a session, whether the user's own key signed it +/// or @hivesigner did when it exchanged a code, and the rest is refused before +/// any account is read. +/// +[Collection("session-token")] +public class SessionTokenTests : IDisposable +{ + private static readonly ECPrivKey Key = HiveCrypto.FromLogin("alice", "test-password"); + private static readonly string Pub = HiveCrypto.PublicKeyFromLogin("alice", "test-password"); + // The key @hivesigner signs the tokens it exchanges codes for. + private static readonly ECPrivKey HsKey = HiveCrypto.FromLogin("hivesigner", "test-password"); + private static readonly string HsPub = HiveCrypto.PublicKeyFromLogin("hivesigner", "test-password"); + private readonly List _reads = new(); + + public SessionTokenTests() + { + PrivateApi.ResetHivesignerCache(); + PrivateApi.ValidationAccounts = names => + { + var name = names.Single(); + _reads.Add(name); + var account = new JsonObject + { + ["name"] = name, + ["posting"] = new JsonObject + { + ["key_auths"] = new JsonArray(new JsonArray(name == "hivesigner" ? HsPub : Pub, 1)), + }, + }; + return Task.FromResult(new JsonArray(account)); + }; + } + + public void Dispose() + { + PrivateApi.ValidationAccounts = names => HiveClients.Default.GetAccounts(names); + PrivateApi.ResetHivesignerCache(); + } + + /// A request body carrying `signedMessage` for alice, signed by `key`. + private static JsonObject Body(string signedMessage, ECPrivKey? key = null) + { + var message = new JsonObject + { + ["signed_message"] = JsonNode.Parse(signedMessage), + ["authors"] = new JsonArray("alice"), + ["timestamp"] = 1726650000, + }; + var digest = HiveCrypto.Sha256Utf8(JsJson.Stringify(message)); + message["signatures"] = new JsonArray(HiveCrypto.Sign(key ?? Key, digest)); + return new JsonObject { ["code"] = B64u.Encode(JsJson.Stringify(message)) }; + } + + [Theory] + [InlineData("""{"type":"posting","app":"ecency.app"}""")] + [InlineData("""{"type":"code","app":"ecency.app"}""")] + public async Task EcencyTokensAreSessions(string signedMessage) + { + Assert.Equal("alice", await PrivateApi.ValidateCode(Body(signedMessage))); + Assert.Equal(new[] { "alice" }, _reads); + } + + [Fact] + public async Task APostingTokenHivesignerExchangedIsASession() + { + // What web and mobile hold: HiveSigner's posting token for the Ecency + // app, signed by @hivesigner rather than by the user's own key. + var body = Body("""{"type":"posting","app":"ecency.app"}""", HsKey); + Assert.Equal("alice", await PrivateApi.ValidateCode(body)); + Assert.Equal(new[] { "alice", "hivesigner" }, _reads); + } + + [Fact] + public async Task HivesignerSignedTokensForOtherAppsAreNot() + { + var body = Body("""{"type":"posting","app":"another.app"}""", HsKey); + Assert.Null(await PrivateApi.ValidateCode(body)); + Assert.Empty(_reads); + } + + [Theory] + [InlineData("""{"message":"hello"}""")] + [InlineData("""{"type":"login","app":"ecency.app"}""")] + [InlineData("""{"type":"posting","app":"another.app"}""")] + public async Task OtherSignedMessagesAreNot(string signedMessage) + { + Assert.Null(await PrivateApi.ValidateCode(Body(signedMessage))); + Assert.Empty(_reads); + } +} + +[CollectionDefinition("session-token", DisableParallelization = true)] +public class SessionTokenCollection +{ +} diff --git a/dotnet/EcencyApi.Tests/TokenTypeTests.cs b/dotnet/EcencyApi.Tests/TokenTypeTests.cs index a38c9bd2..a9525dd4 100644 --- a/dotnet/EcencyApi.Tests/TokenTypeTests.cs +++ b/dotnet/EcencyApi.Tests/TokenTypeTests.cs @@ -5,38 +5,48 @@ namespace EcencyApi.Tests; /// -/// A HiveSigner-style message typed "login" proves who signed it and nothing -/// more: HiveSigner answers /api/me for one and refuses it everywhere else, and -/// the Ecency clients never send one (wallet logins send "code", HiveSigner -/// issues "posting" for the scopes they request). Token validation refuses it -/// before any key lookup, and leaves every other shape to the signature checks. +/// A private-API session is a token issued to the Ecency app: a "code" the +/// apps sign with a key they hold, or the "posting" token HiveSigner gives for +/// one. Anything else signed by the same keys is refused before any key +/// lookup: another app's token, a sign-in proof typed "login", a signed +/// message with no type at all. /// public class TokenTypeTests { private static JsonNode? Parse(string json) => JsonNode.Parse(json); - [Fact] - public void LoginTypedMessageIsRefused() + [Theory] + [InlineData("""{"type":"posting","app":"ecency.app"}""")] + [InlineData("""{"type":"code","app":"ecency.app"}""")] + [InlineData("""{"app":"ecency.app","type":"code"}""")] + [InlineData("""{"type":"code","app":"ecency.app","extra":1}""")] + public void EcencyCodesAndPostingTokensAreSessions(string signedMessage) { - Assert.True(PrivateApi.IsLoginOnlyMessage(Parse("""{"type":"login","app":"ecency.app"}"""))); - Assert.True(PrivateApi.IsLoginOnlyMessage( - Parse("""{"type":"login","app":"ecency.app","audience":"honeyback://hive"}"""))); + Assert.True(PrivateApi.IsEcencySession(Parse(signedMessage))); } [Theory] - [InlineData("""{"type":"code","app":"ecency.app"}""")] - [InlineData("""{"type":"posting","app":"ecency.app"}""")] + [InlineData("""{"type":"login","app":"ecency.app"}""")] + [InlineData("""{"type":"login","app":"ecency.app","audience":"someapp://callback"}""")] [InlineData("""{"type":"offline","app":"ecency.app"}""")] [InlineData("""{"type":"refresh","app":"ecency.app"}""")] - [InlineData("""{"type":"Login","app":"ecency.app"}""")] + [InlineData("""{"type":"Posting","app":"ecency.app"}""")] + [InlineData("""{"type":"posting","app":"another.app"}""")] + [InlineData("""{"type":"code","app":"another.app"}""")] + [InlineData("""{"type":"posting","app":"Ecency.app"}""")] + [InlineData("""{"type":"posting"}""")] [InlineData("""{"app":"ecency.app"}""")] - [InlineData("""{"type":null}""")] - [InlineData("""{"type":1}""")] - [InlineData("""{"type":["login"]}""")] - [InlineData("""["login"]""")] + [InlineData("""{"message":"hello"}""")] + [InlineData("""{"type":"posting","app":null}""")] + [InlineData("""{"type":null,"app":"ecency.app"}""")] + [InlineData("""{"type":1,"app":"ecency.app"}""")] + [InlineData("""{"type":["posting"],"app":"ecency.app"}""")] + [InlineData("""{"type":"posting","app":["ecency.app"]}""")] + [InlineData("""["posting","ecency.app"]""")] + [InlineData("\"posting\"")] [InlineData("null")] - public void EverythingElseIsLeftToTheSignatureChecks(string signedMessage) + public void EverythingElseIsRefused(string signedMessage) { - Assert.False(PrivateApi.IsLoginOnlyMessage(Parse(signedMessage))); + Assert.False(PrivateApi.IsEcencySession(Parse(signedMessage))); } } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs index 7da7f821..6471e25e 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -116,16 +117,20 @@ timestampNode is JsonValue timestampValue if (!signedMessageTypeofObject || author is null || !timestampIsNumber || signature is null) { - Console.WriteLine($"Invalid token structure {JsJson.Stringify(decoded)}"); + // names what is missing, never the token: a real one carries a signature + LogOnce( + $"Invalid token structure: signed_message={signedMessageTypeofObject} author={author is not null} timestamp={timestampIsNumber} signature={signature is not null}"); return null; } - // A message typed "login" only says who signed it. HiveSigner answers - // /api/me for one and refuses everything else, and no Ecency client - // ever sends one here, so it is not a session here either. Decided - // before any node lookup: the shape alone settles it. - if (IsLoginOnlyMessage(signedMessage)) + // A session is a token Ecency's own clients hold: one issued to the + // Ecency app, as a code or as the posting token HiveSigner exchanges + // it for. Nothing else signed by the same keys is one: another app's + // token, a sign-in proof ("login"), a signed message. Decided before + // any node lookup: the shape alone settles it. + if (!IsEcencySession(signedMessage)) { + NoteRefusal(signedMessage as JsonObject); return null; } @@ -144,7 +149,7 @@ timestampNode is JsonValue timestampValue var digest = HiveCrypto.Sha256Utf8(rawMessage); var recoveredPubKey = HiveCrypto.RecoverPublicKey(signature, digest); - var accounts = await HiveClients.Default.GetAccounts(new[] { author }) + var accounts = await ValidationAccounts(new[] { author }) ?? throw new InvalidOperationException("getAccounts result is not iterable"); var account = accounts.Count > 0 ? accounts[0] : null; if (account is null) @@ -167,7 +172,7 @@ timestampNode is JsonValue timestampValue { try { - var hsAccounts = await HiveClients.Default.GetAccounts(new[] { "hivesigner" }) + var hsAccounts = await ValidationAccounts(new[] { "hivesigner" }) ?? throw new InvalidOperationException("getAccounts result is not iterable"); // TS caches whatever destructures out — undefined included — // and stamps the time either way. @@ -201,17 +206,67 @@ timestampNode is JsonValue timestampValue } } + /// Forgets the cached @hivesigner account (tests). + internal static void ResetHivesignerCache() + { + _hivesignerAccountCache = null; + _hivesignerCacheTime = 0; + } + + /// The chain read behind token validation (tests answer it). + internal static Func, Task> ValidationAccounts = + names => HiveClients.Default.GetAccounts(names); + + /// The HiveSigner app id Ecency's clients sign in as. + internal const string EcencyApp = "ecency.app"; + /// - /// True for a signed_message whose type is the string "login": a proof of - /// identity for another app, never a session. Anything else, including a - /// missing or non-string type, is left to the signature checks as before. + /// True for a signed_message issued to the Ecency app as a "code" (what + /// the apps sign with a key they hold) or a "posting" token (what + /// HiveSigner gives for one): the two things Ecency's clients send as their + /// session. Everything else is refused, including a missing or non-string + /// type or app. /// - internal static bool IsLoginOnlyMessage(JsonNode? signedMessage) => + internal static bool IsEcencySession(JsonNode? signedMessage) => signedMessage is JsonObject obj - && obj.TryGetPropertyValue("type", out var typeNode) - && typeNode is JsonValue typeValue - && JsVal.TryGetStringLenient(typeValue, out var type) - && type == "login"; + && StringField(obj, "app") == EcencyApp + && StringField(obj, "type") is "posting" or "code"; + + // Token diagnostics on the request path, written once per distinct line and + // at most MaxTokenLogLines lines per process: the service stays quiet on + // request paths however many bad tokens arrive. The first sighting of a + // first-party client refused by the session rule still reaches the logs, + // unless junk tokens have used up the lines since the last restart. + private const int MaxTokenLogLines = 32; + private static int _tokenLogLines; + private static readonly ConcurrentDictionary TokenLogSeen = new(); + + private static void LogOnce(string line) + { + if (Volatile.Read(ref _tokenLogLines) >= MaxTokenLogLines || TokenLogSeen.ContainsKey(line)) return; + if (!TokenLogSeen.TryAdd(line, 0)) return; + if (Interlocked.Increment(ref _tokenLogLines) > MaxTokenLogLines) return; + Console.WriteLine(line); + } + + private static void NoteRefusal(JsonObject? obj) => + LogOnce($"Token refused as a session: app={LogLabel(obj, "app")} type={LogLabel(obj, "type")}"); + + /// A signed_message label for a log line: short, printable, never the value of anything else. + private static string LogLabel(JsonObject? obj, string name) + { + var text = obj is null ? null : StringField(obj, name); + if (text is null) return "-"; + var clean = new string(text.Where(c => c is >= ' ' and <= '~').Take(40).ToArray()); + return clean.Length == 0 ? "?" : clean; + } + + private static string? StringField(JsonObject obj, string name) => + obj.TryGetPropertyValue(name, out var node) + && node is JsonValue value + && JsVal.TryGetStringLenient(value, out var text) + ? text + : null; /// key_auths.map(([key]) => key) — throws on non-array input like .map on a non-array. private static List MapKeyAuths(JsonNode? keyAuths)