spike(org-write): GO — SW-built SSHSIG commit passes the org hook; transport is host-specific

Task 1 GO/NO-GO for v0.9.0 Plan C (org write). Proven locally end-to-end: a
commit signed with ONLY the raw sign_for_git primitive + manual SSHSIG framing
(no new WASM export) passes both `git verify-commit` and `relicario-server
verify-org-commit`, incl. item+manifest dual-write to a granted collection with
full path authz. Hook matches on the signing-KEY fingerprint -> members[].
ed25519_pubkey; committer/author text is free-form (not checked).

Transport diverges by host:
- GitHub: Git Data API `POST /git/commits` has a `signature` field, inserted
  verbatim into gpgsig -> LIKELY-GO (1 live round-trip to confirm SSHSIG, not
  just PGP, is stored byte-for-byte).
- Gitea: `/git` API is GET-only on live 1.25.5 (no create-commit) -> REST
  NO-GO. Viable path is a `git-receive-pack` smart-HTTP packfile push
  (endpoint reachable, 401 Basic-auth) = bigger Task 2 than planned.

Also flags a pre-existing latent bug: gitea.ts putBlob calls nonexistent
Gitea /git write endpoints. Scope decision (GitHub-only vs build the Gitea
packfile pusher vs defer write) raised to PM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha
This commit is contained in:
adlee-was-taken
2026-06-25 21:05:03 -04:00
parent 79284979c1
commit 426b82a7d2

View File

