1package serve23import (4 "bufio"5 "bytes"6 "crypto/ed25519"7 "crypto/rand"8 "encoding/pem"9 "errors"10 "fmt"11 "io/fs"12 "os"13 "path/filepath"14 "strings"1516 gossh "golang.org/x/crypto/ssh"17)1819// hostKey loads the server's private key from path, generating an20// ed25519 key there first if there's none. A key others can read or21// write is refused, as sshd refuses one.22func hostKey(path string) (gossh.Signer, error) {23 st, err := os.Stat(path)24 if errors.Is(err, fs.ErrNotExist) {25 if err := generateHostKey(path); err != nil {26 return nil, fmt.Errorf("generating the host key: %w", err)27 }28 st, err = os.Stat(path)29 }30 if err != nil {31 return nil, err32 }33 if st.Mode().Perm()&0o077 != 0 {34 return nil, fmt.Errorf("host key %s is accessible by others (%v); chmod 600 it", path, st.Mode().Perm())35 }36 data, err := os.ReadFile(path)37 if err != nil {38 return nil, err39 }40 s, err := gossh.ParsePrivateKey(data)41 if err != nil {42 return nil, fmt.Errorf("host key %s: %w", path, err)43 }44 return s, nil45}4647// generateHostKey writes a new ed25519 key to path (0600) and its public48// half beside it, creating the directory (0700) if needed.49func generateHostKey(path string) error {50 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {51 return err52 }53 pub, priv, err := ed25519.GenerateKey(rand.Reader)54 if err != nil {55 return err56 }57 block, err := gossh.MarshalPrivateKey(priv, "012 host key")58 if err != nil {59 return err60 }61 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)62 if err != nil {63 return err64 }65 _, err = f.Write(pem.EncodeToMemory(block))66 if cerr := f.Close(); err == nil {67 err = cerr68 }69 if err != nil {70 os.Remove(path)71 return err72 }73 sshPub, err := gossh.NewPublicKey(pub)74 if err != nil {75 return err76 }77 return os.WriteFile(path+".pub", gossh.MarshalAuthorizedKey(sshPub), 0o644)78}7980// safeOptions are the authorized_keys options a key may carry and still81// be accepted: they only turn off things 012 never offers. Keys with any82// other option (command=, from=, no-pty, expiry-time= and so on) are83// skipped, since 012 can't honor the restriction.84var safeOptions = map[string]bool{85 "no-port-forwarding": true,86 "no-agent-forwarding": true,87 "no-x11-forwarding": true,88 "no-user-rc": true,89 "restrict": true,90 "pty": true,91}9293// authorizedKeys reads the keys allowed to log in from path, in94// OpenSSH's format. It fails if the file can be written by others, as95// sshd's StrictModes does, or if a line doesn't parse. skipped counts96// keys left out for their options.97func authorizedKeys(path string) (keys []gossh.PublicKey, skipped int, err error) {98 st, err := os.Stat(path)99 if err != nil {100 return nil, 0, err101 }102 if !st.Mode().IsRegular() {103 return nil, 0, fmt.Errorf("%s is not a regular file", path)104 }105 if st.Mode().Perm()&0o022 != 0 {106 return nil, 0, fmt.Errorf("%s can be written by others (%v); chmod go-w it", path, st.Mode().Perm())107 }108 data, err := os.ReadFile(path)109 if err != nil {110 return nil, 0, err111 }112 sc := bufio.NewScanner(bytes.NewReader(data))113 sc.Buffer(nil, 1<<20)114 for n := 1; sc.Scan(); n++ {115 line := bytes.TrimSpace(sc.Bytes())116 if len(line) == 0 || line[0] == '#' {117 continue118 }119 key, _, opts, _, err := gossh.ParseAuthorizedKey(line)120 if err != nil {121 return nil, 0, fmt.Errorf("%s:%d: %w", path, n, err)122 }123 if !acceptable(opts) {124 skipped++125 continue126 }127 keys = append(keys, key)128 }129 return keys, skipped, sc.Err()130}131132// acceptable reports whether a key with these authorized_keys options133// may log in to 012.134func acceptable(opts []string) bool {135 restricted, pty := false, false136 for _, o := range opts {137 o = strings.ToLower(o)138 if !safeOptions[o] {139 return false140 }141 restricted = restricted || o == "restrict"142 pty = pty || o == "pty"143 }144 // restrict takes the terminal away unless pty gives it back, and 012145 // needs one.146 return !restricted || pty147}148149// fingerprint is a key's SHA256 fingerprint, as ssh-keygen -l shows it.150func fingerprint(k gossh.PublicKey) string {151 if k == nil {152 return ""153 }154 return gossh.FingerprintSHA256(k)155}