// Package keygen creates SSH key pairs natively in Go (no ssh-keygen // dependency) and, optionally, appends a matching Host block to ~/.ssh/config. // // It is a rebuild of an older create_ssh_keypair.sh, fixing that script's // cross-platform and safety problems: // - no macOS-only `sed -i ''` (public-key comments are handled in Go); // - the .pub file is kept, not deleted after display; // - ed25519 by default, RSA-4096 as an explicit opt-out; // - config appends are quoted/validated, back up the file first, and refuse // to create a duplicate Host alias. package keygen import ( "crypto" "crypto/ed25519" "crypto/rand" "crypto/rsa" "encoding/pem" "fmt" "io" "os" "path/filepath" "strings" "time" "golang.org/x/crypto/ssh" "gitea.apointless.space/bsncubed/dial/internal/sshconf" ) // Algorithm selects the key type. type Algorithm string const ( AlgoED25519 Algorithm = "ed25519" AlgoRSA Algorithm = "rsa" rsaBits = 4096 ) // Result holds the encoded key material ready to write to disk. type Result struct { PrivatePEM []byte // OpenSSH-format private key (optionally passphrase-encrypted) PublicLine []byte // authorized_keys line, comment appended, trailing newline } // Generate creates a key pair of the given algorithm. comment is embedded in // the private key and appended to the public line. A non-empty passphrase // encrypts the private key at rest. func Generate(algo Algorithm, comment string, passphrase []byte) (Result, error) { var privKey crypto.PrivateKey var pubKey crypto.PublicKey switch algo { case AlgoRSA: k, err := rsa.GenerateKey(rand.Reader, rsaBits) if err != nil { return Result{}, err } privKey, pubKey = k, &k.PublicKey case AlgoED25519, "": pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return Result{}, err } privKey, pubKey = priv, pub default: return Result{}, fmt.Errorf("unknown algorithm %q", algo) } var block *pem.Block var err error if len(passphrase) > 0 { block, err = ssh.MarshalPrivateKeyWithPassphrase(privKey, comment, passphrase) } else { block, err = ssh.MarshalPrivateKey(privKey, comment) } if err != nil { return Result{}, fmt.Errorf("encoding private key: %w", err) } sshPub, err := ssh.NewPublicKey(pubKey) if err != nil { return Result{}, err } pubLine := strings.TrimRight(string(ssh.MarshalAuthorizedKey(sshPub)), "\n") if comment != "" { pubLine += " " + comment } pubLine += "\n" return Result{PrivatePEM: pem.EncodeToMemory(block), PublicLine: []byte(pubLine)}, nil } // WriteKeyPair writes the private key (0600) and public key (0644) into sshDir // as and .pub. It refuses to overwrite existing files so a stray // re-run can't clobber a key still in use. func WriteKeyPair(sshDir, name string, r Result) (privPath, pubPath string, err error) { if err = os.MkdirAll(sshDir, 0o700); err != nil { return "", "", err } privPath = filepath.Join(sshDir, name) pubPath = privPath + ".pub" if exists(privPath) || exists(pubPath) { return privPath, pubPath, fmt.Errorf("key files for %q already exist; refusing to overwrite", name) } if err = os.WriteFile(privPath, r.PrivatePEM, 0o600); err != nil { return privPath, pubPath, err } if err = os.WriteFile(pubPath, r.PublicLine, 0o644); err != nil { return privPath, pubPath, err } return privPath, pubPath, nil } // HostEntry describes a Host block to append to ~/.ssh/config. type HostEntry struct { Alias string HostName string User string Port string IdentityFile string Tags []string } // AliasExists reports whether the config already declares the given Host alias. func AliasExists(configPath, alias string) (bool, error) { hosts, err := sshconf.Parse(configPath) if err != nil { return false, err } for _, h := range hosts { if h.Alias == alias { return true, nil } } return false, nil } // AppendHostEntry appends a Host block to configPath. It validates fields // (rejecting whitespace/newlines that could break or inject config), backs up // an existing config first, and refuses to duplicate an existing alias. func AppendHostEntry(configPath string, e HostEntry) (backup string, err error) { if err := validateEntry(e); err != nil { return "", err } if exists(configPath) { dup, err := AliasExists(configPath, e.Alias) if err != nil { return "", err } if dup { return "", fmt.Errorf("Host %q already exists in %s", e.Alias, configPath) } backup = fmt.Sprintf("%s.%s.bak", configPath, time.Now().Format("20060102-150405")) if err := copyFile(configPath, backup); err != nil { return "", fmt.Errorf("backing up config: %w", err) } } else if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { return "", err } var b strings.Builder b.WriteString("\n") if len(e.Tags) > 0 { b.WriteString("# tag: " + strings.Join(e.Tags, ", ") + "\n") } b.WriteString("Host " + e.Alias + "\n") if e.HostName != "" { b.WriteString(" HostName " + e.HostName + "\n") } if e.User != "" { b.WriteString(" User " + e.User + "\n") } if e.Port != "" && e.Port != "22" { b.WriteString(" Port " + e.Port + "\n") } if e.IdentityFile != "" { b.WriteString(" IdentityFile " + e.IdentityFile + "\n") b.WriteString(" IdentitiesOnly yes\n") } f, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return backup, err } defer f.Close() if _, err := f.WriteString(b.String()); err != nil { return backup, err } return backup, nil } func validateEntry(e HostEntry) error { if strings.TrimSpace(e.Alias) == "" { return fmt.Errorf("host alias is required") } fields := map[string]string{ "alias": e.Alias, "hostname": e.HostName, "user": e.User, "port": e.Port, "identityfile": e.IdentityFile, } for name, v := range fields { if strings.ContainsAny(v, "\n\r") { return fmt.Errorf("%s contains a newline", name) } } if strings.ContainsAny(e.Alias, " \t") { return fmt.Errorf("host alias %q must not contain spaces", e.Alias) } for _, t := range e.Tags { if strings.ContainsAny(t, "\n\r,") { return fmt.Errorf("tag %q must not contain newlines or commas", t) } } return nil } func exists(p string) bool { _, err := os.Stat(p) return err == nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } return out.Close() }