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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
105 changes: 105 additions & 0 deletions dotnet/EcencyApi.Tests/SessionTokenTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System.Text.Json.Nodes;
using EcencyApi.Handlers;
using EcencyApi.Infrastructure;
using NBitcoin.Secp256k1;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// 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.
/// </summary>
[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<string?> _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<JsonArray?>(new JsonArray(account));
};
}

public void Dispose()
{
PrivateApi.ValidationAccounts = names => HiveClients.Default.GetAccounts(names);
PrivateApi.ResetHivesignerCache();
}

/// <summary>A request body carrying `signedMessage` for alice, signed by `key`.</summary>
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
{
}
48 changes: 29 additions & 19 deletions dotnet/EcencyApi.Tests/TokenTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,48 @@
namespace EcencyApi.Tests;

/// <summary>
/// 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.
/// </summary>
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)));
}
}
87 changes: 71 additions & 16 deletions dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Session denials lack endpoint coverage 📘 Rule violation ▣ Testability

The new IsEcencySession check changes the authentication result used by private-api handlers, but
the new tests call ValidateCode directly and no endpoint-specific divergence was added to
dotnet/parity/driver.py. For a signed token issued to another app, handlers such as Activities
now receive null and return a 401 response without a matching HTTP test or parity entry.
Agent Prompt
## Issue description
The shared session check changes HTTP-visible denials without endpoint-level tests or a parity divergence entry.

## Fix Focus Areas
- dotnet/EcencyApi/Handlers/PrivateApi.Core.cs[125-136]
- dotnet/EcencyApi.Tests/SessionTokenTests.cs[83-99]
- dotnet/parity/driver.py[273-285]

## Recommended Fix
Add an HTTP-level test for an affected private endpoint using a signed token from another app, and document that endpoint's changed behavior relative to the reference in the parity divergences.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No parity entry needed. The harness only sends invalid codes (invalid, badcode), and those are refused identically before and after this change. No catalogued case sends a correctly signed token, so no response diverges. The new rule is covered by SessionTokenTests with real signatures.

{
NoteRefusal(signedMessage as JsonObject);
return null;
}

Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -201,17 +206,67 @@ timestampNode is JsonValue timestampValue
}
}

/// <summary>Forgets the cached @hivesigner account (tests).</summary>
internal static void ResetHivesignerCache()
{
_hivesignerAccountCache = null;
_hivesignerCacheTime = 0;
}

/// <summary>The chain read behind token validation (tests answer it).</summary>
internal static Func<IEnumerable<string?>, Task<JsonArray?>> ValidationAccounts =
names => HiveClients.Default.GetAccounts(names);

/// <summary>The HiveSigner app id Ecency's clients sign in as.</summary>
internal const string EcencyApp = "ecency.app";

/// <summary>
/// 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.
/// </summary>
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<string, byte> 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")}");

/// <summary>A signed_message label for a log line: short, printable, never the value of anything else.</summary>
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;

/// <summary>key_auths.map(([key]) => key) — throws on non-array input like .map on a non-array.</summary>
private static List<string?> MapKeyAuths(JsonNode? keyAuths)
Expand Down
Loading