Mini-spike (isomorphic-git) proved the universal write path end-to-end with no
live creds: Tier 1 commit({onSign}) embeds our SSHSIG -> git verify-commit +
verify-org-commit both PASS; Tier 2 push over git-receive-pack to a bare repo
running the real org pre-receive hook ACCEPTS a member-signed commit and
REJECTS a non-member one.
- Spike doc: + Addendum recording the isomorphic-git Tier 1/2 results + Task 2
implications (in-memory fs only, http/web, onAuth Basic, no new WASM export).
- Plan doc: architecture re-scoped (Gitea has no write Git Data API at any
version -> single universal receive-pack path for both hosts); Task 1 marked
DONE/GO; Task 2 rewritten as the universal commitSigned; Dev-A OrgManifest
contract + Tasks 3-5 HELD-on-A/B recorded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha
156 lines
17 KiB
Markdown
156 lines
17 KiB
Markdown
# 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; universal isomorphic-git packfile-push transport proven 2026-06-25 — see **Addendum**)
|
|
- **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)
|
|
```
|
|
|
|
## Addendum (2026-06-25): universal isomorphic-git packfile push — PROVEN end-to-end
|
|
|
|
**Decision (user):** build org write NOW in v0.9.0 as a **single universal write path for both Gitea and GitHub** — a native `git-receive-pack` smart-HTTP packfile push driven by **isomorphic-git** in the service worker. The GitHub Git Data API + `signature` approach is retired (one path, not two). Rationale: Gitea has no write Git Data API at any version (§ above), and receive-pack is host-agnostic, so it also covers GitHub.
|
|
|
|
A follow-on mini-spike validated the isomorphic-git integration, again with **no live-host credentials**:
|
|
|
|
- **Tier 1 — signature survives isomorphic-git serialization.** `git.commit({ onSign })` hands the callback the exact serialized commit payload; the callback returns our SSHSIG armor (raw ed25519 over `SSHSIG‖git‖sha512‖H(payload)`, the framing proven above), and isomorphic-git embeds it as `gpgsig`. Result: real `git verify-commit` **PASS** and `relicario-server verify-org-commit` **PASS**. Because `onSign` signs isomorphic-git's *own* payload, the byte-canonicalization hazard (timestamps, header folding) is eliminated by construction.
|
|
- **Tier 2 — push through the real hook.** `git.push({ http })` over git smart-HTTP `git-receive-pack` to a local `git-http-backend` bare repo carrying the real `relicario-server generate-org-hook` pre-receive hook: an owner-signed commit was **ACCEPTED** (`refs/heads/main ok`), a commit signed by a non-member was **REJECTED** ("pre-receive hook declined"). The hook gates the push exactly as in production.
|
|
|
|
**Implications for Task 2 (now "universal `commitSigned`"):**
|
|
- One `commitSigned` for both hosts: in-memory fs + isomorphic-git shallow `clone` → stage files → `commit({ onSign: sshsig })` → `push` over receive-pack. Host difference is only auth + URL.
|
|
- SW uses `isomorphic-git/http/web`; the extension's `host_permissions` exempt SW `fetch()` from page CORS (no proxy). Auth via `onAuth → { username, password: token }` (Basic) — Gitea PAT-as-password; a GitHub token works as Basic over HTTPS.
|
|
- SSHSIG framing is SW/TypeScript (`sshsig.ts`), calling the existing WASM `sign_for_git` for the one secret operation. Still **no new WASM export**.
|
|
- Residual unchanged: one live push to git.adlee.work + github.com with real tokens (transport-only confirmation).
|
|
|
|
Mini-spike harness: `iso-spike/spike1.mjs` (commit+verify) and `spike2.mjs` (push through the real hook) in the Dev-C scratchpad.
|