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.

Accounts with stable ids; the roster moves into the system repository

31df8baea9c9e8acf74b71bdda26d27907b30f2a · Keenan Nemetz · 18d ago · parent 36293228

Accounts with stable ids; the roster moves into the system repository

Names were the primary key everywhere — the keys file name, the ACL entry, the
approval file — so renaming someone broke their access, orphaned their approvals,
and left a retired name able to hand a newcomer the old holder's rights. This
splits identity into an account (stable id, anchored by its SSH keys) and a name
(mutable, never reissued).

  data/system.git  refs/heads/main
    users/<ulid>/meta.json   name, status, keys, former_names

Everything that grants authority now holds the id: ACL entries, approval files,
and the identity resolved at the SSH handshake (VOLE_ACTOR_ID). A rename is a
one-file change that disturbs nothing else, and both the old and new names stay
reserved forever.

The roster moved from a trusted-keys directory back into git, which reverses an
earlier simplification — deliberately, and in a smaller shape (one file per
account, one writer, no CAS plumbing for unused fields). Git buys what a directory
could not: `git log` answers who renamed whom and when a key appeared, and the
roster is now readable by anyone who can clone, since it is served read-only at
ssh://host/system.git. `vole request new` and `approve` use exactly that to turn a
name typed on the command line into the account id they store.

Two latent bugs surfaced, both caught by tests added here:

- gitx inherited git's repository-scoped environment. pre-receive runs with
  GIT_OBJECT_DIRECTORY and GIT_QUARANTINE_PATH pointing at the pushed repository's
  quarantine, so every git call a hook made for a *different* repository silently
  read the pushed one: the hook saw an empty roster. gitx now scrubs those
  variables and the hook restores them for the pushed repository only.
- The hook built the repository name with filepath.Base on receive-pack's path,
  which carries a trailing separator, so refusals read "does not have write access
  to .". Cleaned first.

Verified end to end on a live instance: two accounts file, approve and land a
change; `vole user rename wilbur robert` keeps the same key's write access and the
approval still reads as current, now credited to robert; the roster history shows
"user: rename wilbur to robert"; retiring an account rejects its key within five
seconds and refuses to reissue the name.

internal/identity/identity.go +400−198

