Skip to content

Commit c77ad10

Browse files
forge: Bitcoin-anchored repo history — git-mark trails via Blocktrails
Per-repo trail: enable → genesis mark from the default-branch tip; every push and PR merge advances a pending mark (post-receive via an onExit seam on the CGI child — core's #271 'post-receive is core, not a plugin' line moved when the forge took the receive path). Derivation is the Blocktrails spec exactly (BIP-341 TapTweak chaining, full-point chain, x-only at the boundary; pinned against the reference implementation's outputs), addresses are bech32m, testnet-only by default (mainnet refused without allowMainnet). The server NEVER touches the chain: it derives and records; owners report txos in order; blocktrails.json (the one CORS-open route) matches the hosted verifier's contract byte-for-byte, so anyone verifies independently. Findings 17-20 include: forge-held trail keys are testnet custody (client-side signing via NIP-07 is the real answer — derivation needs no secrets); and the git-mark CLI does not implement the spec (raw commit scalars, no TapTweak) — the served document self-describes its derivation so the divergence is visible, and verifier interop holds because the verifier walks the spend chain. 98/98 tests.
1 parent 507d6da commit c77ad10

3 files changed

Lines changed: 1130 additions & 31 deletions

File tree

forge/README.md

Lines changed: 201 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests; polish: labels, search, releases)
1+
# forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests; polish: labels, search, releases; tier 3.5: Bitcoin-anchored history via Blocktrails)
22

