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.
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/identity/identity_test.go +170−132
| @@ -12,18 +12,18 @@ import ( | |||
| 12 | 12 | "golang.org/x/crypto/ssh" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | func newStore(t *testing.T) (*Store, string) { | ||
| 15 | func newStore(t *testing.T) *Store { | ||
| 16 | 16 | t.Helper() | |
| 17 | dir := t.TempDir() | ||
| 18 | sys := filepath.Join(dir, "system.git") | ||
| 19 | if err := Init(sys); err != nil { | ||
| 17 | dir := filepath.Join(t.TempDir(), "trusted_keys") | ||
| 18 | if err := Init(dir); err != nil { | ||
| 20 | 19 | t.Fatal(err) | |
| 21 | 20 | } | |
| 22 | s, err := Open(sys) | ||
| 21 | s, err := Open(dir) | ||
| 23 | 22 | if err != nil { | |
| 24 | 23 | t.Fatal(err) | |
| 25 | 24 | } | |
| 26 | return s, sys | ||
| 25 | s.AuditPath = filepath.Join(filepath.Dir(dir), "audit.log") | ||
| 26 | return s | ||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | func pubLine(t *testing.T) (string, string) { | |
| @@ -39,11 +39,27 @@ func pubLine(t *testing.T) (string, string) { | |||
| 39 | 39 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k))), ssh.FingerprintSHA256(k) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | func TestEmptyRosterLoads(t *testing.T) { | ||
| 43 | s := newStore(t) | ||
| 44 | if got := len(s.Snapshot().Users); got != 0 { | ||
| 45 | t.Fatalf("users = %d, want 0", got) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | func TestMissingDirectoryIsAnError(t *testing.T) { | ||
| 50 | if _, err := Open(filepath.Join(t.TempDir(), "nope")); err == nil { | ||
| 51 | t.Fatal("Open succeeded on a missing directory") | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 42 | 55 | func TestCreateUserAndAddKey(t *testing.T) { | |
| 43 | s, _ := newStore(t) | ||
| 56 | s := newStore(t) | ||
| 44 | 57 | if err := s.Create("keenan"); err != nil { | |
| 45 | 58 | t.Fatal(err) | |
| 46 | 59 | } | |
| 60 | if _, ok := s.Snapshot().Users["keenan"]; !ok { | ||
| 61 | t.Fatal("user missing after create") | ||
| 62 | } | ||
| 47 | 63 | line, wantFP := pubLine(t) | |
| 48 | 64 | fp, err := s.AddKey("keenan", line, "test") | |
| 49 | 65 | if err != nil { | |
| @@ -52,17 +68,53 @@ func TestCreateUserAndAddKey(t *testing.T) { | |||
| 52 | 68 | if fp != wantFP { | |
| 53 | 69 | t.Fatalf("fingerprint = %s, want %s", fp, wantFP) | |
| 54 | 70 | } | |
| 55 | name, ok := s.Lookup(fp) | ||
| 56 | if !ok || name != "keenan" { | ||
| 57 | t.Fatalf("Lookup(%s) = %q,%v", fp, name, ok) | ||
| 71 | if name, ok := s.Lookup(fp); !ok || name != "keenan" { | ||
| 72 | t.Fatalf("Lookup = %q,%v", name, ok) | ||
| 73 | } | ||
| 74 | data, err := os.ReadFile(filepath.Join(s.Dir, "keenan.keys")) | ||
| 75 | if err != nil { | ||
| 76 | t.Fatal(err) | ||
| 77 | } | ||
| 78 | if !strings.Contains(string(data), line) { | ||
| 79 | t.Fatalf("key not written verbatim:\n%s", data) | ||
| 80 | } | ||
| 81 | if !strings.HasPrefix(string(data), "#") { | ||
| 82 | t.Fatal("file has no explanatory header") | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | func TestMultipleKeysPerUser(t *testing.T) { | ||
| 87 | s := newStore(t) | ||
| 88 | if err := s.Create("keenan"); err != nil { | ||
| 89 | t.Fatal(err) | ||
| 90 | } | ||
| 91 | var fps []string | ||
| 92 | for i := 0; i < 3; i++ { | ||
| 93 | line, fp := pubLine(t) | ||
| 94 | if _, err := s.AddKey("keenan", line, "test"); err != nil { | ||
| 95 | t.Fatal(err) | ||
| 96 | } | ||
| 97 | fps = append(fps, fp) | ||
| 98 | } | ||
| 99 | if got := len(s.Snapshot().Users["keenan"].Keys); got != 3 { | ||
| 100 | t.Fatalf("keys = %d, want 3", got) | ||
| 58 | 101 | } | |
| 59 | if got := len(s.Snapshot().Users["keenan"].Keys); got != 1 { | ||
| 60 | t.Fatalf("key count = %d", got) | ||
| 102 | reopened, err := Open(s.Dir) | ||
| 103 | if err != nil { | ||
| 104 | t.Fatal(err) | ||
| 105 | } | ||
| 106 | if got := len(reopened.Snapshot().Users["keenan"].Keys); got != 3 { | ||
| 107 | t.Fatalf("keys after reopen = %d, want 3", got) | ||
| 108 | } | ||
| 109 | for _, fp := range fps { | ||
| 110 | if _, ok := reopened.Lookup(fp); !ok { | ||
| 111 | t.Fatalf("key %s lost across reopen", fp) | ||
| 112 | } | ||
| 61 | 113 | } | |
| 62 | 114 | } | |
| 63 | 115 | ||
| 64 | 116 | func TestDuplicateKeyAcrossUsersRejected(t *testing.T) { | |
| 65 | s, _ := newStore(t) | ||
| 117 | s := newStore(t) | ||
| 66 | 118 | for _, n := range []string{"alice", "bob"} { | |
| 67 | 119 | if err := s.Create(n); err != nil { | |
| 68 | 120 | t.Fatal(err) | |
| @@ -73,60 +125,119 @@ func TestDuplicateKeyAcrossUsersRejected(t *testing.T) { | |||
| 73 | 125 | t.Fatal(err) | |
| 74 | 126 | } | |
| 75 | 127 | if _, err := s.AddKey("bob", line, "test"); err == nil { | |
| 76 | t.Fatal("the same key was accepted for a second user") | ||
| 128 | t.Fatal("same key accepted for a second user") | ||
| 77 | 129 | } else if !strings.Contains(err.Error(), "already registered") { | |
| 78 | 130 | t.Fatalf("unclear error: %v", err) | |
| 79 | 131 | } | |
| 80 | 132 | } | |
| 81 | 133 | ||
| 82 | func TestRemoveKeyRevokesAccess(t *testing.T) { | ||
| 83 | s, _ := newStore(t) | ||
| 84 | if err := s.Create("keenan"); err != nil { | ||
| 134 | // TestDuplicateKeyOnDiskFailsStartup covers a hand-edited directory: two files | ||
| 135 | // claiming the same key is ambiguous, so the roster must refuse to load. | ||
| 136 | func TestDuplicateKeyOnDiskFailsStartup(t *testing.T) { | ||
| 137 | s := newStore(t) | ||
| 138 | for _, n := range []string{"alice", "bob"} { | ||
| 139 | if err := s.Create(n); err != nil { | ||
| 140 | t.Fatal(err) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | line, _ := pubLine(t) | ||
| 144 | if _, err := s.AddKey("alice", line, "test"); err != nil { | ||
| 85 | 145 | t.Fatal(err) | |
| 86 | 146 | } | |
| 87 | line, fp := pubLine(t) | ||
| 88 | if _, err := s.AddKey("keenan", line, "test"); err != nil { | ||
| 147 | // Hand-copy the same key into bob's file, bypassing AddKey. | ||
| 148 | if err := os.WriteFile(filepath.Join(s.Dir, "bob.keys"), []byte(line+"\n"), 0o644); err != nil { | ||
| 89 | 149 | t.Fatal(err) | |
| 90 | 150 | } | |
| 91 | if err := s.RemoveKey("keenan", fp, "test"); err != nil { | ||
| 151 | if _, err := Open(s.Dir); err == nil { | ||
| 152 | t.Fatal("ambiguous roster loaded; it must refuse to start") | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | func TestRemoveKey(t *testing.T) { | ||
| 157 | s := newStore(t) | ||
| 158 | if err := s.Create("keenan"); err != nil { | ||
| 92 | 159 | t.Fatal(err) | |
| 93 | 160 | } | |
| 94 | if _, ok := s.Lookup(fp); ok { | ||
| 161 | keepLine, keepFP := pubLine(t) | ||
| 162 | dropLine, dropFP := pubLine(t) | ||
| 163 | for _, l := range []string{keepLine, dropLine} { | ||
| 164 | if _, err := s.AddKey("keenan", l, "test"); err != nil { | ||
| 165 | t.Fatal(err) | ||
| 166 | } | ||
| 167 | } | ||
| 168 | if err := s.RemoveKey("keenan", dropFP, "test"); err != nil { | ||
| 169 | t.Fatal(err) | ||
| 170 | } | ||
| 171 | if _, ok := s.Lookup(dropFP); ok { | ||
| 95 | 172 | t.Fatal("revoked key still authenticates") | |
| 96 | 173 | } | |
| 174 | if _, ok := s.Lookup(keepFP); !ok { | ||
| 175 | t.Fatal("removing one key took the other with it") | ||
| 176 | } | ||
| 177 | data, err := os.ReadFile(filepath.Join(s.Dir, "keenan.keys")) | ||
| 178 | if err != nil { | ||
| 179 | t.Fatal(err) | ||
| 180 | } | ||
| 181 | if strings.Contains(string(data), dropLine) { | ||
| 182 | t.Fatal("revoked key still on disk") | ||
| 183 | } | ||
| 184 | if !strings.Contains(string(data), keepLine) { | ||
| 185 | t.Fatal("kept key vanished from disk") | ||
| 186 | } | ||
| 97 | 187 | } | |
| 98 | 188 | ||
| 99 | func TestTreeLayoutIsTheSchema(t *testing.T) { | ||
| 100 | s, _ := newStore(t) | ||
| 189 | func TestUnparsableLineFailsStartup(t *testing.T) { | ||
| 190 | s := newStore(t) | ||
| 101 | 191 | if err := s.Create("keenan"); err != nil { | |
| 102 | 192 | t.Fatal(err) | |
| 103 | 193 | } | |
| 104 | line, _ := pubLine(t) | ||
| 105 | if _, err := s.AddKey("keenan", line, "test"); err != nil { | ||
| 194 | if err := os.WriteFile(filepath.Join(s.Dir, "keenan.keys"), []byte("this is not a key\n"), 0o644); err != nil { | ||
| 195 | t.Fatal(err) | ||
| 196 | } | ||
| 197 | if _, err := Open(s.Dir); err == nil { | ||
| 198 | t.Fatal("roster with an unparsable key loaded; it must refuse to start") | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | func TestNotesAndBlanksIgnored(t *testing.T) { | ||
| 203 | s := newStore(t) | ||
| 204 | line, fp := pubLine(t) | ||
| 205 | content := "# a note\n\n \n" + line + "\n# trailing note\n" | ||
| 206 | if err := os.WriteFile(filepath.Join(s.Dir, "keenan.keys"), []byte(content), 0o644); err != nil { | ||
| 106 | 207 | t.Fatal(err) | |
| 107 | 208 | } | |
| 108 | entries, err := s.Git.LsTreeR(UsersRef) | ||
| 209 | re, err := Open(s.Dir) | ||
| 109 | 210 | if err != nil { | |
| 110 | 211 | t.Fatal(err) | |
| 111 | 212 | } | |
| 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 | ||
| 213 | if _, ok := re.Lookup(fp); !ok { | ||
| 214 | t.Fatal("key on a commented file was not loaded") | ||
| 215 | } | ||
| 216 | if got := len(re.Snapshot().Users["keenan"].Keys); got != 1 { | ||
| 217 | t.Fatalf("keys = %d, want 1", got) | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | func TestInvalidNamesRefused(t *testing.T) { | ||
| 222 | s := newStore(t) | ||
| 223 | for _, name := range []string{"Keenan", "has space", "a", "with/slash", "trailing."} { | ||
| 224 | if err := s.Create(name); err == nil { | ||
| 225 | t.Fatalf("%q accepted as a username", name) | ||
| 119 | 226 | } | |
| 120 | 227 | } | |
| 121 | if !sawMeta || !sawKey { | ||
| 122 | t.Fatalf("unexpected tree: %+v", entries) | ||
| 228 | // A file named with an invalid user must also fail to load. | ||
| 229 | if err := os.WriteFile(filepath.Join(s.Dir, "Bad Name.keys"), []byte("x\n"), 0o644); err != nil { | ||
| 230 | t.Fatal(err) | ||
| 231 | } | ||
| 232 | if _, err := Open(s.Dir); err == nil { | ||
| 233 | t.Fatal("invalid filename in the roster directory was accepted") | ||
| 123 | 234 | } | |
| 124 | 235 | } | |
| 125 | 236 | ||
| 126 | // TestConcurrentAddsAllLand is the CAS guarantee: two stores editing the roster | ||
| 127 | // at once must not lose each other's writes. | ||
| 237 | // TestConcurrentAddsAllLand relies on append semantics: no read-modify-write, so | ||
| 238 | // there is no lost-update window. | ||
| 128 | 239 | func TestConcurrentAddsAllLand(t *testing.T) { | |
| 129 | s, sys := newStore(t) | ||
| 240 | s := newStore(t) | ||
| 130 | 241 | if err := s.Create("keenan"); err != nil { | |
| 131 | 242 | t.Fatal(err) | |
| 132 | 243 | } | |
| @@ -135,22 +246,13 @@ func TestConcurrentAddsAllLand(t *testing.T) { | |||
| 135 | 246 | for i := range lines { | |
| 136 | 247 | lines[i], _ = pubLine(t) | |
| 137 | 248 | } | |
| 138 | |||
| 139 | other, err := Open(sys) | ||
| 140 | if err != nil { | ||
| 141 | t.Fatal(err) | ||
| 142 | } | ||
| 143 | 249 | var wg sync.WaitGroup | |
| 144 | 250 | errs := make([]error, n) | |
| 145 | 251 | for i := 0; i < n; i++ { | |
| 146 | 252 | wg.Add(1) | |
| 147 | 253 | go func(i int) { | |
| 148 | 254 | defer wg.Done() | |
| 149 | store := s | ||
| 150 | if i%2 == 1 { | ||
| 151 | store = other | ||
| 152 | } | ||
| 153 | _, errs[i] = store.AddKey("keenan", lines[i], "test") | ||
| 255 | _, errs[i] = s.AddKey("keenan", lines[i], "test") | ||
| 154 | 256 | }(i) | |
| 155 | 257 | } | |
| 156 | 258 | wg.Wait() | |
| @@ -159,114 +261,50 @@ func TestConcurrentAddsAllLand(t *testing.T) { | |||
| 159 | 261 | t.Fatalf("add %d: %v", i, err) | |
| 160 | 262 | } | |
| 161 | 263 | } | |
| 162 | final, err := Open(sys) | ||
| 264 | final, err := Open(s.Dir) | ||
| 163 | 265 | if err != nil { | |
| 164 | 266 | t.Fatal(err) | |
| 165 | 267 | } | |
| 166 | 268 | 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) | ||
| 269 | t.Fatalf("after concurrent adds: %d keys, want %d", got, n) | ||
| 168 | 270 | } | |
| 169 | 271 | } | |
| 170 | 272 | ||
| 171 | func TestReloadSeesExternalWrites(t *testing.T) { | ||
| 172 | s, sys := newStore(t) | ||
| 273 | func TestAuditLogRecordsChanges(t *testing.T) { | ||
| 274 | s := newStore(t) | ||
| 173 | 275 | if err := s.Create("keenan"); err != nil { | |
| 174 | 276 | t.Fatal(err) | |
| 175 | 277 | } | |
| 176 | 278 | 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 { | ||
| 279 | if _, err := s.AddKey("keenan", line, "operator"); err != nil { | ||
| 189 | 280 | t.Fatal(err) | |
| 190 | 281 | } | |
| 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 { | ||
| 282 | if err := s.RemoveKey("keenan", fp, "operator"); err != nil { | ||
| 203 | 283 | t.Fatal(err) | |
| 204 | 284 | } | |
| 205 | old, err := s.Git.RevParseRaw(UsersRef) | ||
| 285 | data, err := os.ReadFile(s.AuditPath) | ||
| 206 | 286 | if err != nil { | |
| 207 | 287 | t.Fatal(err) | |
| 208 | 288 | } | |
| 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) | ||
| 289 | log := string(data) | ||
| 290 | for _, want := range []string{"identity.create", "keenan", "identity.add-key", "identity.remove-key", fp} { | ||
| 291 | if !strings.Contains(log, want) { | ||
| 292 | t.Fatalf("audit log missing %q:\n%s", want, log) | ||
| 293 | } | ||
| 237 | 294 | } | |
| 238 | if err := s.Git.UpdateRef(UsersRef, blob); err == nil { | ||
| 239 | t.Fatal("git allowed a branch ref to point at a blob") | ||
| 295 | if got := strings.Count(log, "\n"); got != 3 { | ||
| 296 | t.Fatalf("audit lines = %d, want 3:\n%s", got, log) | ||
| 240 | 297 | } | |
| 241 | 298 | } | |
| 242 | 299 | ||
| 243 | func TestDisabledUserCannotAuthenticate(t *testing.T) { | ||
| 244 | s, sys := newStore(t) | ||
| 300 | func TestAuditFailureDoesNotBlockChanges(t *testing.T) { | ||
| 301 | s := newStore(t) | ||
| 302 | s.AuditPath = filepath.Join(t.TempDir(), "missing-dir", "audit.log") | ||
| 245 | 303 | if err := s.Create("keenan"); err != nil { | |
| 246 | t.Fatal(err) | ||
| 304 | t.Fatalf("create failed because the audit log was unwritable: %v", err) | ||
| 247 | 305 | } | |
| 248 | line, fp := pubLine(t) | ||
| 306 | line, _ := pubLine(t) | ||
| 249 | 307 | 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) | ||
| 308 | t.Fatalf("add-key failed because the audit log was unwritable: %v", err) | ||
| 271 | 309 | } | |
| 272 | 310 | } | |