git-ents.gitmain
⌘K
foforge
commit b8fba02
spec: specify hosted web sign-in and attributed authorship

roots.web-signin: a hosted session authenticates only by proving an enrolled, active member key over a one-time, session-bound challenge in an SSHSIG namespace distinct from git’s; the payload binds host and challenge, rebuilt locally by the signing tool. receive.attributed-author: committer is always the signing identity, author may be an attributed member, the gate keys off the signer. roots.web-signing gains the member-authored/server-committed clause; roots.single-node-hosted gains the web-UI mount behind the front proxy. The development plan’s phase-7 hosted-web-UI gap paragraphs record the closure.

Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

docs/development-plan.adoc @@ -178,17 +178,15 @@ a composition root injects. Signing identity is injected by the composition root; the crate assumes nothing about network reachability, so in-process webview embedding stays a supported - deployment (`roots.web-agnostic`). Gap this phase does not close, not - assigned to any later phase either: no composition root actually wires - `ents-web` onto a hosted root — not the single-node hosted root this - binary already doubles as, deployed at `git.ents.cloud` since Phase 6, - and not `git-ents-server` (phase 8, whose own row below merely reuses - this phase's sessions/CSRF code, never mounts the web UI). - `roots.web-signing`'s server-key indirection is proven only - against fixture identities in this crate's own tests; "turning the - single-node hosted root into a hosted web UI," which an earlier - version of this row asserted as delivered, describes the crate's - capability, not a running deployment. + deployment (`roots.web-agnostic`). The gap this phase shipped with — + no composition root wiring `ents-web` onto a hosted root, the + server-key indirection proven only against fixture identities — was + closed after phase 10 by `git ents serve --hosted` + (`roots.single-node-hosted`'s web-UI clause): the single-node hosted + root mounts this same crate behind nginx with the mandatory gate, the + server's enrolled member key as signing identity, and member sign-in + via the CLI-driven challenge flow (`roots.web-signin`, + `receive.attributed-author` — edits land as "member via the web"). | `gix-receive` | 8 @@ -326,11 +324,11 @@ through an identity the composition root injects, never one this crate resolves itself (`roots.web-signing`, `roots.web-agnostic`); the same router is provably reachable with no bound socket, driven - in-process via `tower::ServiceExt::oneshot`. This row's own gap is a - known exception, not a silent one: no hosted deployment (`git.ents.cloud` - or `git-ents-server`) actually mounts this web UI yet, so - `roots.web-signing`'s server-key half stays proven only against - fixture identities until some later phase's composition root wires it. + in-process via `tower::ServiceExt::oneshot`. This row's gap — no + hosted deployment mounting the web UI, the server-key half proven only + against fixtures — closed after phase 10: `git ents serve --hosted` + wires it onto the single-node hosted root at `git.ents.cloud`, with + member sign-in (`roots.web-signin`) gating mutations. * *Phase 8 exit*: the honesty test (`roots.honesty-test`) — the Postgres/Tigris/durable-queue root, now served by `gix-receive` in place of git's own `receive-pack`, is wired as a new composition root
docs/spec/receive.adoc @@ -46,6 +46,19 @@ through uninterpreted until one is. -- +[role="requirement", id="receive.attributed-author"] +.Attributed Author, Signing Committer +-- +A mutation commit's committer MUST be the signing identity — the actor +whose key produces the commit's `gpgsig` header. +The author MAY be a distinct attributed member, so history reads +"<member> via <frontend>" (<<roots.web-signing>>); when no attribution +applies, author and committer MUST be identical. +Gate evaluation and authorization (<<gate.tip-signed>>) MUST key off the +signer, never the author: attribution is provenance for readers of +history, not authority. +-- + [role="requirement", id="receive.multi-ref-atomicity"] .An Entity Declared Across Multiple Refs Writes Them as One Proposal --
docs/spec/roots.adoc @@ -44,6 +44,13 @@ `pre-receive`/`post-receive` hooks call the gate and reconcile obligations around that write, never through this crate's own `RefStore::transaction`, to avoid a double-write race against `receive-pack`'s internal ref update. +This root MUST also mount the web UI: the same `ents-web` frontend the +local root serves (<<roots.web-agnostic>>), wired over this root's own +seams with the mandatory gate and the server's member key as signing +identity (<<roots.web-signing>>), served behind the same host. +The front proxy MUST route git's smart-HTTP paths to stock git's +`http-backend` and every other path to the loopback-bound web process; +the web process MUST NOT add a git transport of its own. This is not <<roots.hosted>>: that root replaces the `RefStore` and object store themselves with Postgres and Tigris once scale forces `receive-pack` out entirely (<<roots.honesty-test>>); this root keeps a real on-disk @@ -88,6 +95,10 @@ -- A hosted web edit MUST be signed by the server's own member key, and that key MUST itself be an enrolled member. +A hosted web edit's commit MUST be authored by the signed-in member +(<<roots.web-signin>>) and committed by the server identity +(<<receive.attributed-author>>), so history reads "member via the web" — +the member is never impersonated as the signer. The local web UI MUST sign edits with the user's own member key instead; the server-key indirection used hosted MUST NOT be imported into the local root. @@ -148,6 +159,32 @@ (<<roots.web-signing>>) only on behalf of an authenticated session. -- +[role="requirement", id="roots.web-signin"] +.Hosted Web Sign-In Proves a Member Key +-- +A hosted web session MUST become authenticated (<<roots.web-session>>) +only by proving control of an enrolled, active member key: the server +issues a one-time challenge bound to exactly one browser session, and the +member's own tooling signs it under an SSHSIG namespace distinct from +git's commit-signing namespace, so a sign-in signature can never double +as a push signature or vice versa. +A challenge MUST be short-lived and single-use; consuming it MUST be the +only path to authenticating the session it was bound to. +The signed payload MUST bind the serving host and the challenge itself, +so a signature cannot be replayed against another deployment or another +session, and the signing tool MUST construct that payload locally from +the host the member addressed, never from bytes the server supplies. +An authenticated session MUST record only the member's username and +public key, held in server memory per <<roots.web-session>>; no member +secret is ever transmitted or stored. +An unauthenticated hosted session MAY browse read-only and MUST NOT +reach any signing path (<<roots.web-signing>>); a mutation under a +session whose member is no longer enrolled and active MUST be refused +at the time of the mutation, not only at sign-in. +The local root (<<roots.local>>) MUST NOT expose a sign-in surface: its +serving identity is the operator's own key, resolved at startup. +-- + [role="requirement", id="roots.path-validation"] .Repository Path Validation --