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)