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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Replace the git-backed roster with a trusted keys directory

197ecaa29d992ecce2b6078803f3512f565f3672 · Keenan Nemetz · 18d ago · parent a5f19b3c

Replace the git-backed roster with a trusted keys directory

The roster was 476 lines of CAS retries, tree building and snapshot reloading to
store what amounts to username -> authorized_keys lines. Two of the three fields
in meta.json were unused: ACLs key on username, never on id, and nothing ever set
disabled. The git version was paying for history and tamper evidence that
nothing consumed.

Now:

  data/trusted_keys/keenan.keys    # one authorized_keys line per device

This is the AuthorizedKeysFile convention, so ssh-keygen -lf, ansible, rsync and
an editor all work on it, and dropping a key in by hand is a supported way to add
a user because the running instance re-reads the directory every five seconds.
Adds are O_APPEND, so two processes cannot lose each other's writes; removes are
read-modify-rename and assume a single operator. A file that does not parse, or
the same key under two names, still refuses startup rather than silently changing
who can authenticate.

What the directory could not provide on its own is an audit trail, so key changes
append a line to data/audit.log: timestamp, action, user, and key fingerprint.
That is the git log we deleted, at about 15 lines instead of a commit graph. If a
user must be blocked rather than removed, their keys are removed; there is no
disabled flag until something needs one.

Identity is now ~200 lines instead of 476, and the lost-update class of bug that
the CAS loop made possible is gone with the loop.

Verified by hand: fresh init shows data/ only holding audit.log, index.db, repos
and trusted_keys; a key appended to the file by hand starts working within the
poll interval without a restart; removing a key by CLI stops that key
authenticating; audit.log records both.

internal/server/integration_test.go +42−14

@@ -68,11 +68,11 @@ func newFixture(t *testing.T) *fixture {
68 68 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
69 69 t.Fatal(err)
70 70 }
71 if err := identity.Init(cfg.SystemRepo()); err != nil {
71 if err := identity.Init(cfg.TrustedKeysDir()); err != nil {
72 72 t.Fatal(err)
73 73 }
74 74 pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test"))
75 ident, err := identity.Open(cfg.SystemRepo())
75 ident, err := identity.Open(cfg.TrustedKeysDir())
76 76 if err != nil {
77 77 t.Fatal(err)
78 78 }
@@ -355,9 +355,10 @@ func TestRebuildIndexAfterDelete(t *testing.T) {
355 355 }
356 356 }
357 357
358 // TestIdentitySurvivesWithoutSideDatabase proves the roster really is git state:
359 // the whole auth store is two refs away from a fresh clone.
360 func TestIdentitySurvivesWithoutSideDatabase(t *testing.T) {
358 // TestRosterDirectoryIsTheWholeIdentity proves identity is nothing but a
359 // directory of public keys: copy it anywhere and the roster is complete, with no
360 // database and no side repository.
361 func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) {
361 362 f := newFixture(t)
362 363 f.publish(f.repo)
363 364 work := workRepo(t, map[string]string{"readme.md": "x\n"})
@@ -365,23 +366,50 @@ func TestIdentitySurvivesWithoutSideDatabase(t *testing.T) {
365 366 t.Fatalf("push: %s", errs)
366 367 }
367 368
368 // Clone the system repo to a fresh location and read the roster from it.
369 dst := filepath.Join(t.TempDir(), "sys.git")
370 if out, err := exec.Command("git", "clone", "-q", "--bare", f.cfg.SystemRepo(), dst).CombinedOutput(); err != nil {
371 t.Fatalf("clone system repo: %v: %s", err, out)
369 copied := filepath.Join(t.TempDir(), "trusted_keys")
370 if out, err := exec.Command("cp", "-r", f.cfg.TrustedKeysDir(), copied).CombinedOutput(); err != nil {
371 t.Fatalf("copy roster: %v: %s", err, out)
372 372 }
373 ident, err := identity.Open(dst)
373 ident, err := identity.Open(copied)
374 374 if err != nil {
375 375 t.Fatal(err)
376 376 }
377 377 if _, ok := ident.Snapshot().Users["keenan"]; !ok {
378 t.Fatal("cloned system repo has no users")
378 t.Fatal("copied roster has no users")
379 379 }
380 380 if len(ident.Snapshot().ByFingerprint) != 1 {
381 t.Fatalf("cloned roster has %d keys", len(ident.Snapshot().ByFingerprint))
381 t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByFingerprint))
382 382 }
383 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, "auth.db")); err == nil {
384 t.Fatal("an auth.db exists; identity is supposed to live in git only")
383
384 for _, absent := range []string{"auth.db", "system.git"} {
385 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, absent)); err == nil {
386 t.Fatalf("%s exists; identity is supposed to be a keys directory only", absent)
387 }
388 }
389 }
390
391 // TestRejectsKeyAddedOutsideTheCli is the property a plain directory trades away:
392 // a hand-edited key is accepted, because there is no signed history to compare
393 // against. What we keep is that the change is unambiguous and reloadable.
394 func TestRosterIsReloadable(t *testing.T) {
395 f := newFixture(t)
396 // A key dropped in by hand is picked up on reload.
397 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_second"))
398 if err := f.srv.Identity.Reload(); err != nil {
399 t.Fatal(err)
400 }
401 if _, err := f.srv.Identity.AddKey("keenan", pub, "test"); err != nil {
402 t.Fatal(err)
403 }
404 if got := len(f.srv.Identity.Snapshot().ByFingerprint); got != 2 {
405 t.Fatalf("keys = %d, want 2", got)
406 }
407 audit, err := os.ReadFile(f.cfg.AuditLog())
408 if err != nil {
409 t.Fatal(err)
410 }
411 if !strings.Contains(string(audit), "identity.add-key") {
412 t.Fatalf("audit log missing the change:\n%s", audit)
385 413 }
386 414 }
387 415