33
The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44
HTTP, get a GitHub-style (light theme) web UI for it — repo list, file
@@ -16,12 +16,20 @@ wave adds **labels** on issues and PRs (GitHub's default set, colored
1616
chips, `?label=` filters), **read-time search** (repo-list filter +
1717
in-repo `git grep`, bounded, no index), **releases** (every tag with
1818
streamed tar.gz/zip archives), and hides the internal compare refs from
19-
`ls-remote` (closing Finding 15).
20-
Zero npm dependencies, zero build step, no
21-
framework: every page is server-rendered HTML with inline CSS, all git
22-
work is done by the system `git` binary, and the wire protocol is
23-
delegated to the stock `git-http-backend` CGI (gitscratch's proven
24-
plumbing, re-rooted).
19+
`ls-remote` (closing Finding 15). Tier 3.5 adds **git-mark anchoring via
20+
[Blocktrails](https://blocktrails.org)** — Bitcoin(testnet)-anchored,
21+
tamper-evident repo history: every default-branch tip derives a fresh
22+
taproot address; spending the previous mark's output to the new address
23+
IS the advance; the forge only **derives and records** (see "Anchors").
24+
Zero build step, no framework: every page is server-rendered HTML with
25+
inline CSS, all git work is done by the system `git` binary, and the
26+
wire protocol is delegated to the stock `git-http-backend` CGI
27+
(gitscratch's proven plumbing, re-rooted). One npm dependency —
28+
`@noble/curves`, the host's own crypto library — arrived with tier 3.5
29+
for the secp256k1 point math (the `blocktrails` npm package is
30+
deliberately **not** imported: the spec math is implemented locally and
31+
cross-checked in test.js against a vector generated from the
32+
maintainer's reference implementation).
2533

2634
```js
2735
plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
@@ -30,6 +38,8 @@ plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
3038
gitHttpBackend: '/usr/lib/git-core/git-http-backend', // optional
3139
pushTokenTtl: 3600, // default lifetime of exchanged push tokens (s)
3240
cspConnect: [], // extra connect-src origins (e.g. external Solid IdPs)
41+
chain: 'tbtc4', // anchoring chain (testnet4); 'btc' is REFUSED …
42+
allowMainnet: false, // … unless this is explicitly true (see Finding 18)
3343
} }]
3444
```
3545

@@ -86,6 +96,8 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
8696
| `.../pulls/<n>` | PR conversation: state banner (merged / closed / clean-with-merge-button / conflict list), thread, comment form |
8797
| `.../pulls/<n>/commits`, `.../pulls/<n>/files` | GitHub-style sub-tabs: the ahead commits, the structured diff |
8898
| `.../pulls/new?base=...&head=...` | new-PR form (from the compare page) |
99+
| `.../marks` | Anchors: the mark table (commit, state hash, derived tb1p… address, pending/marked chips, mempool.space tx links), chain badge, "Verify independently" hand-off, fund-this-mark box; the Enable pitch when anchoring is off |
100+
| `.../blocktrails.json` | the verifier-compatible trail document — the ONE CORS-open (`Access-Control-Allow-Origin: *`) route |
89101
| `<prefix>/<owner>/<name>.git/...` | git smart HTTP (`info/refs`, `git-upload-pack`, `git-receive-pack`) |
90102
| `<prefix>/api/token` | POST: exchange any `getAgent` credential for a push token (see "Nostr agents") |
91103
| `<prefix>/api/hosted/<hex>/<uuid>` | GET (public) / DELETE (author-only): a podless agent's hosted issue words |
@@ -185,6 +197,26 @@ Polish wave (additive):
185197
— every tag, newest first; `message` is the first line of an annotated
186198
tag's message, `null` for lightweight tags; `sha` is the peeled commit.
187199

200+
Tier 3.5 (additive):
201+
202+
- `GET api/repos/<o>/<n>/marks``{ enabled: false, chain }` or
203+
`{ enabled: true, chain, pubkeyBase, marks: [{ index,
204+
state: {commit, repo, branch}, stateHash, program, address, status:
205+
'pending'|'marked', at, txid?, vout?, amount?, markedAt? }] }` —
206+
never the trail privkey (test-proven).
207+
- `POST .../marks/enable` (OWNER only) → 201 `{ enabled, chain,
208+
pubkeyBase, mark }` — genesis: mints the trail key and derives mark 0
209+
from the current default-branch tip; 422 on an empty repo, 409 when
210+
already enabled (no silent key rotation).
211+
- `POST .../marks/<i>/txo` `{txid, vout, amount}` (OWNER only) →
212+
`{ index, status: 'marked', txid, vout, amount }` — records where the
213+
mark landed on-chain. Shape-validated (64-hex txid, integer vout,
214+
positive integer sats) and **recorded, never verified** — the server
215+
does no chain I/O. 409 out of order (the trail is a linear spend
216+
chain), 409 when already marked.
217+
- `GET <prefix>/<o>/<n>/blocktrails.json` → the verifier document (see
218+
"Anchors"); 404 (still CORS-readable) when anchoring is off.
219+
188220
Errors are `{ error }` with 4xx. `cloneUrl` is absolute: the origin comes
189221
from `api.serverInfo` (#601) at request time, with `config.baseUrl` as
190222
the reverse-proxy override.
@@ -375,6 +407,79 @@ is validated (`okRef`/sha) and resolved with `rev-parse` *before* any
375407
header goes out, so a bogus ref is a clean 404. Any resolvable ref works
376408
— tags, branches, shas — which is exactly GitHub's archive behavior.
377409

410+
## Anchors (tier 3.5): git-mark anchoring via Blocktrails
411+
412+
**The design stance, absolute: the server derives and records; it NEVER
413+
touches the network.** All trail math is pure `@noble/curves` +
414+
`node:crypto`; Bitcoin appears only as derived bech32m addresses,
415+
mempool.space **links** in HTML (the user clicks), the served
416+
`blocktrails.json`, and README instructions for the maintainer's own
417+
CLIs (`fund-agent`, `git mark`) which do the funding/broadcasting
418+
elsewhere. No fetch to any chain API from the plugin, ever (Finding 19).
419+
420+
**The derivation** (blocktrails spec v0.2, normative — implemented from
421+
the spec, not imported):
422+
423+
```
424+
state = { commit, repo, branch } one per default-branch tip
425+
h = sha256(JSON.stringify(state)) the git-mark-demo state-hash convention
426+
tᵢ = tagged_hash("TapTweak", x_only(Pᵢ₋₁) || h) mod n BIP-341 TapTweak
427+
Pᵢ = Pᵢ₋₁ + tᵢ·G chained; P₋₁ = the trail base key
428+
address = bech32m(hrp, 1, x_only(Pᵢ)) tb1p… on testnet4
429+
```
430+
431+
Each state derives a **new** address by tweaking; **spending the
432+
previous mark's UTXO to the new address IS the advance** — a linear,
433+
Bitcoin-ordered chain of commitments. The plugin derives and records
434+
addresses and the expected chain; the transactions happen outside.
435+
436+
**The lifecycle**:
437+
438+
1. **Enable** (`POST .../marks/enable`, owner): mints a fresh trail key
439+
(forge-held, 0600 in `pluginDir/marks/<owner>/<repo>.json` — the
440+
custody trade-off is Finding 18) and derives mark 0 from the CURRENT
441+
default-branch tip.
442+
2. **Advance on tip change**: when `git-receive-pack` completes on an
443+
anchoring-enabled repo (the CGI child's exit is the post-receive
444+
moment — Finding 17), or a PR merge lands via `update-ref`, one
445+
`recordTip` helper compares the default-branch tip to the last mark
446+
and appends a new **pending** mark. Multiple pushes stack pending
447+
marks honestly — each mark is one state; only funded/spent marks ever
448+
become `marked`. Non-default-branch pushes never advance
449+
(test-proven). 500-mark cap per trail.
450+
3. **Fund/spend off-server**: the marks page shows the first pending
451+
mark's address plus copy-paste `npx fund-agent "txo:tbtc4:…"` /
452+
`git mark advance` instructions per the maintainer's READMEs.
453+
4. **Record** (`POST .../marks/<i>/txo`, owner): report `{txid, vout,
454+
amount}`; the mark flips to `marked`. Recorded in order — the trail
455+
is a linear spend chain. Shape-checked, never chain-checked.
456+
5. **Verify independently**: the marks page hands off to the hosted
457+
verifier (`https://blocktrails.github.io/verify/?uri=<this repo's
458+
blocktrails.json>`), which fetches the trail document cross-origin
459+
and checks every mark against the chain **client-side** — tx exists,
460+
confirmed, amount matches, spends the previous mark. The server
461+
records claims; an independent client verifies them. That split is
462+
the architecture's point.
463+
464+
**`blocktrails.json`** is exactly the shape the zero-build verifier
465+
consumes (`@type: Blocktrail`, `profile: gitmark`, `pubkeyBase`,
466+
`chain`, `states[]` = the marked commits, `txo[]` =
467+
`txo:<chain>:<txid>:<vout>?amount=…&commit=…&pubkey=…` URIs), plus
468+
additive self-description (`derivation`, `stateHash`, and the full
469+
`marks[]` list including pendings). It is served with
470+
`Access-Control-Allow-Origin: *` — a deliberate one-route CORS grant:
471+
the document is a public verification artifact whose entire purpose is
472+
to be fetched cross-origin by a verifier page; everything in it is
473+
already public on the marks page, so CORS widens reach, not exposure.
474+
(The hosted-verifier hand-off is a plain `<a href>` navigation, which
475+
CSP's `connect-src 'self'` does not govern — no CSP delta needed.)
476+
477+
**Testnet-only defaults**: `chain: 'tbtc4'` (testnet4). Mainnet chain
478+
identifiers (`btc`/`mainnet`/`bitcoin`) are **refused at activate**
479+
loudly, naming the stakes — unless `config.allowMainnet: true`; unknown
480+
chains are refused too (no address is better than a wrong-network
481+
address someone might fund).
482+
378483
## Nostr agents (tier 2.5)
379484

380485
### Identity model — hex canonical, npub display-only
@@ -798,3 +903,92 @@ invisible — the page renders, the button clicks, nothing happens, and
798903
only the browser console says why. Same lesson as Finding 8's
799904
connect-src discovery: each new *kind* of page interactivity trips a
800905
different CSP directive.
906+
907+
### 17. Core called git-mark post-receive "core, not a plugin" (#271) — the forge moved that line
908+
909+
Core's ISSUES.md argued a git-mark hook must live in core because only
910+
core owns the receive path. The forge is the counter-witness: because
911+
this plugin delegates the wire protocol to `git-http-backend` but OWNS
912+
the CGI child, "receive finished, refs are final" is simply the child's
913+
`close` event — a one-line `onExit` seam on the existing `runBackend`.
914+
Both tip-movers route through one `recordTip` helper: receive-pack via
915+
`onExit`, PR merges via a direct call after `update-ref` (they move the
916+
tip without any receive-pack). `recordTip` re-checks everything under
917+
the per-repo mark lock (compares the tip to the last mark's state), so
918+
firing it on *any* child exit — even a partially failed push — is
919+
correct rather than optimistic. What made it plugin-able wasn't a new
920+
api seam; it was owning the receive path in the first place. The test
921+
wrinkle: the hook fires after the response, so push-advance assertions
922+
poll (the merge route, by contrast, awaits `recordTip` before
923+
answering — the mark is in the response's happens-before).
924+
925+
### 18. Forge-held trail keys are testnet custody — real custody is the xlogin/NIP-07 path
926+
927+
Enabling anchoring mints the trail privkey server-side and keeps it in
928+
`pluginDir/marks/<owner>/<repo>.json` (0600, never crossing an API
929+
surface — test-proven against every marks route). That is CUSTODY: the
930+
operator can read the key, and whoever holds the key controls all
931+
future transitions and the funds in the head output (the spec's own
932+
threat model). On testnet4 — the enforced default — the stakes are
933+
demonstration sats, and the convenience (the forge can derive every
934+
future address without a signing round-trip) is worth it. On mainnet it
935+
would not be, which is exactly why `chain: 'btc'` is refused at
936+
activate unless `allowMainnet` is explicit. The honest next step is
937+
client-side keys: the owner's OWN key signs each advance in the browser
938+
(xlogin already ships NIP-07/NIP-98 plumbing on these very pages), the
939+
forge stores only `pubkeyBase` and derives addresses from public
940+
material — the derivation needs no secrets, only the spend does. Then
941+
the forge is purely a recorder and the custody Finding dissolves.
942+
943+
### 19. Derive-and-record vs. broadcast-and-verify: the SSRF-free anchoring architecture
944+
945+
The obvious design — server calls mempool.space to check funding, maybe
946+
broadcasts too — makes the pod server an outbound HTTP client steered
947+
by user-shaped data (SSRF surface, availability coupling, and a trusted
948+
third party smuggled into the trust chain). The split here keeps the
949+
server pure: it DERIVES addresses (local math) and RECORDS owner-shaped
950+
claims (shape-validated JSON); funding and broadcasting happen in the
951+
owner's own CLIs; verification happens in an independent, zero-build
952+
client-side verifier that anyone can point at the served
953+
`blocktrails.json`. The server never learns whether a claim is true —
954+
and doesn't need to, because the verifier checks the actual chain and
955+
the claims are cheap-talk until it does. One deliberate CORS grant
956+
(`Access-Control-Allow-Origin: *` on `blocktrails.json` alone) is the
957+
entire integration surface between the two halves. Bitcoin appears in
958+
the plugin as: bech32m strings, `https://mempool.space/...` hrefs, and
959+
instructions. `grep -c fetch` on the anchoring code: zero.
960+
961+
### 20. x-only parity in TapTweak land — and the maintainer's own CLI disagrees with the spec
962+
963+
Two real discoveries from implementing the derivation instead of
964+
importing it:
965+
966+
- **Parity is an output-encoding concern, not a chain concern.** The
967+
spec keeps FULL points through the chain (`Pᵢ = Pᵢ₋₁ + tᵢ·G`, parity
968+
and all) and takes `x_only` twice per step: as the tweak input (the
969+
raw x of the possibly-odd-Y running point — NOT re-lifted to even-Y
970+
as BIP-341 wallet stacks do between steps) and at the output boundary
971+
(where `x(P) == x(−P)` makes comparison parity-free; only a SPENDER
972+
must negate per BIP-340, and this plugin never spends). Get the
973+
lift-x placement wrong and addresses diverge silently — which is why
974+
the test suite pins a vector generated by RUNNING the maintainer's
975+
reference implementation (`blocktrails` deriveChainedPublicKey +
976+
p2trXonly on fixed inputs; the mirror carries no hard-coded spec
977+
vectors, so the reference impl is the vector source), plus an
978+
independent bech32m decode round-trip. bech32 vs bech32m turned out
979+
to be ONE constant (`1` vs `0x2bc830a3`) over the same polymod — the
980+
tier-2.5 npub encoder and the tb1p… encoder now share their guts.
981+
- **The git-mark CLI (npm `git-seal`) does not implement the spec's
982+
derivation.** It uses the raw commit hash as the scalar
983+
(`t = commit mod n` — no TapTweak, no state hash, no pubkey
984+
binding), while the spec mandates
985+
`tagged_hash("TapTweak", x_only(P) || sha256(state))` and the
986+
git-mark-demo hashes `{commit, repo, branch}`. This plugin follows
987+
the SPEC (the task's normative reference) with the demo's state
988+
convention — so addresses derived here will not match `git mark
989+
address` for the same commits under the same key. Interop survives
990+
because the hosted verifier checks the SPEND CHAIN (tx exists,
991+
confirmed, amount, spends-previous), not the derivation; the
992+
divergence is written down here and self-described in the served
993+
document (`derivation`, `stateHash` fields) so a future re-deriving
994+
verifier knows exactly what it is looking at.

0 commit comments

Comments
 (0)