Skip to content

Commit 67f5c87

Browse files
dhruuvsharmaclaude
andcommitted
feat(plugins): feed signing tool (FeedSigner) + hosting/publish doc (#25)
The publish-side counterpart to FeedSignatureVerifier, completing the feed's producer half: - FeedSigner (Infrastructure/Plugins/Feed): GenerateKeyPair (ECDSA P-256), Sign, and SignIndexFile — byte-for-byte the operation the app inverts (SHA-256, raw detached signature over the raw index bytes). Publish-side only; the app ships/holds the public key and only ever verifies. - 4 round-trip tests lock the contract: a key it mints signs an index the app's FeedSignatureVerifier accepts, and tamper / wrong-key / file round-trip behave. - docs/marketplace-hosting.md: the daxalgo-plugins registry layout (index on Pages, packages as release assets), key custody/rotation, the sign+publish flow, revocation, and the appsettings to turn the Catalog on. Leaves only the external repos (registry + submissions CI) for #25. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent da2342b commit 67f5c87

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

docs/marketplace-hosting.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Hosting the plugin marketplace feed
2+
3+
> How the signed plugin feed is produced and served. This is the **maintainer / operator** side; plugin
4+
> *authors* read [plugin-authoring.md](plugin-authoring.md) and [marketplace-policy.md](marketplace-policy.md),
5+
> and *users* just see the Plugin Manager's **Catalog** tab. The app-side consumer is
6+
> [`PluginFeedClient`](../src/windows/Pipeline/TradingTerminal.Infrastructure/Plugins/Feed/PluginFeedClient.cs);
7+
> the trust model is in [plugin-security.md](plugin-security.md).
8+
9+
The feed needs **no server**: a static, signed `plugins-index.json` plus the `.daxplugin` packages it points
10+
at, both served over HTTPS. The reference hosting is a public GitHub repo — index on GitHub Pages, packages
11+
as Release assets — but any static host works. A website built later reads the exact same index.
12+
13+
## The registry repo (`daxalgo-plugins`)
14+
15+
```
16+
daxalgo-plugins/
17+
├─ docs/ # GitHub Pages root (Settings → Pages → /docs)
18+
│ ├─ plugins-index.json # the feed → https://<pages-host>/plugins-index.json
19+
│ └─ plugins-index.json.sig # detached signature, served beside it
20+
└─ .github/workflows/publish.yml # regenerate + sign + deploy on push to the index source
21+
```
22+
23+
- **Index**`https://dhruuvsharma.github.io/daxalgo-plugins/plugins-index.json` (whatever Pages URL you
24+
get). That URL goes into `Plugins:FeedUrl`.
25+
- **Signature** → the client always fetches `<FeedUrl>.sig`, so it must sit at the same path plus `.sig`.
26+
- **Packages** → upload each `<id>-<version>.daxplugin` as a **Release asset** and put its download URL in
27+
the index entry's `url`. Release assets are CDN-backed, immutable per release, and free.
28+
29+
## Feed schema (`plugins-index.json`)
30+
31+
Matches [`PluginIndex`](../src/windows/Pipeline/TradingTerminal.Infrastructure/Plugins/Feed/PluginIndex.cs)
32+
(`feedVersion` is the schema version — the app ignores an index newer than it understands):
33+
34+
```json
35+
{
36+
"feedVersion": 1,
37+
"publishedUtc": "2026-07-12T00:00:00Z",
38+
"plugins": [
39+
{
40+
"id": "acme.orderflow-imbalance",
41+
"name": "Order-Flow Imbalance",
42+
"publisher": "Acme Research",
43+
"description": "Trade-based OBI regime signal.",
44+
"tags": ["orderflow", "microstructure"],
45+
"paperUrl": "https://arxiv.org/abs/2507.22712",
46+
"latest": {
47+
"version": "1.2.0",
48+
"sdkVersion": "0.1.0-alpha",
49+
"minAppVersion": "1.1.0",
50+
"url": "https://github.com/dhruuvsharma/daxalgo-plugins/releases/download/acme.orderflow-imbalance-1.2.0/acme.orderflow-imbalance-1.2.0.daxplugin",
51+
"sha256": "8F3C…",
52+
"sizeBytes": 41231,
53+
"signatureThumbprint": null
54+
},
55+
"versions": [ /* older builds, same shape */ ]
56+
}
57+
],
58+
"revoked": [
59+
{ "id": "bad.plugin", "sha256": null, "reason": "withdrawn by author", "dateUtc": "2026-07-11T00:00:00Z" }
60+
]
61+
}
62+
```
63+
64+
- **`sha256`** is the hash of the `.daxplugin` bytes. The catalog installer re-checks the download against
65+
it before anything is unpacked
66+
([`PluginCatalogInstaller`](../src/windows/Pipeline/TradingTerminal.Infrastructure/Plugins/Feed/PluginCatalogInstaller.cs)),
67+
so the signed index is what binds the trusted feed to the bytes a user actually receives. Get it wrong and
68+
the install is refused.
69+
- **`revoked[]`** is synced into each user's local `revoked.json` kill-list on the next feed refresh
70+
([`PluginRevocationSync`](../src/windows/Pipeline/TradingTerminal.Infrastructure/Plugins/Feed/PluginRevocationSync.cs)),
71+
and the loader refuses those builds on the next start. Revoke by `sha256` (one bad build) or `id` (all
72+
builds of a plugin).
73+
74+
## The signing key
75+
76+
The feed is protected by an **ECDSA P-256** keypair. The app pins only the **public** key; the **private**
77+
key signs the index and must never leave the maintainer's control (offline, or a CI secret).
78+
79+
Mint a keypair once with
80+
[`FeedSigner`](../src/windows/Pipeline/TradingTerminal.Infrastructure/Plugins/Feed/FeedSigner.cs):
81+
82+
```csharp
83+
var keys = FeedSigner.GenerateKeyPair();
84+
// keys.PublicKeyBase64 -> paste into PluginsOptions.FeedPublicKey (shipped in the app)
85+
// keys.PrivateKeyBase64 -> store as a SECRET (offline vault or the publish repo's Actions secret)
86+
```
87+
88+
- **Public key**`Plugins:FeedPublicKey` in the app's `appsettings.json`. It is base64
89+
`SubjectPublicKeyInfo`; an index whose signature doesn't verify against it is ignored (Activity Log
90+
warning), so a tampered or unsigned feed cannot inject a plugin.
91+
- **Private key** → base64 PKCS#8. Never commit it. **Rotation:** ship a new public key in an app update,
92+
re-sign the index with the new private key; until users update, keep serving a copy signed by the old key
93+
if you must support both (or accept that un-updated apps stop seeing feed changes — they never load an
94+
*unsigned* feed, which is the safe failure).
95+
96+
## Signing + publishing the index
97+
98+
The signature is a detached ECDSA-P256/SHA-256 signature over the **raw index bytes** (no reformatting —
99+
the verifier is byte-exact, so sign the file exactly as served):
100+
101+
```csharp
102+
// In the publish workflow, with the private key from a secret:
103+
FeedSigner.SignIndexFile("docs/plugins-index.json", Environment.GetEnvironmentVariable("FEED_PRIVATE_KEY")!);
104+
// writes docs/plugins-index.json.sig
105+
```
106+
107+
Then deploy `docs/` to Pages. Publish order matters: upload the **package** release asset first, then the
108+
**index+sig** — never advertise a `url`/`sha256` before the bytes exist.
109+
110+
A minimal publish flow (regenerate index from submissions → sign → deploy) belongs in the registry repo's
111+
`publish.yml`; it can call the two `FeedSigner` lines above via a tiny `dotnet run`, or reproduce the same
112+
ECDSA-P256/SHA-256 operation in any language.
113+
114+
## Submissions → index
115+
116+
Listings are curated and source-required (see [marketplace-policy.md](marketplace-policy.md)). The intended
117+
pipeline: a private `daxalgo-plugin-submissions` repo takes a PR with source + `plugin.json` + declared
118+
permissions; CI **builds from source** (reproducible package hash), runs the plugin's tests and the IL
119+
policy scanner, a human reviews the diff, and on approval the `.daxplugin` is signed (Authenticode, the
120+
`signatureThumbprint`) and an index entry is generated into `daxalgo-plugins`. Until that repo exists, an
121+
operator can hand-build the index and sign it with the flow above.
122+
123+
## Turning it on in the app
124+
125+
Both keys must be set or the Catalog tab stays hidden and no feed is fetched:
126+
127+
```json
128+
"Plugins": {
129+
"FeedUrl": "https://dhruuvsharma.github.io/daxalgo-plugins/plugins-index.json",
130+
"FeedPublicKey": "MFkwEwYHKoZIzj0CAQYI…" // base64 SubjectPublicKeyInfo from GenerateKeyPair
131+
}
132+
```
133+
134+
On launch the app refreshes in the background (never blocking startup), caches the last-good index under
135+
`%LocalAppData%/DaxAlgoTerminal/plugin-feed/`, and syncs revocations. The Plugin Manager's Catalog tab then
136+
lets users browse, install, and update — every install still going through the full
137+
manifest / SDK / trust / IL-scan gate chain.
138+
139+
See also: [plugins.md](plugins.md) · [plugin-security.md](plugin-security.md) ·
140+
[marketplace-policy.md](marketplace-policy.md) · [LICENSE-EXCEPTIONS.md](../LICENSE-EXCEPTIONS.md).
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using System.IO;
2+
using System.Security.Cryptography;
3+
4+
namespace TradingTerminal.Infrastructure.Plugins.Feed;
5+
6+
/// <summary>
7+
/// The offline / CI counterpart to <see cref="FeedSignatureVerifier"/>: mints the ECDSA P-256 keypair
8+
/// whose public half is pinned in the app (<c>PluginsOptions.FeedPublicKey</c>), and produces the DETACHED
9+
/// signature over the raw <c>plugins-index.json</c> bytes that the verifier checks. It is byte-for-byte the
10+
/// same operation the verifier inverts (SHA-256, a raw signature over the raw index bytes with no
11+
/// re-serialization), so what this signs is exactly what the app will accept.
12+
/// <para>
13+
/// This is <b>publish-side maintainer / CI tooling</b> — it takes a PRIVATE key that must never reach the
14+
/// app or a user machine (keep it offline, or as a CI secret). The shipped app only ever holds the public
15+
/// key and only ever verifies. See <c>docs/marketplace-hosting.md</c> for the key-custody + publish flow.
16+
/// </para>
17+
/// </summary>
18+
public static class FeedSigner
19+
{
20+
/// <summary>A fresh feed-signing keypair.</summary>
21+
/// <param name="PrivateKeyBase64">Base64 PKCS#8 private key — kept OFFLINE / secret; used only to sign.</param>
22+
/// <param name="PublicKeyBase64">Base64 SubjectPublicKeyInfo public key — paste into
23+
/// <c>PluginsOptions.FeedPublicKey</c> so the app can verify what this key signs.</param>
24+
public sealed record FeedKeyPair(string PrivateKeyBase64, string PublicKeyBase64);
25+
26+
/// <summary>Generates a new ECDSA P-256 keypair for signing the feed.</summary>
27+
public static FeedKeyPair GenerateKeyPair()
28+
{
29+
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
30+
return new FeedKeyPair(
31+
Convert.ToBase64String(ecdsa.ExportPkcs8PrivateKey()),
32+
Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()));
33+
}
34+
35+
/// <summary>Signs <paramref name="indexBytes"/> with the base64 PKCS#8 private key and returns the
36+
/// detached signature as base64 — the exact contents to store beside the index as
37+
/// <c>&lt;index&gt;.sig</c>.</summary>
38+
public static string Sign(byte[] indexBytes, string privateKeyBase64)
39+
{
40+
using var ecdsa = ECDsa.Create();
41+
ecdsa.ImportPkcs8PrivateKey(Convert.FromBase64String(privateKeyBase64), out _);
42+
return Convert.ToBase64String(ecdsa.SignData(indexBytes, HashAlgorithmName.SHA256));
43+
}
44+
45+
/// <summary>Reads the index at <paramref name="indexPath"/>, signs its raw bytes, and writes the
46+
/// detached signature to <c>&lt;indexPath&gt;.sig</c> (what the feed serves next to the index).
47+
/// Returns the signature file path.</summary>
48+
public static string SignIndexFile(string indexPath, string privateKeyBase64)
49+
{
50+
var signature = Sign(File.ReadAllBytes(indexPath), privateKeyBase64);
51+
var signaturePath = indexPath + ".sig";
52+
File.WriteAllText(signaturePath, signature);
53+
return signaturePath;
54+
}
55+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System.IO;
2+
using System.Text;
3+
using FluentAssertions;
4+
using TradingTerminal.Infrastructure.Plugins.Feed;
5+
using Xunit;
6+
7+
namespace TradingTerminal.Tests.Plugins;
8+
9+
/// <summary>
10+
/// The signer and verifier are a matched pair (issue #25): a key minted by <see cref="FeedSigner"/> signs
11+
/// an index that the app's <see cref="FeedSignatureVerifier"/> — using only the paired public key — accepts,
12+
/// and any tamper or key mismatch is rejected. This locks the publish-side crypto to the exact operation
13+
/// the shipped app inverts.
14+
/// </summary>
15+
public sealed class FeedSignerTests
16+
{
17+
private const string Index = """
18+
{ "feedVersion": 1, "plugins": [ { "id": "a.plugin", "name": "A", "publisher": "P",
19+
"description": "d", "latest": { "version": "1.0.0", "sdkVersion": "0.1.0-alpha",
20+
"url": "https://x/a-1.0.0.daxplugin", "sha256": "AA" } } ] }
21+
""";
22+
23+
[Fact]
24+
public void A_key_it_mints_signs_an_index_the_app_verifier_accepts()
25+
{
26+
var keys = FeedSigner.GenerateKeyPair();
27+
var indexBytes = Encoding.UTF8.GetBytes(Index);
28+
29+
var signature = FeedSigner.Sign(indexBytes, keys.PrivateKeyBase64);
30+
var result = new FeedSignatureVerifier(keys.PublicKeyBase64).Verify(indexBytes, signature);
31+
32+
result.Outcome.Should().Be(FeedVerifyOutcome.Ok);
33+
result.Index!.Plugins.Should().ContainSingle().Which.Id.Should().Be("a.plugin");
34+
}
35+
36+
[Fact]
37+
public void A_tampered_index_no_longer_verifies()
38+
{
39+
var keys = FeedSigner.GenerateKeyPair();
40+
var signature = FeedSigner.Sign(Encoding.UTF8.GetBytes(Index), keys.PrivateKeyBase64);
41+
42+
var tampered = Encoding.UTF8.GetBytes(Index.Replace("a.plugin", "evil.plugin"));
43+
var result = new FeedSignatureVerifier(keys.PublicKeyBase64).Verify(tampered, signature);
44+
45+
result.Outcome.Should().Be(FeedVerifyOutcome.BadSignature);
46+
}
47+
48+
[Fact]
49+
public void A_signature_from_a_different_key_is_rejected()
50+
{
51+
var signingKeys = FeedSigner.GenerateKeyPair();
52+
var otherKeys = FeedSigner.GenerateKeyPair();
53+
var indexBytes = Encoding.UTF8.GetBytes(Index);
54+
55+
var signature = FeedSigner.Sign(indexBytes, signingKeys.PrivateKeyBase64);
56+
var result = new FeedSignatureVerifier(otherKeys.PublicKeyBase64).Verify(indexBytes, signature);
57+
58+
result.Outcome.Should().Be(FeedVerifyOutcome.BadSignature);
59+
}
60+
61+
[Fact]
62+
public void SignIndexFile_writes_a_detached_sig_beside_the_index_that_verifies()
63+
{
64+
var dir = Path.Combine(Path.GetTempPath(), "daxalgo-tests", "signer-" + Guid.NewGuid().ToString("N"));
65+
Directory.CreateDirectory(dir);
66+
try
67+
{
68+
var indexPath = Path.Combine(dir, "plugins-index.json");
69+
File.WriteAllText(indexPath, Index);
70+
var keys = FeedSigner.GenerateKeyPair();
71+
72+
var sigPath = FeedSigner.SignIndexFile(indexPath, keys.PrivateKeyBase64);
73+
74+
sigPath.Should().Be(indexPath + ".sig");
75+
var result = new FeedSignatureVerifier(keys.PublicKeyBase64)
76+
.Verify(File.ReadAllBytes(indexPath), File.ReadAllText(sigPath));
77+
result.Outcome.Should().Be(FeedVerifyOutcome.Ok);
78+
}
79+
finally
80+
{
81+
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)