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.go +150−312
| @@ -1,24 +1,19 @@ | |||
| 1 | // Package identity keeps the instance's user roster in git, inside a dedicated | ||
| 2 | // system repository. | ||
| 1 | // Package identity is the instance's user roster: one file of SSH public keys | ||
| 2 | // per user, in a trusted-keys directory. | ||
| 3 | 3 | // | |
| 4 | // The directory listing is the table: users/<name>/keys/<hex>.pub. Everything | ||
| 5 | // stored here is public material, so a copy of the system repo is a complete | ||
| 6 | // backup that leaks nothing. Writes are compare-and-swap commits; reads come | ||
| 7 | // from an in-memory snapshot refreshed when the ref moves, because the SSH auth | ||
| 8 | // callback is on the hot path and must be a map lookup, not a git invocation. | ||
| 4 | // data/trusted_keys/keenan.keys # one authorized_keys line per device | ||
| 9 | 5 | // | |
| 10 | // The roster lives on refs/heads/main on purpose. A custom namespace such as | ||
| 11 | // refs/vole/users is invisible to a plain `git clone`, which only mirrors | ||
| 12 | // refs/heads/*, so the obvious backup command would have silently produced an | ||
| 13 | // instance with no users. The system repo is never pushed to by clients, so it | ||
| 14 | // gains nothing from a reserved namespace. | ||
| 6 | // This is the same shape sshd uses for AuthorizedKeysFile, so the tools people | ||
| 7 | // already reach for work here: ssh-keygen -lf, ansible, rsync, an editor. Nothing | ||
| 8 | // stored is secret, which is what lets the directory be copied or put under | ||
| 9 | // configuration management without leaking anything. | ||
| 10 | // | ||
| 11 | // Adds are appends, so two processes cannot lose each other's writes. Removes are | ||
| 12 | // read-modify-rename and assume a single operator, which is the deployment this | ||
| 13 | // is built for. | ||
| 15 | 14 | package identity | |
| 16 | 15 | ||
| 17 | 16 | import ( | |
| 18 | "crypto/rand" | ||
| 19 | "crypto/sha256" | ||
| 20 | "encoding/hex" | ||
| 21 | "encoding/json" | ||
| 22 | 17 | "fmt" | |
| 23 | 18 | "os" | |
| 24 | 19 | "path/filepath" | |
| @@ -29,40 +24,30 @@ import ( | |||
| 29 | 24 | "time" | |
| 30 | 25 | ||
| 31 | 26 | "golang.org/x/crypto/ssh" | |
| 32 | |||
| 33 | "github.com/rodent-software/vole/internal/gitx" | ||
| 34 | 27 | ) | |
| 35 | 28 | ||
| 36 | 29 | const ( | |
| 37 | UsersRef = "refs/heads/main" | ||
| 38 | usersDir = "users" | ||
| 39 | metaFile = "meta.json" | ||
| 40 | keysDir = "keys" | ||
| 41 | maxCAS = 64 | ||
| 42 | DirName = "system.git" | ||
| 30 | keysSuffix = ".keys" | ||
| 31 | header = "# vole: SSH public keys for %s, one per device (authorized_keys format).\n" | ||
| 43 | 32 | ) | |
| 44 | 33 | ||
| 45 | 34 | type User struct { | |
| 46 | Name string | ||
| 47 | ID string | ||
| 48 | Created time.Time | ||
| 49 | Disabled bool | ||
| 50 | Keys []Key | ||
| 35 | Name string | ||
| 36 | Keys []Key | ||
| 51 | 37 | } | |
| 52 | 38 | ||
| 53 | 39 | type Key struct { | |
| 54 | Fingerprint string // ssh.FingerprintSHA256 of the key | ||
| 40 | Fingerprint string | ||
| 55 | 41 | Type string | |
| 56 | 42 | Comment string | |
| 57 | Line string // authorized_keys-format line | ||
| 58 | Added time.Time | ||
| 43 | Line string | ||
| 59 | 44 | } | |
| 60 | 45 | ||
| 61 | // Snapshot is an immutable view of the roster, swapped atomically. | ||
| 46 | // Snapshot is an immutable view of the roster, swapped atomically so the SSH | ||
| 47 | // auth callback is a map lookup rather than a file read. | ||
| 62 | 48 | type Snapshot struct { | |
| 63 | 49 | Users map[string]*User | |
| 64 | 50 | ByFingerprint map[string]string | |
| 65 | SHA string | ||
| 66 | 51 | } | |
| 67 | 52 | ||
| 68 | 53 | func (s *Snapshot) Lookup(fingerprint string) (string, bool) { | |
| @@ -86,149 +71,85 @@ func (s *Snapshot) Names() []string { | |||
| 86 | 71 | } | |
| 87 | 72 | ||
| 88 | 73 | type Store struct { | |
| 89 | Dir string | ||
| 90 | Git *gitx.Repo | ||
| 91 | snap atomic.Pointer[Snapshot] | ||
| 92 | mu sync.Mutex // serialises local writers | ||
| 74 | Dir string | ||
| 75 | // AuditPath, when set, receives one line per key change: the audit trail a | ||
| 76 | // git-backed roster used to provide for free. | ||
| 77 | AuditPath string | ||
| 78 | snap atomic.Pointer[Snapshot] | ||
| 79 | mu sync.Mutex | ||
| 93 | 80 | } | |
| 94 | 81 | ||
| 95 | // Init creates the system repo with an empty roster. | ||
| 96 | func Init(dir string) error { | ||
| 97 | if _, err := os.Stat(dir); err == nil { | ||
| 98 | return nil | ||
| 99 | } | ||
| 100 | if err := gitx.InitBare(dir, "main"); err != nil { | ||
| 101 | return err | ||
| 102 | } | ||
| 103 | s, err := Open(dir) | ||
| 104 | if err != nil { | ||
| 105 | return err | ||
| 106 | } | ||
| 107 | return s.writeTree(map[string][]byte{ | ||
| 108 | usersDir + "/.keep": []byte("the users tree: one directory per user\n"), | ||
| 109 | }, "users: initialise empty roster", "vole") | ||
| 110 | } | ||
| 82 | // Init creates the trusted-keys directory if it is missing. | ||
| 83 | func Init(dir string) error { return os.MkdirAll(dir, 0o755) } | ||
| 111 | 84 | ||
| 112 | // Open loads an existing system repo. A missing or unreadable roster is a hard | ||
| 113 | // error: booting with an empty roster would silently mean "nobody can | ||
| 114 | // authenticate", and the diagnosis would happen at an inconvenient hour. | ||
| 85 | // Open loads the roster. An unreadable or unparsable file is a hard error: | ||
| 86 | // booting with a partial roster would silently change who can authenticate. | ||
| 115 | 87 | func Open(dir string) (*Store, error) { | |
| 116 | g := gitx.Open(dir) | ||
| 117 | if !g.Exists() { | ||
| 88 | st, err := os.Stat(dir) | ||
| 89 | if err != nil || !st.IsDir() { | ||
| 118 | 90 | return nil, fmt.Errorf("identity: %s is missing; run vole init", dir) | |
| 119 | 91 | } | |
| 120 | s := &Store{Dir: dir, Git: g} | ||
| 92 | s := &Store{Dir: dir} | ||
| 121 | 93 | if err := s.Reload(); err != nil { | |
| 122 | 94 | return nil, err | |
| 123 | 95 | } | |
| 124 | 96 | return s, nil | |
| 125 | 97 | } | |
| 126 | 98 | ||
| 127 | // Reload re-reads the roster from the ref. | ||
| 128 | 99 | func (s *Store) Reload() error { | |
| 129 | sha, err := s.Git.RevParseRaw(UsersRef) | ||
| 100 | entries, err := os.ReadDir(s.Dir) | ||
| 130 | 101 | if err != nil { | |
| 131 | 102 | return err | |
| 132 | 103 | } | |
| 133 | snap := &Snapshot{Users: map[string]*User{}, ByFingerprint: map[string]string{}, SHA: sha} | ||
| 134 | if sha == "" { | ||
| 135 | s.snap.Store(snap) | ||
| 136 | return nil | ||
| 137 | } | ||
| 138 | // A ref pointing at anything but a commit means the roster is corrupt. Left | ||
| 139 | // unchecked, `ls-tree` on a blob prints nothing and the instance would boot | ||
| 140 | // with an empty roster — meaning nobody can authenticate. | ||
| 141 | typ, err := s.Git.ObjectType(sha) | ||
| 142 | if err != nil { | ||
| 143 | return fmt.Errorf("identity: read %s: %w", UsersRef, err) | ||
| 144 | } | ||
| 145 | if typ != "commit" { | ||
| 146 | return fmt.Errorf("identity: %s points at a %s, want a commit", UsersRef, typ) | ||
| 147 | } | ||
| 148 | entries, err := s.Git.LsTreeR(sha) | ||
| 149 | if err != nil { | ||
| 150 | return fmt.Errorf("identity: read roster: %w", err) | ||
| 151 | } | ||
| 104 | snap := &Snapshot{Users: map[string]*User{}, ByFingerprint: map[string]string{}} | ||
| 152 | 105 | for _, e := range entries { | |
| 153 | if e.Type != "blob" || !strings.HasSuffix(e.Name, ".pub") { | ||
| 106 | if e.IsDir() || !strings.HasSuffix(e.Name(), keysSuffix) { | ||
| 154 | 107 | continue | |
| 155 | 108 | } | |
| 156 | name := userNameFromPath(e.Name) | ||
| 157 | if name == "" { | ||
| 109 | name := strings.TrimSuffix(e.Name(), keysSuffix) | ||
| 110 | if strings.HasPrefix(e.Name(), ".") { | ||
| 158 | 111 | continue | |
| 159 | 112 | } | |
| 160 | data, err := s.Git.BlobBySHA(e.SHA) | ||
| 161 | if err != nil { | ||
| 162 | return fmt.Errorf("identity: read key %s: %w", e.Name, err) | ||
| 113 | if err := ValidateName(name); err != nil { | ||
| 114 | return fmt.Errorf("identity: file %q: %w", e.Name(), err) | ||
| 163 | 115 | } | |
| 164 | pub, comment, _, rest, err := ssh.ParseAuthorizedKey(data) | ||
| 165 | if err != nil { | ||
| 166 | return fmt.Errorf("identity: %s: unparsable key: %w", e.Name, err) | ||
| 167 | } | ||
| 168 | _ = rest | ||
| 169 | u := snap.Users[name] | ||
| 170 | if u == nil { | ||
| 171 | u = &User{Name: name} | ||
| 172 | snap.Users[name] = u | ||
| 173 | } | ||
| 174 | fp := ssh.FingerprintSHA256(pub) | ||
| 175 | u.Keys = append(u.Keys, Key{ | ||
| 176 | Fingerprint: fp, | ||
| 177 | Type: pub.Type(), | ||
| 178 | Comment: comment, | ||
| 179 | Line: strings.TrimSpace(string(data)), | ||
| 180 | }) | ||
| 181 | snap.ByFingerprint[fp] = name | ||
| 182 | } | ||
| 183 | // Metadata files. | ||
| 184 | for _, e := range entries { | ||
| 185 | if e.Type != "blob" || !strings.HasSuffix(e.Name, "/"+metaFile) { | ||
| 186 | continue | ||
| 187 | } | ||
| 188 | name := userNameFromPath(e.Name) | ||
| 189 | if name == "" { | ||
| 190 | continue | ||
| 191 | } | ||
| 192 | data, err := s.Git.BlobBySHA(e.SHA) | ||
| 116 | data, err := os.ReadFile(filepath.Join(s.Dir, e.Name())) | ||
| 193 | 117 | if err != nil { | |
| 194 | 118 | return err | |
| 195 | 119 | } | |
| 196 | var m struct { | ||
| 197 | ID string `json:"id"` | ||
| 198 | Created time.Time `json:"created"` | ||
| 199 | Disabled bool `json:"disabled"` | ||
| 200 | } | ||
| 201 | if err := json.Unmarshal(data, &m); err != nil { | ||
| 202 | return fmt.Errorf("identity: %s/%s: %w", name, metaFile, err) | ||
| 203 | } | ||
| 204 | u := snap.Users[name] | ||
| 205 | if u == nil { | ||
| 206 | u = &User{Name: name} | ||
| 207 | snap.Users[name] = u | ||
| 120 | u := &User{Name: name} | ||
| 121 | for i, line := range strings.Split(string(data), "\n") { | ||
| 122 | line = strings.TrimSpace(line) | ||
| 123 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 124 | continue | ||
| 125 | } | ||
| 126 | pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 127 | if err != nil { | ||
| 128 | return fmt.Errorf("identity: %s line %d: %w", e.Name(), i+1, err) | ||
| 129 | } | ||
| 130 | fp := ssh.FingerprintSHA256(pub) | ||
| 131 | if owner, ok := snap.ByFingerprint[fp]; ok { | ||
| 132 | return fmt.Errorf("identity: key %s appears under both %s and %s", fp, owner, name) | ||
| 133 | } | ||
| 134 | u.Keys = append(u.Keys, Key{ | ||
| 135 | Fingerprint: fp, | ||
| 136 | Type: pub.Type(), | ||
| 137 | Comment: comment, | ||
| 138 | Line: line, | ||
| 139 | }) | ||
| 140 | snap.ByFingerprint[fp] = name | ||
| 208 | 141 | } | |
| 209 | u.ID, u.Created, u.Disabled = m.ID, m.Created, m.Disabled | ||
| 210 | } | ||
| 211 | // Order keys for stable output. | ||
| 212 | for _, u := range snap.Users { | ||
| 213 | 142 | sort.Slice(u.Keys, func(i, j int) bool { return u.Keys[i].Fingerprint < u.Keys[j].Fingerprint }) | |
| 143 | snap.Users[name] = u | ||
| 214 | 144 | } | |
| 215 | 145 | s.snap.Store(snap) | |
| 216 | 146 | return nil | |
| 217 | 147 | } | |
| 218 | 148 | ||
| 219 | // Snapshot returns the current roster view. Cheap and lock-free. | ||
| 220 | 149 | func (s *Store) Snapshot() *Snapshot { return s.snap.Load() } | |
| 221 | 150 | ||
| 222 | // Lookup resolves a key fingerprint to a username. | ||
| 223 | 151 | func (s *Store) Lookup(fingerprint string) (string, bool) { | |
| 224 | name, ok := s.Snapshot().Lookup(fingerprint) | ||
| 225 | if !ok { | ||
| 226 | return "", false | ||
| 227 | } | ||
| 228 | if u, ok := s.Snapshot().Users[name]; ok && u.Disabled { | ||
| 229 | return "", false | ||
| 230 | } | ||
| 231 | return name, true | ||
| 152 | return s.Snapshot().Lookup(fingerprint) | ||
| 232 | 153 | } | |
| 233 | 154 | ||
| 234 | 155 | func ValidateName(name string) error { | |
| @@ -244,8 +165,9 @@ func ValidateName(name string) error { | |||
| 244 | 165 | return nil | |
| 245 | 166 | } | |
| 246 | 167 | ||
| 247 | // Create adds a user with no keys. They cannot authenticate until a key is | ||
| 248 | // added, which is the intended order of operations. | ||
| 168 | func (s *Store) path(name string) string { return filepath.Join(s.Dir, name+keysSuffix) } | ||
| 169 | |||
| 170 | // Create adds an empty key file: a user who exists but cannot authenticate yet. | ||
| 249 | 171 | func (s *Store) Create(name string) error { | |
| 250 | 172 | if err := ValidateName(name); err != nil { | |
| 251 | 173 | return err | |
| @@ -253,27 +175,26 @@ func (s *Store) Create(name string) error { | |||
| 253 | 175 | if _, ok := s.Snapshot().Users[name]; ok { | |
| 254 | 176 | return fmt.Errorf("identity: user %q already exists", name) | |
| 255 | 177 | } | |
| 256 | id, err := randomID() | ||
| 178 | f, err := os.OpenFile(s.path(name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | ||
| 257 | 179 | if err != nil { | |
| 180 | if os.IsExist(err) { | ||
| 181 | return fmt.Errorf("identity: user %q already exists", name) | ||
| 182 | } | ||
| 258 | 183 | return err | |
| 259 | 184 | } | |
| 260 | meta, err := json.MarshalIndent(struct { | ||
| 261 | ID string `json:"id"` | ||
| 262 | Created time.Time `json:"created"` | ||
| 263 | Disabled bool `json:"disabled"` | ||
| 264 | }{id, time.Now().UTC(), false}, "", " ") | ||
| 265 | if err != nil { | ||
| 185 | defer f.Close() | ||
| 186 | if _, err := fmt.Fprintf(f, header, name); err != nil { | ||
| 266 | 187 | return err | |
| 267 | 188 | } | |
| 268 | return s.apply(func(files map[string][]byte) error { | ||
| 269 | files[fmt.Sprintf("%s/%s/%s", usersDir, name, metaFile)] = append(meta, '\n') | ||
| 270 | return nil | ||
| 271 | }, fmt.Sprintf("users: create %s", name), "vole") | ||
| 189 | s.audit("identity.create", name, "") | ||
| 190 | return s.Reload() | ||
| 272 | 191 | } | |
| 273 | 192 | ||
| 274 | 193 | // AddKey registers an SSH public key for a user and returns its fingerprint. | |
| 275 | func (s *Store) AddKey(name, authorizedKey, addedBy string) (string, error) { | ||
| 276 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(authorizedKey))) | ||
| 194 | // The write is an append, so it cannot lose a concurrent add. | ||
| 195 | func (s *Store) AddKey(name, authorizedKey, actor string) (string, error) { | ||
| 196 | line := strings.TrimSpace(authorizedKey) | ||
| 197 | pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 277 | 198 | if err != nil { | |
| 278 | 199 | return "", fmt.Errorf("identity: not an SSH public key: %w", err) | |
| 279 | 200 | } | |
| @@ -284,193 +205,110 @@ func (s *Store) AddKey(name, authorizedKey, addedBy string) (string, error) { | |||
| 284 | 205 | if _, ok := s.Snapshot().Users[name]; !ok { | |
| 285 | 206 | return "", fmt.Errorf("identity: no such user %q", name) | |
| 286 | 207 | } | |
| 287 | line := strings.TrimSpace(authorizedKey) + "\n" | ||
| 288 | path := fmt.Sprintf("%s/%s/%s/%s.pub", usersDir, name, keysDir, keyFileName(pub)) | ||
| 289 | err = s.apply(func(files map[string][]byte) error { | ||
| 290 | files[path] = []byte(line) | ||
| 291 | return nil | ||
| 292 | }, fmt.Sprintf("users: add key for %s (%s)", name, fp), addedBy) | ||
| 208 | |||
| 209 | f, err := os.OpenFile(s.path(name), os.O_APPEND|os.O_WRONLY, 0o644) | ||
| 293 | 210 | if err != nil { | |
| 294 | 211 | return "", err | |
| 295 | 212 | } | |
| 213 | if _, err := fmt.Fprintf(f, "%s\n", line); err != nil { | ||
| 214 | f.Close() | ||
| 215 | return "", err | ||
| 216 | } | ||
| 217 | if err := f.Close(); err != nil { | ||
| 218 | return "", err | ||
| 219 | } | ||
| 220 | s.audit("identity.add-key", name, fp+" "+comment) | ||
| 221 | if err := s.Reload(); err != nil { | ||
| 222 | return "", err | ||
| 223 | } | ||
| 296 | 224 | return fp, nil | |
| 297 | 225 | } | |
| 298 | 226 | ||
| 299 | // RemoveKey revokes a key. Recovery is an operator adding a new key, which is | ||
| 300 | // why there is no reset flow to build. | ||
| 301 | func (s *Store) RemoveKey(name, fingerprint, removedBy string) error { | ||
| 227 | // RemoveKey revokes a key by rewriting the file without it. Read-modify-rename, | ||
| 228 | // so it assumes a single operator process. | ||
| 229 | func (s *Store) RemoveKey(name, fingerprint, actor string) error { | ||
| 230 | s.mu.Lock() | ||
| 231 | defer s.mu.Unlock() | ||
| 232 | |||
| 302 | 233 | u, ok := s.Snapshot().Users[name] | |
| 303 | 234 | if !ok { | |
| 304 | 235 | return fmt.Errorf("identity: no such user %q", name) | |
| 305 | 236 | } | |
| 306 | var target *Key | ||
| 307 | for i := range u.Keys { | ||
| 308 | if u.Keys[i].Fingerprint == fingerprint { | ||
| 309 | target = &u.Keys[i] | ||
| 237 | found := false | ||
| 238 | for _, k := range u.Keys { | ||
| 239 | if k.Fingerprint == fingerprint { | ||
| 240 | found = true | ||
| 310 | 241 | break | |
| 311 | 242 | } | |
| 312 | 243 | } | |
| 313 | if target == nil { | ||
| 244 | if !found { | ||
| 314 | 245 | return fmt.Errorf("identity: %s has no key %s", name, fingerprint) | |
| 315 | 246 | } | |
| 316 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(target.Line)) | ||
| 317 | if err != nil { | ||
| 318 | return err | ||
| 319 | } | ||
| 320 | path := fmt.Sprintf("%s/%s/%s/%s.pub", usersDir, name, keysDir, keyFileName(pub)) | ||
| 321 | return s.apply(func(files map[string][]byte) error { | ||
| 322 | if _, ok := files[path]; !ok { | ||
| 323 | return fmt.Errorf("identity: key file vanished: %s", path) | ||
| 324 | } | ||
| 325 | delete(files, path) | ||
| 326 | return nil | ||
| 327 | }, fmt.Sprintf("users: revoke key for %s (%s)", name, fingerprint), removedBy) | ||
| 328 | } | ||
| 329 | 247 | ||
| 330 | // apply is the single write path: read the tree, mutate a flat path map, write | ||
| 331 | // it back with a compare-and-swap ref update, then refresh the snapshot. | ||
| 332 | func (s *Store) apply(mutate func(map[string][]byte) error, message, actor string) error { | ||
| 333 | s.mu.Lock() | ||
| 334 | defer s.mu.Unlock() | ||
| 335 | |||
| 336 | // The read, the mutation and the compare must all see the same ref value. | ||
| 337 | // Reading the tree once outside this loop and then CAS-ing against a fresh | ||
| 338 | // old value silently discards a concurrent writer's commit, because the CAS | ||
| 339 | // succeeds while the tree content is stale. | ||
| 340 | var lastErr error | ||
| 341 | for attempt := 0; attempt < maxCAS; attempt++ { | ||
| 342 | old, err := s.Git.RevParseRaw(UsersRef) | ||
| 343 | if err != nil { | ||
| 344 | return err | ||
| 345 | } | ||
| 346 | files, err := s.readFlat(old) | ||
| 347 | if err != nil { | ||
| 348 | return err | ||
| 349 | } | ||
| 350 | if err := mutate(files); err != nil { | ||
| 351 | return err | ||
| 352 | } | ||
| 353 | tree, err := buildTree(s.Git, files) | ||
| 354 | if err != nil { | ||
| 355 | return err | ||
| 356 | } | ||
| 357 | commit, err := s.Git.CommitTree(tree, old, withActor(message, actor)) | ||
| 358 | if err != nil { | ||
| 359 | return err | ||
| 360 | } | ||
| 361 | ok, err := s.Git.UpdateRefCAS(UsersRef, commit, old) | ||
| 362 | if err != nil { | ||
| 363 | return err | ||
| 364 | } | ||
| 365 | if ok { | ||
| 366 | return s.Reload() | ||
| 367 | } | ||
| 368 | lastErr = fmt.Errorf("identity: lost %d ref race(s)", attempt+1) | ||
| 369 | time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) | ||
| 370 | } | ||
| 371 | return lastErr | ||
| 372 | } | ||
| 373 | |||
| 374 | func (s *Store) readFlat(sha string) (map[string][]byte, error) { | ||
| 375 | out := map[string][]byte{} | ||
| 376 | if sha == "" { | ||
| 377 | return out, nil | ||
| 378 | } | ||
| 379 | if typ, err := s.Git.ObjectType(sha); err != nil { | ||
| 380 | return nil, err | ||
| 381 | } else if typ != "commit" { | ||
| 382 | return nil, fmt.Errorf("identity: %s points at a %s, want a commit", UsersRef, typ) | ||
| 383 | } | ||
| 384 | entries, err := s.Git.LsTreeR(sha) | ||
| 248 | p := s.path(name) | ||
| 249 | data, err := os.ReadFile(p) | ||
| 385 | 250 | if err != nil { | |
| 386 | return nil, err | ||
| 251 | return err | ||
| 387 | 252 | } | |
| 388 | for _, e := range entries { | ||
| 389 | if e.Type != "blob" { | ||
| 253 | var kept []string | ||
| 254 | for _, line := range strings.Split(string(data), "\n") { | ||
| 255 | trimmed := strings.TrimSpace(line) | ||
| 256 | switch { | ||
| 257 | case trimmed == "": | ||
| 390 | 258 | continue | |
| 259 | case strings.HasPrefix(trimmed, "#"): | ||
| 260 | kept = append(kept, line) | ||
| 261 | default: | ||
| 262 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(trimmed)) | ||
| 263 | if err != nil { | ||
| 264 | return fmt.Errorf("identity: %s: %w", p, err) | ||
| 265 | } | ||
| 266 | if ssh.FingerprintSHA256(pub) != fingerprint { | ||
| 267 | kept = append(kept, line) | ||
| 268 | } | ||
| 391 | 269 | } | |
| 392 | data, err := s.Git.BlobBySHA(e.SHA) | ||
| 393 | if err != nil { | ||
| 394 | return nil, err | ||
| 395 | } | ||
| 396 | out[e.Name] = data | ||
| 397 | 270 | } | |
| 398 | return out, nil | ||
| 271 | if err := writeAtomic(p, strings.Join(kept, "\n")+"\n"); err != nil { | ||
| 272 | return err | ||
| 273 | } | ||
| 274 | s.audit("identity.remove-key", name, fingerprint) | ||
| 275 | return s.Reload() | ||
| 399 | 276 | } | |
| 400 | 277 | ||
| 401 | // writeTree is the one-shot path used by Init, where the ref cannot exist yet. | ||
| 402 | func (s *Store) writeTree(files map[string][]byte, message, actor string) error { | ||
| 403 | tree, err := buildTree(s.Git, files) | ||
| 278 | // writeAtomic replaces path via a temp file in the same directory, so a reader | ||
| 279 | // never observes a half-written roster. | ||
| 280 | func writeAtomic(path, content string) error { | ||
| 281 | dir := filepath.Dir(path) | ||
| 282 | f, err := os.CreateTemp(dir, ".vole-keys-*") | ||
| 404 | 283 | if err != nil { | |
| 405 | 284 | return err | |
| 406 | 285 | } | |
| 407 | commit, err := s.Git.CommitTree(tree, "", withActor(message, actor)) | ||
| 408 | if err != nil { | ||
| 286 | tmp := f.Name() | ||
| 287 | defer os.Remove(tmp) | ||
| 288 | if _, err := f.WriteString(content); err != nil { | ||
| 289 | f.Close() | ||
| 409 | 290 | return err | |
| 410 | 291 | } | |
| 411 | _, err = s.Git.UpdateRefCAS(UsersRef, commit, "") | ||
| 412 | return err | ||
| 413 | } | ||
| 414 | |||
| 415 | func withActor(message, actor string) string { | ||
| 416 | if strings.TrimSpace(actor) == "" { | ||
| 417 | actor = "unknown" | ||
| 418 | } | ||
| 419 | return message + "\n\nBy: " + actor | ||
| 420 | } | ||
| 421 | |||
| 422 | // buildTree writes a nested tree from flat paths. | ||
| 423 | func buildTree(g *gitx.Repo, files map[string][]byte) (string, error) { | ||
| 424 | var entries []gitx.Entry | ||
| 425 | dirs := map[string]map[string][]byte{} | ||
| 426 | for path, content := range files { | ||
| 427 | parts := strings.SplitN(path, "/", 2) | ||
| 428 | if len(parts) == 1 { | ||
| 429 | blob, err := g.HashObject(content) | ||
| 430 | if err != nil { | ||
| 431 | return "", err | ||
| 432 | } | ||
| 433 | entries = append(entries, gitx.Entry{Mode: "100644", Type: "blob", SHA: blob, Name: parts[0]}) | ||
| 434 | continue | ||
| 435 | } | ||
| 436 | if dirs[parts[0]] == nil { | ||
| 437 | dirs[parts[0]] = map[string][]byte{} | ||
| 438 | } | ||
| 439 | dirs[parts[0]][parts[1]] = content | ||
| 292 | if err := f.Close(); err != nil { | ||
| 293 | return err | ||
| 440 | 294 | } | |
| 441 | for name, sub := range dirs { | ||
| 442 | sha, err := buildTree(g, sub) | ||
| 443 | if err != nil { | ||
| 444 | return "", err | ||
| 445 | } | ||
| 446 | entries = append(entries, gitx.Entry{Mode: "040000", Type: "tree", SHA: sha, Name: name}) | ||
| 295 | if err := os.Chmod(tmp, 0o644); err != nil { | ||
| 296 | return err | ||
| 447 | 297 | } | |
| 448 | return g.MkTree(entries) | ||
| 298 | return os.Rename(tmp, path) | ||
| 449 | 299 | } | |
| 450 | 300 | ||
| 451 | // keyFileName derives a filesystem-safe name from the key itself. The filename | ||
| 452 | // is only an address; the .pub content stays authoritative. | ||
| 453 | func keyFileName(pub ssh.PublicKey) string { | ||
| 454 | sum := sha256.Sum256(pub.Marshal()) | ||
| 455 | return hex.EncodeToString(sum[:]) | ||
| 456 | } | ||
| 457 | |||
| 458 | // userNameFromPath extracts the user from "users/<name>/...". | ||
| 459 | func userNameFromPath(p string) string { | ||
| 460 | parts := strings.Split(p, "/") | ||
| 461 | if len(parts) < 3 || parts[0] != usersDir { | ||
| 462 | return "" | ||
| 301 | // audit appends one line describing a roster change. Best effort: failing to | ||
| 302 | // write the log must not fail an otherwise valid key change. | ||
| 303 | func (s *Store) audit(action, user, detail string) { | ||
| 304 | if s.AuditPath == "" { | ||
| 305 | return | ||
| 463 | 306 | } | |
| 464 | return parts[1] | ||
| 465 | } | ||
| 466 | |||
| 467 | func randomID() (string, error) { | ||
| 468 | b := make([]byte, 12) | ||
| 469 | if _, err := rand.Read(b); err != nil { | ||
| 470 | return "", err | ||
| 307 | f, err := os.OpenFile(s.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | ||
| 308 | if err != nil { | ||
| 309 | return | ||
| 471 | 310 | } | |
| 472 | return hex.EncodeToString(b), nil | ||
| 311 | fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", | ||
| 312 | time.Now().UTC().Format(time.RFC3339), action, user, detail) | ||
| 313 | f.Close() | ||
| 473 | 314 | } | |
| 474 | |||
| 475 | // KeyDir is where a user's keys live inside the tree. | ||
| 476 | func KeyDir(name string) string { return filepath.Join(usersDir, name, keysDir) } | ||