Skip to content

Commit d5a753d

Browse files
VortexUKclaude
andcommitted
v0.1.8: update-awareness badge + HMAC-signed uploads
Two user-visible additions: 1. Plugin checks GitHub releases on init and shows a green/yellow/red banner in SettingsPanel above CONFIGURATION: * Current → "v0.1.8 — up to date" (muted) * SlightlyStale (≤2 versions behind) → yellow, "Download update" * TooOld (>2 versions behind) → RED, uploads BLOCKED until user updates * DevBuild → muted "dev build" (no nag for maintainers) * Unknown → banner hidden (fails OPEN — network outage must not silently brick parse uploads) Threshold lives in UpdateChecker.StaleThresholdVersions (=2). UploadClient.UpdateStatus gates the upload path. Plugin fires the check once per ACT session (no cache — GH 60/h/IP limit is plenty for the request volume, and re-checking on each start means the banner clears instantly after a user installs an update). 2. Every upload now ships X-Lexicon-Signature = HMAC-SHA256(body, api_token) via PayloadSigner. Companion server-side validator in the EQ2Lexicon repo (commit 33e48a4) runs in opportunistic mode for now so v0.1.7 installs aren't broken during the rollout. Threat model documented in PayloadSigner.cs + CLAUDE.md: this stops in-flight tampering and replay-with-stolen-token-only attacks. It does NOT stop the legitimate token holder from signing a forged parse — that's unfixable client-side, real integrity has to come from server-side sanity caps. Implementation notes: * New src/Core types are pure + testable; UI assembly stays the only place that imports ACT or WinForms. * UploadClient.UserAgent now embeds the assembly version (EQ2LexiconACTPlugin/0.1.8) so the server can roll out a strict HMAC gate once telemetry shows enough 0.1.8+ traffic. * 38 new xUnit tests: UpdateChecker (version parsing, status matrix, JSON parsing, end-to-end orchestration with stub fetcher), PayloadSigner (deterministic HMAC, key sensitivity, body sensitivity, RFC 4231 § 4.3 Test Case 2 vector). 110/110. Lockstep version bump on Core + UI per the project rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 375b2a2 commit d5a753d

11 files changed

Lines changed: 1065 additions & 4 deletions

