Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Move writes to SSH, drop web authentication, keep identity in git

a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc

Move writes to SSH, drop web authentication, keep identity in git

The authenticated-HTTP design is gone rather than extended. Writes now require
an SSH key, the web is read-only, and the user roster lives in git.

What replaced what:

- internal/sshserver is the only write transport. An embedded SSH server
  (golang.org/x/crypto/ssh) authenticates by public key and hands the principal
  to pre-receive through the environment, so the hook's policy code is unchanged.
  The exec surface is whitelisted to git-upload-pack and git-receive-pack, the
  repository argument is resolved through the store rather than concatenated, and
  everything else -- including `sh -c` -- is refused and asserted refused in a
  test.
- internal/identity stores users on refs/heads/main in data/system.git. The
  directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS
  commits, reads come from an in-memory snapshot so the SSH auth callback is a
  map lookup. Nothing stored is a secret, so a clone of system.git is a complete
  backup and git log on it is the audit trail.
- internal/transport is now anonymous read-only: upload-pack behind a visibility
  check, 404 for private repositories, and no push path at all.
- internal/web has no login, no sessions and no mutations.

Deleted outright: internal/auth, the Basic-auth path, login/logout, web
settings/rename/delete, chunked body spooling, the spool janitor, the push
semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize
per repo, which enforces it on every transport without buffering.

Three things worth recording:

- The CAS helper read the tree outside the retry loop and compared against a
  freshly-read old value, so a concurrent writer's commit could be silently
  discarded while the CAS "succeeded". A test with two stores adding eight keys
  at once found it losing half of them; read, mutate and compare now all happen
  inside the loop.