@@ -1,306 +1,509 @@
1 // Package identity is the instance's user roster: one file of SSH public keys
2 // per user, in a trusted-keys directory.
1 // Package identity stores the user roster in git.
3 2 //
4 // data/trusted_keys/keenan.keys # one authorized_keys line per device
3 // data/system.git
4 // refs/heads/main
5 // users/<ulid>/meta.json
5 6 //
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.
7 // An account has a stable id and a mutable name. The id is what ACL entries and
8 // approvals refer to, so renaming somebody touches no repository state, and a
9 // retired name is never handed to a second account — which is what stops a new
10 // user from inheriting an old one's access and approvals.
10 11 //
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.
12 // The keys anchor the account: the SSH handshake resolves a presented key to an
13 // account id, and every authorization decision downstream uses that id rather
14 // than a name somebody chose to type.
15 //
16 // Nothing stored here is secret, so the repository can be cloned, copied or put
17 // under configuration management without leaking anything.
14 18 package identity
15 19
16 20 import (
21 "encoding/json"
17 22 "fmt"
18 23 "os"
24 "os/exec"
19 25 "path/filepath"
26 "regexp"
20 27 "sort"
21 28 "strings"
22 "sync"
23 29 "sync/atomic"
24 30 "time"
25 31
32 "github.com/rodent-software/vole/internal/gitx"
33 "github.com/rodent-software/vole/internal/id"
26 34 "golang.org/x/crypto/ssh"
27 35 )
28 36
29 37 const (
30 keysSuffix = ".keys"
31 header = "# vole: SSH public keys for %s, one per device (authorized_keys format).\n"
38 // Ref is the branch of the system repository carrying the roster.
39 Ref = "refs/heads/main"
40 // Dir is the roster subtree.
41 Dir = "users"
42
43 metaName = "/meta.json"
32 44 )
33 45
34 type User struct {
35 Name string
36 Keys []Key
46 var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
47
48 // ValidateName reports whether s may be an account name.
49 func ValidateName(s string) error {
50 if !nameRE.MatchString(s) {
51 return fmt.Errorf("name %q is not valid: 2-32 characters, lowercase letters, digits, - and _", s)
52 }
53 return nil
37 54 }
38 55
39 56 type Key struct {
40 Fingerprint string
41 Type string
42 Comment string
43 Line string
57 Fingerprint string `json:"fingerprint"`
58 Type string `json:"type"`
59 Line string `json:"line"`
60 Comment string `json:"comment,omitempty"`
61 }
62
63 type Account struct {
64 ID string
65 Name string
66 Status string // active | retired
67 Former []string
68 Keys []Key
69 Created time.Time
70 }
71
72 func (a *Account) Active() bool { return a.Status == "active" }
73
74 type meta struct {
75 Name string `json:"name"`
76 Status string `json:"status"`
77 Created string `json:"created"`
78 Former []string `json:"former_names,omitempty"`
79 Keys []Key `json:"keys"`
44 80 }
45 81
46 82 // 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.
83 // auth callback stays a map lookup.
48 84 type Snapshot struct {
49 Users map[string]*User
50 ByFingerprint map[string]string
85 ByID map[string]*Account
86 // ByKey maps a key fingerprint to an active account id. Retired accounts are
87 // absent, so revoking is immediate.
88 ByKey map[string]string
89 // ByName maps an active name to its account.
90 ByName map[string]*Account
91 // Reserved holds every name an account has ever held, including former names
92 // and retired accounts. A reserved name is never issued twice.
93 Reserved map[string]string
94
95 Count int // active accounts
51 96 }
52 97
53 98 func (s *Snapshot) Lookup(fingerprint string) (string, bool) {
54 99 if s == nil {
55 100 return "", false
56 101 }
57 name, ok := s.ByFingerprint[fingerprint]
58 return name, ok
59 }
60
61 func (s *Snapshot) Names() []string {
62 if s == nil {
63 return nil
64 }
65 out := make([]string, 0, len(s.Users))
66 for n := range s.Users {
67 out = append(out, n)
68 }
69 sort.Strings(out)
70 return out
102 a, ok := s.ByKey[fingerprint]
103 return a, ok
71 104 }
72 105
73 106 type Store struct {
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.
107 // AuditPath, when set, receives one line per roster change.
77 108 AuditPath string
109 repo *gitx.Repo
78 110 snap atomic.Pointer[Snapshot]
79 mu sync.Mutex
80 111 }
81 112
82 // Init creates the trusted-keys directory if it is missing.
83 func Init(dir string) error { return os.MkdirAll(dir, 0o755) }
113 // Init creates the system repository if it is missing.
114 func Init(dir string) error {
115 if _, err := os.Stat(filepath.Join(dir, "HEAD")); err == nil {
116 return nil
117 }
118 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
119 return err
120 }
121 // -b main so that `git log` in the roster repository shows the roster rather
122 // than an unborn master branch.
123 if out, err := exec.Command("git", "init", "--bare", "-q", "-b", "main", dir).CombinedOutput(); err != nil {
124 return fmt.Errorf("identity: git init --bare %s: %w: %s", dir, err, out)
125 }
126 return nil
127 }
84 128
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.
129 // Open loads the roster. An empty roster is valid: it means nobody can write yet.
130 // A roster that does not parse is a hard error, because booting with a partial
131 // roster would silently change who can authenticate.
87 132 func Open(dir string) (*Store, error) {
88 st, err := os.Stat(dir)
89 if err != nil || !st.IsDir() {
90 return nil, fmt.Errorf("identity: %s is missing; run vole init", dir)
133 if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil {
134 if _, err := os.Stat(dir); err != nil {
135 return nil, fmt.Errorf("identity: %s is missing; run vole init", dir)
136 }
91 137 }
92 s := &Store{Dir: dir}
138 s := &Store{repo: gitx.Open(dir)}
93 139 if err := s.Reload(); err != nil {
94 140 return nil, err
95 141 }
96 142 return s, nil
97 143 }
98 144
145 func (s *Store) Snapshot() *Snapshot { return s.snap.Load() }
146
147 // Dir is the system repository's path.
148 func (s *Store) Dir() string { return s.repo.Dir }
149
150 // Reload re-reads the roster, keeping the previous snapshot if the new one does
151 // not parse: a half-finished edit must not lock everybody out.
99 152 func (s *Store) Reload() error {
100 entries, err := os.ReadDir(s.Dir)
153 commit, err := s.repo.RevParse(Ref)
101 154 if err != nil {
102 155 return err
103 156 }
104 snap := &Snapshot{Users: map[string]*User{}, ByFingerprint: map[string]string{}}
105 for _, e := range entries {
106 if e.IsDir() || !strings.HasSuffix(e.Name(), keysSuffix) {
107 continue
157 files := map[string][]byte{}
158 if commit != "" {
159 files, err = s.repo.FlatTree(commit)
160 if err != nil {
161 return err
162 }
163 }
164 snap, err := parse(files)
165 if err != nil {
166 return err
167 }
168 s.snap.Store(snap)
169 return nil
170 }
171
172 func parse(files map[string][]byte) (*Snapshot, error) {
173 snap := &Snapshot{
174 ByID: map[string]*Account{},
175 ByKey: map[string]string{},
176 ByName: map[string]*Account{},
177 Reserved: map[string]string{},
178 }
179 paths := make([]string, 0, len(files))
180 for p := range files {
181 if strings.HasPrefix(p, Dir+"/") && strings.HasSuffix(p, metaName) {
182 paths = append(paths, p)
183 }
184 }
185 sort.Strings(paths)
186 for _, path := range paths {
187 accountID := strings.TrimSuffix(strings.TrimPrefix(path, Dir+"/"), metaName)
188 if strings.Contains(accountID, "/") || !id.Valid(accountID) {
189 return nil, fmt.Errorf("identity: %s: %q is not a valid account id", path, accountID)
108 190 }
109 name := strings.TrimSuffix(e.Name(), keysSuffix)
110 if strings.HasPrefix(e.Name(), ".") {
111 continue
191 var m meta
192 dec := json.NewDecoder(strings.NewReader(string(files[path])))
193 dec.DisallowUnknownFields()
194 if err := dec.Decode(&m); err != nil {
195 return nil, fmt.Errorf("identity: %s: %w", path, err)
112 196 }
113 if err := ValidateName(name); err != nil {
114 return fmt.Errorf("identity: file %q: %w", e.Name(), err)
197 if err := ValidateName(m.Name); err != nil {
198 return nil, fmt.Errorf("identity: %s: %w", path, err)
115 199 }
116 data, err := os.ReadFile(filepath.Join(s.Dir, e.Name()))
200 switch m.Status {
201 case "":
202 m.Status = "active"
203 case "active", "retired":
204 default:
205 return nil, fmt.Errorf("identity: %s: status must be active or retired", path)
206 }
207 created, err := time.Parse(time.RFC3339, m.Created)
117 208 if err != nil {
118 return err
209 return nil, fmt.Errorf("identity: %s: created must be RFC3339: %w", path, err)
119 210 }
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
211 a := &Account{ID: accountID, Name: m.Name, Status: m.Status, Former: m.Former, Keys: m.Keys, Created: created.UTC()}
212 if a.Active() {
213 snap.ByName[a.Name] = a
214 snap.Count++
215 }
216 for _, n := range append([]string{a.Name}, a.Former...) {
217 if other, taken := snap.Reserved[n]; taken && other != a.ID {
218 return nil, fmt.Errorf("identity: the name %q is claimed by both %s and %s", n, other, a.ID)
125 219 }
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)
220 snap.Reserved[n] = a.ID
221 }
222 for _, k := range a.Keys {
223 if other, ok := snap.ByKey[k.Fingerprint]; ok && other != a.ID {
224 return nil, fmt.Errorf("identity: key %s appears under both %s and %s", k.Fingerprint, other, a.ID)
129 225 }
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)
226 if a.Active() {
227 snap.ByKey[k.Fingerprint] = a.ID
133 228 }
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
141 229 }
142 sort.Slice(u.Keys, func(i, j int) bool { return u.Keys[i].Fingerprint < u.Keys[j].Fingerprint })
143 snap.Users[name] = u
230 sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint })
231 snap.ByID[a.ID] = a
144 232 }
145 s.snap.Store(snap)
146 return nil
233 return snap, nil
147 234 }
148 235
149 func (s *Store) Snapshot() *Snapshot { return s.snap.Load() }
236 // Lookup resolves a key fingerprint to an active account id and name.
237 func (s *Store) Lookup(fingerprint string) (accountID, name string, ok bool) {
238 a, ok := s.Snapshot().ByID[s.Snapshot().ByKey[fingerprint]]
239 if !ok {
240 return "", "", false
241 }
242 return a.ID, a.Name, true
243 }
150 244
151 func (s *Store) Lookup(fingerprint string) (string, bool) {
152 return s.Snapshot().Lookup(fingerprint)
245 // ResolveID maps a name to an active account id.
246 func (s *Store) ResolveID(name string) (string, bool) {
247 a, ok := s.Snapshot().ByName[name]
248 if !ok {
249 return "", false
250 }
251 return a.ID, true
153 252 }
154 253
155 func ValidateName(name string) error {
156 if len(name) < 2 || len(name) > 32 {
157 return fmt.Errorf("username must be 2-32 characters")
254 // Name returns the display name for an account id, or the id itself when the
255 // account is unknown.
256 func (s *Store) Name(accountID string) string {
257 if a, ok := s.Snapshot().ByID[accountID]; ok {
258 return a.Name
158 259 }
159 for _, r := range name {
160 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
161 continue
260 return accountID
261 }
262
263 // Find resolves an id, an active name, a former name or a retired name.
264 func (s *Store) Find(idOrName string) (*Account, error) {
265 snap := s.Snapshot()
266 if a, ok := snap.ByID[idOrName]; ok {
267 return a, nil
268 }
269 if a, ok := snap.ByName[idOrName]; ok {
270 return a, nil
271 }
272 if owner, ok := snap.Reserved[idOrName]; ok {
273 if a, ok := snap.ByID[owner]; ok {
274 return a, nil
162 275 }
163 return fmt.Errorf("username may only contain lowercase letters, digits, dash and underscore")
164 276 }
165 return nil
277 return nil, fmt.Errorf("identity: no account %q", idOrName)
278 }
279
280 // All returns every account, active first, then by name.
281 func (s *Store) All() []*Account {
282 snap := s.Snapshot()
283 out := make([]*Account, 0, len(snap.ByID))
284 for _, a := range snap.ByID {
285 out = append(out, a)
286 }
287 sort.Slice(out, func(i, j int) bool {
288 if out[i].Active() != out[j].Active() {
289 return out[i].Active()
290 }
291 return out[i].Name < out[j].Name
292 })
293 return out
166 294 }
167 295
168 func (s *Store) path(name string) string { return filepath.Join(s.Dir, name+keysSuffix) }
296 // ---------- mutations ----------
297 //
298 // Every change is one commit on the system repository, so `git log users'`
299 // answers who changed what and when without a separate audit trail.
169 300
170 // Create adds an empty key file: a user who exists but cannot authenticate yet.
171 func (s *Store) Create(name string) error {
172 if err := ValidateName(name); err != nil {
301 func (s *Store) update(message, actor string, mutate func(files map[string][]byte, snap *Snapshot) error) error {
302 _, err := gitx.CommitFiles(s.repo, Ref, func(files map[string][]byte) error {
303 snap, err := parse(files)
304 if err != nil {
305 return err
306 }
307 return mutate(files, snap)
308 }, message, actor)
309 if err != nil {
173 310 return err
174 311 }
175 if _, ok := s.Snapshot().Users[name]; ok {
176 return fmt.Errorf("identity: user %q already exists", name)
312 return s.Reload()
313 }
314
315 // Create adds an account with a fresh id and no keys.
316 func (s *Store) Create(name, actor string) (*Account, error) {
317 if err := ValidateName(name); err != nil {
318 return nil, err
177 319 }
178 f, err := os.OpenFile(s.path(name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
320 var created *Account
321 err := s.update("user: create "+name, actor, func(files map[string][]byte, snap *Snapshot) error {
322 if owner, taken := snap.Reserved[name]; taken {
323 return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a retired or former name is never handed out twice", name, owner)
324 }
325 now := time.Now().UTC()
326 accountID, err := id.New(now)
327 if err != nil {
328 return err
329 }
330 a := &Account{ID: accountID, Name: name, Status: "active", Created: now}
331 raw, err := marshal(a)
332 if err != nil {
333 return err
334 }
335 files[Dir+"/"+accountID+metaName] = raw
336 created = a
337 return nil
338 })
179 339 if err != nil {
180 if os.IsExist(err) {
181 return fmt.Errorf("identity: user %q already exists", name)
340 return nil, err
341 }
342 s.audit("identity.create", created.ID, created.Name)
343 return created, nil
344 }
345
346 // Rename changes the name on an account. The old name stays reserved forever, so
347 // nobody can inherit it later.
348 func (s *Store) Rename(idOrName, newName, actor string) (*Account, error) {
349 if err := ValidateName(newName); err != nil {
350 return nil, err
351 }
352 var renamed *Account
353 err := s.update("user: rename "+idOrName+" to "+newName, actor, func(files map[string][]byte, snap *Snapshot) error {
354 a, err := find(snap, idOrName)
355 if err != nil {
356 return err
182 357 }
183 return err
358 if a.Name == newName {
359 return fmt.Errorf("identity: %s is already called %q", a.ID, newName)
360 }
361 if owner, taken := snap.Reserved[newName]; taken && owner != a.ID {
362 return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused", newName, owner)
363 }
364 a.Former = append(append([]string{}, a.Former...), a.Name)
365 a.Name = newName
366 raw, err := marshal(a)
367 if err != nil {
368 return err
369 }
370 files[Dir+"/"+a.ID+metaName] = raw
371 renamed = a
372 return nil
373 })
374 if err != nil {
375 return nil, err
184 376 }
185 defer f.Close()
186 if _, err := fmt.Fprintf(f, header, name); err != nil {
187 return err
377 s.audit("identity.rename", renamed.ID, renamed.Name)
378 return renamed, nil
379 }
380
381 // Retire disables an account. Its keys stop authenticating at the next reload and
382 // its name is never reissued.
383 func (s *Store) Retire(idOrName, actor string) (*Account, error) {
384 var retired *Account
385 err := s.update("user: retire "+idOrName, actor, func(files map[string][]byte, snap *Snapshot) error {
386 a, err := find(snap, idOrName)
387 if err != nil {
388 return err
389 }
390 a.Status = "retired"
391 raw, err := marshal(a)
392 if err != nil {
393 return err
394 }
395 files[Dir+"/"+a.ID+metaName] = raw
396 retired = a
397 return nil
398 })
399 if err != nil {
400 return nil, err
188 401 }
189 s.audit("identity.create", name, "")
190 return s.Reload()
402 s.audit("identity.retire", retired.ID, retired.Name)
403 return retired, nil
191 404 }
192 405
193 // AddKey registers an SSH public key for a user and returns its fingerprint.
194 // The write is an append, so it cannot lose a concurrent add.
195 func (s *Store) AddKey(name, authorizedKey, actor string) (string, error) {
406 // AddKey attaches an authorized_keys line to an account and returns its
407 // fingerprint.
408 func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) {
196 409 line := strings.TrimSpace(authorizedKey)
197 410 pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
198 411 if err != nil {
199 412 return "", fmt.Errorf("identity: not an SSH public key: %w", err)
200 413 }
201 414 fp := ssh.FingerprintSHA256(pub)
202 if owner, ok := s.Lookup(fp); ok {
203 return "", fmt.Errorf("identity: that key is already registered to %s", owner)
204 }
205 if _, ok := s.Snapshot().Users[name]; !ok {
206 return "", fmt.Errorf("identity: no such user %q", name)
207 }
208
209 f, err := os.OpenFile(s.path(name), os.O_APPEND|os.O_WRONLY, 0o644)
415 err = s.update("user: add key "+fp, actor, func(files map[string][]byte, snap *Snapshot) error {
416 a, err := find(snap, idOrName)
417 if err != nil {
418 return err
419 }
420 if owner, taken := snap.ByKey[fp]; taken && owner != a.ID {
421 return fmt.Errorf("identity: that key is already registered to %s", owner)
422 }
423 for _, k := range a.Keys {
424 if k.Fingerprint == fp {
425 return fmt.Errorf("identity: %s already has that key", a.Name)
426 }
427 }
428 a.Keys = append(a.Keys, Key{Fingerprint: fp, Type: pub.Type(), Line: line, Comment: comment})
429 raw, err := marshal(a)
430 if err != nil {
431 return err
432 }
433 files[Dir+"/"+a.ID+metaName] = raw
434 return nil
435 })
210 436 if err != nil {
211 437 return "", err
212 438 }
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 }
439 s.audit("identity.add-key", idOrName, fp+" "+comment)
224 440 return fp, nil
225 441 }
226 442
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
233 u, ok := s.Snapshot().Users[name]
234 if !ok {
235 return fmt.Errorf("identity: no such user %q", name)
236 }
237 found := false
238 for _, k := range u.Keys {
239 if k.Fingerprint == fingerprint {
240 found = true
241 break
443 // RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted.
444 func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error {
445 err := s.update("user: remove key "+fingerprint, actor, func(files map[string][]byte, snap *Snapshot) error {
446 a, err := find(snap, idOrName)
447 if err != nil {
448 return err
242 449 }
243 }
244 if !found {
245 return fmt.Errorf("identity: %s has no key %s", name, fingerprint)
246 }
247
248 p := s.path(name)
249 data, err := os.ReadFile(p)
250 if err != nil {
251 return err
252 }
253 var kept []string
254 for _, line := range strings.Split(string(data), "\n") {
255 trimmed := strings.TrimSpace(line)
256 switch {
257 case trimmed == "":
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)
450 var kept []Key
451 found := 0
452 for _, k := range a.Keys {
453 if k.Fingerprint == fingerprint || strings.HasPrefix(k.Fingerprint, fingerprint) {
454 found++
455 continue
268 456 }
457 kept = append(kept, k)
269 458 }
270 }
271 if err := writeAtomic(p, strings.Join(kept, "\n")+"\n"); err != nil {
459 switch {
460 case found == 0:
461 return fmt.Errorf("identity: %s has no key %s", a.Name, fingerprint)
462 case found > 1:
463 return fmt.Errorf("identity: %q matches %d keys of %s; give more of the fingerprint", fingerprint, found, a.Name)
464 }
465 a.Keys = kept
466 raw, err := marshal(a)
467 if err != nil {
468 return err
469 }
470 files[Dir+"/"+a.ID+metaName] = raw
471 return nil
472 })
473 if err != nil {
272 474 return err
273 475 }
274 s.audit("identity.remove-key", name, fingerprint)
275 return s.Reload()
476 s.audit("identity.remove-key", idOrName, fingerprint)
477 return nil
276 478 }
277 479
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-*")
283 if err != nil {
284 return err
480 func find(snap *Snapshot, idOrName string) (*Account, error) {
481 if a, ok := snap.ByID[idOrName]; ok {
482 return a, nil
285 483 }
286 tmp := f.Name()
287 defer os.Remove(tmp)
288 if _, err := f.WriteString(content); err != nil {
289 f.Close()
290 return err
291 }
292 if err := f.Close(); err != nil {
293 return err
484 if a, ok := snap.ByName[idOrName]; ok {
485 return a, nil
294 486 }
295 if err := os.Chmod(tmp, 0o644); err != nil {
296 return err
487 return nil, fmt.Errorf("identity: no active account %q", idOrName)
488 }
489
490 func marshal(a *Account) ([]byte, error) {
491 b, err := json.MarshalIndent(meta{
492 Name: a.Name,
493 Status: a.Status,
494 Created: a.Created.UTC().Format(time.RFC3339),
495 Former: a.Former,
496 Keys: a.Keys,
497 }, "", " ")
498 if err != nil {
499 return nil, err
297 500 }
298 return os.Rename(tmp, path)
501 return append(b, '\n'), nil
299 502 }
300 503
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) {
504 // audit appends one line describing a roster change. The git history is the real
505 // record; this is for operators grepping one file.
506 func (s *Store) audit(action, account, detail string) {
304 507 if s.AuditPath == "" {
305 508 return
306 509 }
@@ -308,7 +511,6 @@ func (s *Store) audit(action, user, detail string) {
308 511 if err != nil {
309 512 return
310 513 }
311 fmt.Fprintf(f, "%s\t%s\t%s\t%s\n",
312 time.Now().UTC().Format(time.RFC3339), action, user, detail)
514 fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, account, detail)
313 515 f.Close()
314 516 }