Files
relicario/docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md
adlee-was-taken c6abcf8da7 docs(plan-c): re-scope to universal isomorphic-git receive-pack push
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
2026-06-25 23:22:41 -04:00

17 KiB

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/blobsPOST /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-packHTTP 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.