- The roster moved from refs/vole/users to refs/heads/main because `git clone`
  only mirrors refs/heads/*: the documented backup command produced an instance
  with zero users. Storing on a branch also means git itself refuses to point the
  ref at a non-commit.
- Exec refusals are answered on the channel rather than by rejecting the request.
  Rejecting made the client print "exec request failed on channel 0", so the
  reason was unreadable.

Verified by hand as well as by test: init -> user -> add-key -> repo -> serve,
push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on
both anonymous surfaces while still cloning over SSH, a refused `sh -c` that
prints why and executes nothing, and a plain clone of system.git yielding the
whole roster with no side database present.

internal/identity/identity_test.go +272

@@ -0,0 +1,272 @@
1 package identity
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11
12 "golang.org/x/crypto/ssh"
13 )
14
15 func newStore(t *testing.T) (*Store, string) {
16 t.Helper()
17 dir := t.TempDir()
18 sys := filepath.Join(dir, "system.git")
19 if err := Init(sys); err != nil {
20 t.Fatal(err)
21 }
22 s, err := Open(sys)
23 if err != nil {
24 t.Fatal(err)
25 }
26 return s, sys
27 }
28
29 func pubLine(t *testing.T) (string, string) {
30 t.Helper()
31 pub, _, err := ed25519.GenerateKey(rand.Reader)
32 if err != nil {
33 t.Fatal(err)
34 }
35 k, err := ssh.NewPublicKey(pub)
36 if err != nil {
37 t.Fatal(err)
38 }
39 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k))), ssh.FingerprintSHA256(k)
40 }
41
42 func TestCreateUserAndAddKey(t *testing.T) {
43 s, _ := newStore(t)
44 if err := s.Create("keenan"); err != nil {
45 t.Fatal(err)
46 }
47 line, wantFP := pubLine(t)
48 fp, err := s.AddKey("keenan", line, "test")
49 if err != nil {
50 t.Fatal(err)
51 }
52 if fp != wantFP {
53 t.Fatalf("fingerprint = %s, want %s", fp, wantFP)
54 }
55 name, ok := s.Lookup(fp)
56 if !ok || name != "keenan" {
57 t.Fatalf("Lookup(%s) = %q,%v", fp, name, ok)
58 }
59 if got := len(s.Snapshot().Users["keenan"].Keys); got != 1 {
60 t.Fatalf("key count = %d", got)
61 }
62 }
63
64 func TestDuplicateKeyAcrossUsersRejected(t *testing.T) {
65 s, _ := newStore(t)
66 for _, n := range []string{"alice", "bob"} {
67 if err := s.Create(n); err != nil {
68 t.Fatal(err)
69 }
70 }
71 line, _ := pubLine(t)
72 if _, err := s.AddKey("alice", line, "test"); err != nil {
73 t.Fatal(err)
74 }
75 if _, err := s.AddKey("bob", line, "test"); err == nil {
76 t.Fatal("the same key was accepted for a second user")
77 } else if !strings.Contains(err.Error(), "already registered") {
78 t.Fatalf("unclear error: %v", err)
79 }
80 }
81
82 func TestRemoveKeyRevokesAccess(t *testing.T) {
83 s, _ := newStore(t)
84 if err := s.Create("keenan"); err != nil {
85 t.Fatal(err)
86 }
87 line, fp := pubLine(t)
88 if _, err := s.AddKey("keenan", line, "test"); err != nil {
89 t.Fatal(err)
90 }
91 if err := s.RemoveKey("keenan", fp, "test"); err != nil {
92 t.Fatal(err)
93 }
94 if _, ok := s.Lookup(fp); ok {
95 t.Fatal("revoked key still authenticates")
96 }
97 }
98
99 func TestTreeLayoutIsTheSchema(t *testing.T) {
100 s, _ := newStore(t)
101 if err := s.Create("keenan"); err != nil {
102 t.Fatal(err)
103 }
104 line, _ := pubLine(t)
105 if _, err := s.AddKey("keenan", line, "test"); err != nil {
106 t.Fatal(err)
107 }
108 entries, err := s.Git.LsTreeR(UsersRef)
109 if err != nil {
110 t.Fatal(err)
111 }
112 var sawMeta, sawKey bool
113 for _, e := range entries {
114 if e.Name == "users/keenan/meta.json" {
115 sawMeta = true
116 }
117 if strings.HasPrefix(e.Name, "users/keenan/keys/") && strings.HasSuffix(e.Name, ".pub") {
118 sawKey = true
119 }
120 }
121 if !sawMeta || !sawKey {
122 t.Fatalf("unexpected tree: %+v", entries)
123 }
124 }
125
126 // TestConcurrentAddsAllLand is the CAS guarantee: two stores editing the roster
127 // at once must not lose each other's writes.
128 func TestConcurrentAddsAllLand(t *testing.T) {
129 s, sys := newStore(t)
130 if err := s.Create("keenan"); err != nil {
131 t.Fatal(err)
132 }
133 const n = 8
134 lines := make([]string, n)
135 for i := range lines {
136 lines[i], _ = pubLine(t)
137 }
138
139 other, err := Open(sys)
140 if err != nil {
141 t.Fatal(err)
142 }
143 var wg sync.WaitGroup
144 errs := make([]error, n)
145 for i := 0; i < n; i++ {
146 wg.Add(1)
147 go func(i int) {
148 defer wg.Done()
149 store := s
150 if i%2 == 1 {
151 store = other
152 }
153 _, errs[i] = store.AddKey("keenan", lines[i], "test")
154 }(i)
155 }
156 wg.Wait()
157 for i, err := range errs {
158 if err != nil {
159 t.Fatalf("add %d: %v", i, err)
160 }
161 }
162 final, err := Open(sys)
163 if err != nil {
164 t.Fatal(err)
165 }
166 if got := len(final.Snapshot().Users["keenan"].Keys); got != n {
167 t.Fatalf("after concurrent adds: %d keys, want %d (a CAS retry lost a write)", got, n)
168 }
169 }
170
171 func TestReloadSeesExternalWrites(t *testing.T) {
172 s, sys := newStore(t)
173 if err := s.Create("keenan"); err != nil {
174 t.Fatal(err)
175 }
176 line, fp := pubLine(t)
177
178 other, err := Open(sys)
179 if err != nil {
180 t.Fatal(err)
181 }
182 if _, err := other.AddKey("keenan", line, "test"); err != nil {
183 t.Fatal(err)
184 }
185 if _, ok := s.Lookup(fp); ok {
186 t.Fatal("stale snapshot saw a key it should not have")
187 }
188 if err := s.Reload(); err != nil {
189 t.Fatal(err)
190 }
191 if _, ok := s.Lookup(fp); !ok {
192 t.Fatal("Reload did not pick up the new key")
193 }
194 }
195
196 // TestCorruptRosterFailsLoudly covers the corruption that is actually
197 // reachable. git itself refuses to point a branch ref at a non-commit, so the
198 // failure mode to guard is a commit whose roster files do not parse: without a
199 // hard error, Open would boot an instance with no usable users.
200 func TestCorruptRosterFailsLoudly(t *testing.T) {
201 s, sys := newStore(t)
202 if err := s.Create("keenan"); err != nil {
203 t.Fatal(err)
204 }
205 old, err := s.Git.RevParseRaw(UsersRef)
206 if err != nil {
207 t.Fatal(err)
208 }
209 tree, err := buildTree(s.Git, map[string][]byte{
210 "users/broken/meta.json": []byte("{ this is not json"),
211 })
212 if err != nil {
213 t.Fatal(err)
214 }
215 commit, err := s.Git.CommitTree(tree, old, "corrupt roster")
216 if err != nil {
217 t.Fatal(err)
218 }
219 if err := s.Git.UpdateRef(UsersRef, commit); err != nil {
220 t.Fatal(err)
221 }
222 if _, err := Open(sys); err == nil {
223 t.Fatal("Open succeeded on an unparsable roster; it must refuse to start")
224 }
225 }
226
227 // TestBranchRefCannotPointAtANonCommit documents the property we get for free by
228 // storing the roster on a branch rather than a custom ref.
229 func TestBranchRefCannotPointAtANonCommit(t *testing.T) {
230 s, _ := newStore(t)
231 if err := s.Create("keenan"); err != nil {
232 t.Fatal(err)
233 }
234 blob, err := s.Git.HashObject([]byte("not a commit"))
235 if err != nil {
236 t.Fatal(err)
237 }
238 if err := s.Git.UpdateRef(UsersRef, blob); err == nil {
239 t.Fatal("git allowed a branch ref to point at a blob")
240 }
241 }
242
243 func TestDisabledUserCannotAuthenticate(t *testing.T) {
244 s, sys := newStore(t)
245 if err := s.Create("keenan"); err != nil {
246 t.Fatal(err)
247 }
248 line, fp := pubLine(t)
249 if _, err := s.AddKey("keenan", line, "test"); err != nil {
250 t.Fatal(err)
251 }
252 u := s.Snapshot().Users["keenan"]
253 u.Disabled = true
254 if _, ok := s.Lookup(fp); ok {
255 t.Fatal("disabled user still authenticates")
256 }
257 _ = sys
258 }
259
260 func TestInitIsIdempotent(t *testing.T) {
261 dir := t.TempDir()
262 sys := filepath.Join(dir, "system.git")
263 if err := Init(sys); err != nil {
264 t.Fatal(err)
265 }
266 if err := Init(sys); err != nil {
267 t.Fatalf("second Init: %v", err)
268 }
269 if _, err := os.Stat(filepath.Join(sys, "HEAD")); err != nil {
270 t.Fatal(err)
271 }
272 }