CLAUDE.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ The split also unlocks GitHub Actions CI: the workflow at `.github/workflows/ci.
3030
| `src/Core/PayloadBuilder.cs` | Pure transform: `EncounterSnapshot` → ingest-JSON dict shape. Owns `OutgoingGroupToSwingType`, `FormatTime` (emits ISO-8601 + Z), `SanitizePayload` (replaces NaN/∞ with 0), and `SerializeJson` (JavaScriptSerializer with an 8 MB ceiling). |
3131
| `src/Core/Snapshots.cs` | Plain DTOs (`EncounterSnapshot`, `CombatantSnapshot`, `DamageTypeAggregate`, `AttackTypeSnapshot`) that mirror the slice of ACT's data model the payload builder reads. |
3232
| `src/Core/PluginConfig.cs` | User settings persisted as XML. `Load(path)` and `Save(path)` take the path from the caller (resolved in `Plugin.cs` via `ActHelpers.GetConfigPath()`). API token is DPAPI-encrypted on disk (current-user scope) with a `DPAPI:` prefix; legacy plaintext from v0.1.0–v0.1.4 still loads. |
33-
| `src/Core/UploadClient.cs` | `HttpClient` wrapper. `TestConnectionAsync` (GET `/api/auth/whoami`), `UploadEncounterAsync` (POST `/api/parses/ingest`). `ValidateServerUrl` rejects non-https except `localhost`/`127.0.0.1`/`[::1]`. |
33+
| `src/Core/UploadClient.cs` | `HttpClient` wrapper. `TestConnectionAsync` (GET `/api/auth/whoami`), `UploadEncounterAsync` (POST `/api/parses/ingest`). `ValidateServerUrl` rejects non-https except `localhost`/`127.0.0.1`/`[::1]`. Adds `X-Lexicon-Signature` HMAC header to every upload (see PayloadSigner) and blocks uploads when `UpdateStatus.UploadBlocked == true`. |
34+
| `src/Core/UpdateChecker.cs` | GitHub-releases-feed fetcher + pure version-comparison logic. `Compute(currentVersion, releasedVersions)` returns an `UpdateCheckResult` with `Current`/`SlightlyStale`/`TooOld`/`DevBuild`/`Unknown`. Threshold = `StaleThresholdVersions` (2) — older than that blocks uploads. Failure modes (network down, GH 5xx, rate-limit) all collapse to `Unknown` so the gate fails OPEN. |
35+
| `src/Core/PayloadSigner.cs` | HMAC-SHA256 of the upload body keyed by the user's API token. See "Payload integrity" below for the threat model and what this does/doesn't defend. |
3436
| `tests/EQ2Lexicon.ACTPlugin.Tests/` | xUnit project, `net48`. 72 tests. References Core only (UI types aren't testable without ACT). Covers PayloadBuilder, PluginConfig (incl. DPAPI roundtrip), UploadClient (incl. URL validator). Coverage collected via `coverlet.collector`. |
3537

3638
## Versioning + releases
@@ -106,6 +108,38 @@ Dependabot (`.github/dependabot.yml`) opens weekly PRs for NuGet packages in the
106108

107109
See the audit findings + fixes in commits `9eb39e0` (v0.1.4) and `5f9e11a` (v0.1.5).
108110

111+
## Payload integrity (HMAC, v0.1.8+)
112+
113+
Every upload includes an `X-Lexicon-Signature` header: HMAC-SHA256 of the request body, keyed by the user's API token. Implementation in `src/Core/PayloadSigner.cs`. The server in the [EQ2Lexicon](https://github.com/VortexUK/EQ2Lexicon) repo re-computes the HMAC from the body + the token the Bearer header resolved to, and compares with constant-time equality.
114+
115+
**What this defends**:
116+
117+
- Casual payload tampering by a user editing JSON in a debugging proxy and replaying.
118+
- An attacker who somehow MITMs a TLS session and tries to mutate the body in flight.
119+
- An attacker who steals only the token (e.g. via a hypothetical XSS on the site) still needs to know the HMAC scheme to forge a valid request — small but non-zero friction.
120+
121+
**What this does NOT defend**:
122+
123+
- The legitimate holder of an API token can sign anything — they have the key. Forging a fake parse for *yourself* is unfixable client-side. Real integrity has to come from server-side sanity checks (DPS-vs-level caps, encounter duration plausibility, cross-validation between multiple plugin uploads of the same encounter).
124+
- A determined user who runs the DLL through `dnSpy`/`ILSpy` can read the signing code; the algorithm is intentionally not a secret.
125+
126+
**Why key = API token, not an embedded shared secret**: no new secret to manage / rotate / leak. The token is already in memory. We get a per-user key as a side effect, which means cross-user replay also doesn't work.
127+
128+
**Rollout coordination with the server**: ship the server's signature validator in *opportunistic* mode first (validate only if header present, reject only if validation fails) so existing v0.1.7 installs keep uploading. Flip to *strict* mode (require the header) once telemetry shows ≥98% of uploads carry it — pulled from the `User-Agent` header which UploadClient sets to `EQ2LexiconACTPlugin/<assembly version>`.
129+
130+
## Update awareness (v0.1.8+)
131+
132+
The plugin fetches `https://api.github.com/repos/VortexUK/EQ2LexiconACTPlugin/releases` once per ACT session, compares the assembly version to the published tags, and:
133+
134+
- Shows a green/yellow/red banner above the Configuration card in SettingsPanel (`SetUpdateStatus`).
135+
- Blocks uploads when the installed version is more than `UpdateChecker.StaleThresholdVersions` (=2) releases behind. Block lives in `UploadClient.UploadEncounterAsync` — see `UpdateStatus.UploadBlocked`.
136+
137+
Failure modes (offline, GitHub 5xx, rate-limit, malformed JSON) all collapse to `UpdateStatus.Unknown`, which intentionally **fails open** — uploads still proceed. A GitHub incident must not silently brick every user's parse upload.
138+
139+
`Compute()` returns `DevBuild` when the local version is strictly greater than the latest published tag (i.e. you bumped `<Version>` but haven't tagged yet) — UI shows a muted "dev build" label so maintainers don't get nagged about their own un-released version.
140+
141+
No caching of the GitHub response — request volume is at most a handful per user per day, well under the unauthenticated 60/h/IP limit, and re-fetching on every ACT start means a user who just installed an update sees the banner clear instantly after restarting ACT.
142+
109143
## ACT API gotchas worth remembering
110144

111145
- `ActGlobals.charName` returns the string `"YOU"` for EQ2 — useless. Use `ActHelpers.GetLoggingCharacterName()` instead (parses the log filename).

src/Core/EQ2Lexicon.ACTPlugin.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Build metadata is mirrored from the UI csproj so this assembly's
1919
version tracks the user-facing plugin version.
2020
-->
21-
<Version>0.1.7</Version>
21+
<Version>0.1.8</Version>
2222
<Company>EQ2 Lexicon</Company>
2323
<Product>EQ2 Lexicon Uploader (Core)</Product>
2424
<Description>Pure payload / config / HTTP layer shared by EQ2Lexicon.ACTPlugin.</Description>

src/Core/PayloadSigner.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
5+
namespace EQ2Lexicon.ACTPlugin
6+
{
7+
/// <summary>
8+
/// HMAC-SHA256 over the upload body, keyed by the user's API token.
9+
/// The signature is sent as the <c>X-Lexicon-Signature</c> header and
10+
/// the server recomputes + compares with constant-time equality.
11+
///
12+
/// ── What this defends ───────────────────────────────────────────────
13+
/// The intended threat is "casual payload tampering" — a user (or a
14+
/// MITM with the user's TLS session somehow) editing the JSON between
15+
/// what the plugin built and what the server stored, hoping to inflate
16+
/// their numbers. With HMAC the server can detect that the body
17+
/// changed after the holder of the API token signed it.
18+
///
19+
/// ── What this does NOT defend ───────────────────────────────────────
20+
/// The legitimate holder of an API token can sign ANY payload — they
21+
/// have the key. So this does not, and cannot, prevent a determined
22+
/// user from forging a parse for themselves. Real integrity has to
23+
/// come from server-side sanity checks (cap-vs-level checks, encounter
24+
/// duration plausibility, cross-validation against other parses of the
25+
/// same encounter, etc.) which live in the EQ2 Lexicon repo.
26+
///
27+
/// Choosing the API token as the HMAC key (rather than an embedded
28+
/// per-plugin secret) means:
29+
/// - No secret to manage / rotate / leak via DLL disassembly.
30+
/// - Tampering by someone who steals only the token still requires
31+
/// them to recompute the signature using the protocol described
32+
/// here — small extra friction over "just curl with Bearer".
33+
/// - The plugin holds the API token in memory already, so no new
34+
/// attack surface is added.
35+
/// </summary>
36+
public static class PayloadSigner
37+
{
38+
/// <summary>
39+
/// HTTP header name used to ship the signature to the server.
40+
/// Centralised here so plugin + server tests can reference the
41+
/// same string and a rename can't desync.
42+
/// </summary>
43+
public const string SignatureHeaderName = "X-Lexicon-Signature";
44+
45+
/// <summary>
46+
/// Compute the HMAC-SHA256 of <paramref name="body"/> keyed by
47+
/// <paramref name="apiToken"/>. Returns a lower-case hex string.
48+
/// </summary>
49+
/// <exception cref="ArgumentException">
50+
/// Thrown when <paramref name="apiToken"/> is empty/whitespace.
51+
/// Caller MUST have validated the token before reaching here;
52+
/// signing with an empty key would produce a misleadingly
53+
/// authoritative-looking signature.
54+
/// </exception>
55+
public static string Sign(string body, string apiToken)
56+
{
57+
if (string.IsNullOrWhiteSpace(apiToken))
58+
throw new ArgumentException("API token is required to sign the payload.", nameof(apiToken));
59+
60+
var keyBytes = Encoding.UTF8.GetBytes(apiToken);
61+
var bodyBytes = Encoding.UTF8.GetBytes(body ?? "");
62+
using (var hmac = new HMACSHA256(keyBytes))
63+
{
64+
var hash = hmac.ComputeHash(bodyBytes);
65+
return BytesToHex(hash);
66+
}
67+
}
68+
69+
/// <summary>
70+
/// Lowercase-hex with no separator. Standard format for sig headers
71+
/// (matches what e.g. GitHub uses for `X-Hub-Signature-256`).
72+
/// </summary>
73+
private static string BytesToHex(byte[] bytes)
74+
{
75+
var sb = new StringBuilder(bytes.Length * 2);
76+
for (int i = 0; i < bytes.Length; i++)
77+
{
78+
sb.Append(bytes[i].ToString("x2"));
79+
}
80+
return sb.ToString();
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)