diff --git a/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md b/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md new file mode 100644 index 0000000..99956a1 --- /dev/null +++ b/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md @@ -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 `), 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 ` 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 \n" (+ "parent \n")? + "author \n" + + "committer \n" + "\n" + + "\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/.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) +```