package repo import ( "context" "fmt" "os" "path/filepath" "strings" "time" ) // KeyLister is the half of the store this needs, so a caller does not drag the whole database in. type KeyLister interface { AllKeys(ctx context.Context) ([]AuthKey, error) } // AuthKey is one public key and the account it authorises. type AuthKey struct { Account string Algo string Blob string // Retired is when the key stopped granting access, and the zero time means it still does. Retired time.Time } // keyOptions refuse everything ssh can do except run the one command. Chapter 41.3. const keyOptions = "no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty" // WriteAuthorizedKeys rewrites the file from the database, because the database is what grants access. func WriteAuthorizedKeys(dir, bin string, keys []AuthKey) error { if dir == "" { return fmt.Errorf("no ssh directory to write into") } if bin == "" { bin = "barerepo" } var b strings.Builder b.WriteString("# Written by barerepo from its own database. Edits here are lost on the next write.\n") for _, k := range keys { if k.Account == "" || k.Algo == "" || k.Blob == "" { continue } // A quote in the account name would end the command, and a name cannot hold one anyway. if strings.ContainsAny(k.Account, "\"\\\n\r") { continue } fmt.Fprintf(&b, "command=\"%s ssh --account %s\",%s %s %s\n", bin, k.Account, keyOptions, k.Algo, k.Blob) } if err := os.MkdirAll(dir, 0o700); err != nil { return err } // Written beside the target and moved, so a reader never sees half a file and lose every key. tmp := filepath.Join(dir, ".authorized_keys.tmp") if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil { return err } return os.Rename(tmp, filepath.Join(dir, "authorized_keys")) } // WriteAllowedSigners writes the keys git checks a commit signature against, in ssh-keygen's format. func WriteAllowedSigners(path string, keys []AuthKey) error { if path == "" { return fmt.Errorf("no allowed signers file to write") } var b strings.Builder b.WriteString("# Written by barerepo from its own database. Edits here are lost on the next write.\n") seen := map[string]bool{} for _, k := range keys { if k.Algo == "" || k.Blob == "" || seen[k.Blob] { continue } seen[k.Blob] = true // The principal is a wildcard because a commit names an address barerepo never issued, and the namespace is what stops a sign in signature counting as a commit signature. opts := "namespaces=\"git\"" if !k.Retired.IsZero() { // One second past retirement in local time with no suffix, which is the only spelling ssh-keygen reads as a moment. opts += ",valid-before=\"" + k.Retired.Local().Add(time.Second).Format("20060102150405") + "\"" } fmt.Fprintf(&b, "* %s %s %s\n", opts, k.Algo, k.Blob) } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil { return err } return os.Rename(tmp, path) }