@@ -0,0 +1,138 @@
# Spike: Org signed-commit push via host APIs — GO/NO-GO
- **Date run:** 2026-06-25 (Dev-C, v0.9.0 Plan C / org write)
- **Status:** **COMPLETE — GO** (signature mechanism proven; transport is host-specific, see below)
- **Plan:** `docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md` Task 1
- **Spec:** `docs/superpowers/specs/2026-06-20-extension-org-gui-design.md` § "The org-write signing gate"
## The question
> Can a commit signed **in the service worker** with the device key (no `git`, no `ssh-keygen`), pushed via a host API, survive server-side `git verify-commit` **and** `relicario-server verify-org-commit`? Per host: Gitea, then GitHub.
## TL;DR verdict
| Layer | Result |
|---|---|
| **Signature mechanism** (SW builds a commit the hook accepts) | ✅ **GO — proven locally end-to-end**, both `git verify-commit` and `verify-org-commit` pass; needs **no new WASM export** |
| **GitHub transport** (Git Data API + `signature` field) | ✅ **LIKELY-GO** — documented & sound; one live `create→fetch→verify-commit` round-trip is the only unconfirmed link (docs say "PGP", SSHSIG acceptance unproven in public) |
| **Gitea transport** (the planned REST Git Data API) | ❌ **NO-GO** — Gitea's `/git` API is **GET-only** (verified live on git.adlee.work 1.25.5); `POST /git/commits` does not exist, and the Contents API carries no caller signature |
| **Gitea transport** (`git-receive-pack` smart-HTTP push) | ✅ **LIKELY-GO** — endpoint live (401 Basic-auth), preserves the commit byte-for-byte, runs the pre-receive hook; **cost:** an in-browser packfile/pkt-line encoder (isomorphic-git-class) |
**Net:** org write is **feasible on both hosts** — the binary "does a SW-signed commit reach the hook intact" is **GO**. But the *transport* diverges sharply, and **Gitea — the project's primary host — needs a fundamentally heavier transport than the plan assumed.** That is a scope decision for the PM (see § Decision).
## What the hook actually checks (contract, confirmed)
`relicario-server verify-org-commit` (`crates/relicario-server/src/main.rs:286-425`):
1. Rejects merge commits; loads `members.json` **as of the commit** (root bootstrap is special-cased).
2. **`verify_org_signer` (`main.rs:205-284`)** writes an `allowed_signers` file of every current member's `ed25519_pubkey` (line `relicario <pubkey>`), runs `git verify-commit --raw` with `gpg.ssh.allowedSignersFile` injected, then parses the `SHA256:…` fingerprint from stderr and maps it to a member via `relicario_core::fingerprint`.
3. Authorizes each changed path against the signer's role/grants (`PathClass`): item writes require an explicit grant for the collection slug **and** the slug must exist in `collections.json` as of the commit.
**Hook matching rule (PM's question — answered empirically):** the signer is identified **solely by the SSH signing-key SHA-256 fingerprint** matched against `members[].ed25519_pubkey`. **The commit's author/committer name+email are NOT checked by the hook.** In the proof, a commit whose committer was the arbitrary `Spike Owner <owner@relicario.test>` verified fine because the *key* was a member's key. → The extension is free to set any committer identity, **but should set it to the member's identity for audit hygiene** (git's audit log is by committer text). The security gate is the key, not the text.
## The signing primitive: the gap the spike found
The spec/plan said "sign the commit payload with `sign_for_git`." That is **not sufficient as-is**:
- `sign_for_git` (`crates/relicario-wasm/src/lib.rs:253`) → `wasm device::sign_for_git` (`crates/relicario-wasm/src/device.rs:46-53`) → `core_device::sign` (`crates/relicario-core/src/device.rs:58-77`), which returns a **raw ed25519 signature, base64-encoded**. That is **not** the SSHSIG armored format `git verify-commit` requires.
- `git verify-commit` for an SSH-signed commit expects a `gpgsig` header carrying a `-----BEGIN SSH SIGNATURE-----` SSHSIG block whose signature is over `SSHSIG‖namespace("git")‖reserved‖hashalg("sha512")‖SHA-512(payload)`, **not** the raw payload.
**Resolution (and the good news):** because `sign_for_git(data)` signs *arbitrary bytes*, the SW computes the SSHSIG signed-blob itself and passes **that** as `data`. The raw ed25519-over-arbitrary-bytes primitive is exactly what SSHSIG needs at its core. So:
- **No new WASM export is required.** The device secret stays in the WASM `Zeroizing` fortress; only a public SHA-512 digest crosses to be signed, and a public signature crosses back.
- The **SSHSIG framing** (digest → blob → armor → `gpgsig` folding) is pure serialization and lives in the **SW/TypeScript** layer — no dependency on Dev-A's WASM bridge for signing.
### SSHSIG framing spec (the Task-2 reference)
`string(x)` = 4-byte big-endian length ‖ bytes. Namespace `"git"`, hash `"sha512"`.
```
payload = commit object text WITHOUT the gpgsig header:
"tree <sha>\n" (+ "parent <sha>\n")? + "author <id> <ts> <tz>\n"
+ "committer <id> <ts> <tz>\n" + "\n" + <message> + "\n"
H = SHA-512(payload)
signedblob= "SSHSIG" ‖ string("git") ‖ string("") ‖ string("sha512") ‖ string(H)
rawsig = sign_for_git(signedblob) # raw ed25519, 64 bytes (base64-decoded)
pubwire = base64-decode( 2nd field of the OpenSSH "ssh-ed25519 AAAA… " pubkey line )
sigfield = string("ssh-ed25519") ‖ string(rawsig)
armored = "SSHSIG" ‖ u32(1) ‖ string(pubwire) ‖ string("git") ‖ string("")
‖ string("sha512") ‖ string(sigfield)
gpgsig = "-----BEGIN SSH SIGNATURE-----\n" + base64(armored) wrapped@70
+ "\n-----END SSH SIGNATURE-----"
```
The commit object is then `tree/parent/author/committer` + `gpgsig ` + armored (continuation lines prefixed with one SP) + blank line + message. The bytes signed (`payload`) must equal what `git verify-commit` reconstructs by stripping the `gpgsig` header — i.e. the author/committer/tree/parent lines must be byte-identical in the signed payload and the stored object.
## Proof (local, end-to-end)
A throwaway Rust harness (reproduction in § Appendix) built commits using **only** `relicario_core::device::sign` (the exact function `sign_for_git` wraps) + the framing above, inserted them via git plumbing (`git hash-object -w` / `write-tree` / `update-ref` — byte-for-byte what a host stores), and verified:
```
=== ROOT commit (sole owner, manual SSHSIG) ===
git verify-commit: PASS | Good "git" signature for relicario with ED25519 key SHA256:dwS2mGgEEP…
verify-org-commit: PASS | OK: org commit … verified — signed by 'Owner' (Owner), 4 path(s) authorized
=== ITEM-WRITE commit (items/prod-infra/<id>.enc + manifest.enc, one signed commit) ===
git verify-commit: PASS | Good "git" signature …
verify-org-commit: PASS | OK: org commit … verified — signed by 'Owner' (Owner), 3 path(s) authorized
=== NEGATIVE control (sign payload A, store payload B) ===
git verify-commit: FAIL (want FAIL) | Signature verification failed: incorrect signature
```
This exercises the **dual-write invariant** (item + manifest in one signed commit), the **grant + slug-existence** path authz, and the **committer-identity-is-free-form** finding, against the real `relicario-server` binary.
## Host transport findings
### GitHub — Git Data API + `signature` (LIKELY-GO)
- `POST /repos/{owner}/{repo}/git/commits` has a **`signature`** body param; GitHub **inserts it verbatim into the `gpgsig` header** (it has no private key — it cannot re-sign). The signature must cover "the string commit as it would be written to the object database" = exactly the `payload` above. The `gpgsig` header is signature-type-agnostic, so SSHSIG armor stores like PGP armor.
- Sequence: `POST /git/blobs``POST /git/trees` (`base_tree` + entries) → `POST /git/commits` (`message,tree,parents,author,committer,signature`) → `PATCH /git/refs/heads/{branch}` (`sha`, `force:false`). Scope: fine-grained **Contents: write** / classic `repo`.
- **Byte-repro hazard:** the date. GitHub preserves the *exact* timestamp you send; send **both** `author` and `committer` with explicit unix-seconds + matching offset (don't let committer default to author, don't let GitHub fill UTC). GitHub echoes `verification.payload` = "the value that was signed" — use it as a self-check.
- GitHub's "Verified/Unverified" badge is **irrelevant** to the server (our keys aren't registered as GitHub signing keys; expect "Unverified"). Do **not** enable branch protection "require signed commits" on the target branch — that gates on GitHub's verdict, not ours.
- **Residual (the one unconfirmed link):** docs only ever say "PGP signature"; no published example pushes an **SSHSIG** via this endpoint and passes `git verify-commit`. Needs **one live round-trip** to confirm GitHub stores a non-PGP signature unaltered.
### Gitea — REST Git Data API = NO-GO; receive-pack = LIKELY-GO
- **`/git` API is GET-only.** Verified live: `git.adlee.work/api/v1/version` = **1.25.5**; its `swagger.v1.json` lists 9 `/git/` paths, **all GET** (blobs/commits/trees/refs/tags/notes). There is **no** `POST /git/blobs|trees|commits` and **no** `PATCH /git/refs`. (Corroborated by go-gitea source: the `/git` route group is read-only; the only multi-file write is the Contents API's `ChangeFiles`, which builds the commit server-side.)
- The **Contents API** carries no caller `signature` (only a `Signoff` text trailer); Gitea optionally signs CRUD commits with a **single shared instance key** that can never match a per-member device key. → unusable for this hook.
- ⚠️ **Discovered latent bug (out of my stream — flagged to PM):** the shipped `extension/src/service-worker/gitea.ts:162-243` (`putBlob` large-blob fallback) calls `POST /git/blobs`, `POST /git/trees`, `POST /git/commits`, `PATCH /git/refs` — endpoints that **do not exist on Gitea**. Any attachment over `BLOB_THRESHOLD_BYTES` pushed to a Gitea host would 404. Pre-dates this stream (≈v0.8.2).
- **Viable Gitea transport: `git-receive-pack` smart-HTTP push.** Live probe: `GET …/relicario.git/info/refs?service=git-receive-pack`**HTTP 401 `WWW-Authenticate: Basic realm="Gitea"`** (endpoint exists, needs auth; `git-upload-pack` → 200). Gitea pipes the body to the real `git-receive-pack`, so the pushed commit is stored byte-for-byte (preserving `gpgsig`) and the **pre-receive hook runs**. Auth = `Authorization: Basic base64(user:token)` (PAT as password), `write:repository` scope. A SW `fetch()` with host permissions can send the binary body; the `chrome.runtime.sendMessage` ArrayBuffer-drop issue does not apply (binary stays in the SW).
- **Cost:** the client must implement the smart-HTTP push wire format — read `info/refs` for the old-sha/caps, emit a pkt-line `git-receive-pack` request + a (thin) **packfile** (zlib objects + checksum) matching the **repo's hash algorithm** (Gitea default SHA-1). Best de-risked by vendoring isomorphic-git's push/pack layer. This is materially more than the plan's "extend the existing Git Data API code."
## Implications for Task 2 (`commitSigned`)
1. **No WASM change for signing.** Reuse `sign_for_git` as-is; do SSHSIG framing in the SW (SHA-512 via WebCrypto, byte assembly, base64). No Dev-A signing dependency.
2. **`commitSigned` must be transport-polymorphic per host:**
- **GitHub:** the planned Git Data API path + the `signature` field — close to the plan. Add explicit `author`/`committer` with fixed timestamps so the signed payload is byte-reproducible.
- **Gitea:** **not** the Git Data API (it doesn't exist). A `git-receive-pack` smart-HTTP packfile push. Bigger surface; consider isomorphic-git.
3. The hook authorizes on signing key, not committer text — but set committer = member identity for the audit log.
4. The existing `gitea.ts` Git-Data-API fallback is a dead path on Gitea; do not "extend" it for write (and the attachment bug should be fixed separately).
## Decision (for the PM)
The mechanism is GO, so org write **will** work; the question is **Gitea-write scope**, since Gitea is the project's primary host (GitHub-only write has little real-world value here):
- **Option A — GitHub-only write in v0.9.0**, defer Gitea write to a follow-up that builds the receive-pack pusher. Smallest scope; lowest real value (user is on Gitea).
- **Option B — Build the Gitea receive-pack packfile pusher in v0.9.0** (vendor/port isomorphic-git's push layer). Highest value; materially larger/riskier Task 2 than planned.
- **Option C — Defer org write entirely to a follow-up** (ship org **read** + the keyfile track in v0.9.0, exactly the spec's documented value-preserving fallback) and do write properly (both transports) as a focused follow-up.
**Dev-C recommendation: Option C or B, not A.** The spike's purpose was to learn the transport reality before building; it revealed the planned transport is wrong for the host that matters. Building a packfile pusher is real work that deserves its own scoped lift rather than being smuggled into Task 2 as written. Option A ships the low-value half. Final call is the PM's/user's.
## Residuals requiring live credentials (cannot close in-sandbox)
- **GitHub:** one `create-commit-with-SSHSIG → fetch → git verify-commit` round-trip on a throwaway repo + token. Needed to convert GitHub from LIKELY-GO to GO.
- **Gitea:** one live receive-pack push of a manually-signed commit to a throwaway org repo (not the source repo) + token, to confirm the hook accepts it end-to-end over the wire. (The server-side behavior is already proven locally; this confirms only the transport.)
No GitHub/Gitea tokens or sandbox repos were available in this environment.
## Appendix — reproduction
Harness (standalone Rust, depends on `relicario-core` by path; held in the Dev-C scratchpad, not committed): generates an org keypair via `device::generate_keypair`, scaffolds `members.json`/`collections.json`/`org.json` + opaque `manifest.enc`, signs root + item-write commits via the SSHSIG framing above using `device::sign`, inserts via `git hash-object/write-tree/update-ref`, and runs `git verify-commit` + `target/debug/relicario-server verify-org-commit`. The signing routine is reproduced verbatim in § "SSHSIG framing spec" above. Live probes:
```
curl -s https://git.adlee.work/api/v1/version # {"version":"1.25.5"}
curl -s https://git.adlee.work/swagger.v1.json | jq '.paths|keys[]|select(test("/git/"))' # all GET
curl -s -o /dev/null -w '%{http_code}' \
'https://git.adlee.work/alee/relicario.git/info/refs?service=git-receive-pack' # 401 (exists)
```