New Upstream Release - golang-github-charmbracelet-keygen

Ready changes

Summary

Merged new upstream version: 0.4.2 (was: 0.3.0).

Diff

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 067ee56..c9fcf3f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,28 +1,12 @@
 name: build
-on: [push, pull_request]
-jobs:
-  test:
-    strategy:
-      matrix:
-        go-version: [~1.17]
-        os: [ubuntu-latest, macos-latest, windows-latest]
-    runs-on: ${{ matrix.os }}
-    env:
-      GO111MODULE: "on"
-    steps:
-      - name: Install Go
-        uses: actions/setup-go@v2
-        with:
-          go-version: ${{ matrix.go-version }}
-
-      - name: Checkout code
-        uses: actions/checkout@v2
 
-      - name: Download Go modules
-        run: go mod download
+on: [push, pull_request]
 
-      - name: Build
-        run: go build -v ./...
+jobs:
+  build:
+    uses: charmbracelet/meta/.github/workflows/build.yml@main
 
-      - name: Test
-        run: go test ./...
+  snapshot:
+    uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
+    secrets:
+      goreleaser_key: ${{ secrets.GORELEASER_KEY }}
\ No newline at end of file
diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml
new file mode 100644
index 0000000..55b3662
--- /dev/null
+++ b/.github/workflows/goreleaser.yml
@@ -0,0 +1,19 @@
+name: goreleaser
+
+on:
+  push:
+    tags:
+      - v*.*.*
+
+concurrency:
+  group: goreleaser
+  cancel-in-progress: true
+
+jobs:
+  goreleaser:
+    uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main
+    secrets:
+      docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
+      docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
+      gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
+      goreleaser_key: ${{ secrets.GORELEASER_KEY }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 8f5e197..6249b5b 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,5 +1,7 @@
 name: lint
-on: [push, pull_request]
+on:
+  push:
+  pull_request:
 
 jobs:
   golangci:
@@ -12,7 +14,5 @@ jobs:
         with:
           # Optional: golangci-lint command line arguments.
           args: --issues-exit-code=0
-          # Optional: working directory, useful for monorepos
-          # working-directory: somedir
           # Optional: show only new issues if it's a pull request. The default value is `false`.
           only-new-issues: true
diff --git a/.github/workflows/soft-serve.yml b/.github/workflows/soft-serve.yml
new file mode 100644
index 0000000..b10a5c9
--- /dev/null
+++ b/.github/workflows/soft-serve.yml
@@ -0,0 +1,12 @@
+name: soft-serve
+
+on:
+  push:
+    branches:
+      - main
+
+jobs:
+  soft-serve:
+    uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
+    secrets:
+      ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..eea5c74
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,3 @@
+includes:
+  - from_url:
+      url: charmbracelet/meta/main/goreleaser-lib.yaml
\ No newline at end of file
diff --git a/README.md b/README.md
index da00d4f..656b621 100644
--- a/README.md
+++ b/README.md
@@ -10,23 +10,30 @@ An SSH key pair generator with password protected keys support. Supports generat
 ## Example
 
 ```go
-filepath := filepath.Join(".ssh",  "my_awesome_key")
-passphrase := []byte("awesome_secret")
-k, err := NewWithWrite(filepath, passphrase, key.Ed25519)
+kp, err := keygen.New("awesome", keygen.WithPassphrase("awesome_secret"),
+	keygen.WithKeyType(keygen.Ed25519))
 if err != nil {
-	fmt.Printf("error creating SSH key pair: %v", err)
-	os.Exit(1)
+	log.Fatalf("error creating SSH key pair: %v", err)
 }
+fmt.Printf("Your authorized key: %s\n", kp.AuthorizedKey())
 ```
 
+## Feedback
+
+We’d love to hear your thoughts on this project. Feel free to drop us a note!
+
+- [Twitter](https://twitter.com/charmcli)
+- [The Fediverse](https://mastodon.social/@charmcli)
+- [Discord](https://charm.sh/chat)
+
 ## License
 
 [MIT](https://github.com/charmbracelet/keygen/raw/master/LICENSE)
 
-***
+---
 
 Part of [Charm](https://charm.sh).
 
-<a href="https://charm.sh/"><img alt="the Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
+<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
 
 Charm热爱开源 • Charm loves open source
diff --git a/debian/changelog b/debian/changelog
index 3ed1594..6e6a6df 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,12 @@
-golang-github-charmbracelet-keygen (0.3.0-1) UNRELEASED; urgency=medium
+golang-github-charmbracelet-keygen (0.4.2-1) UNRELEASED; urgency=medium
 
+  [ Martin Dosch ]
   * New upstream release.
 
- -- Martin Dosch <martin@mdosch.de>  Sun, 30 Oct 2022 09:40:45 +0000
+  [ Debian Janitor ]
+  * New upstream release.
+
+ -- Martin Dosch <martin@mdosch.de>  Mon, 05 Jun 2023 11:30:39 -0000
 
 golang-github-charmbracelet-keygen (0.1.2-2) unstable; urgency=medium
 
diff --git a/go.mod b/go.mod
index 955be9b..5eb5dd5 100644
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,8 @@ module github.com/charmbracelet/keygen
 go 1.17
 
 require (
-	github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3
-	github.com/mitchellh/go-homedir v1.1.0
-	golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70
+	github.com/caarlos0/sshmarshal v0.1.0
+	golang.org/x/crypto v0.8.0
 )
 
-require golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect
+require golang.org/x/sys v0.7.0 // indirect
diff --git a/go.sum b/go.sum
index 6ba6dc9..f10a613 100644
--- a/go.sum
+++ b/go.sum
@@ -1,16 +1,44 @@
-github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3 h1:w2ANoiT4ubmh4Nssa3/QW1M7lj3FZkma8f8V5aBDxXM=
-github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
-github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 h1:syTAU9FwmvzEoIYMqcPHOcVm4H3U5u90WsvuYgwpETU=
+github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
+github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
+golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
+golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/keygen.go b/keygen.go
index f35f590..37b5ce3 100644
--- a/keygen.go
+++ b/keygen.go
@@ -2,7 +2,6 @@
 package keygen
 
 import (
-	"bytes"
 	"crypto"
 	"crypto/ecdsa"
 	"crypto/ed25519"
@@ -16,9 +15,9 @@ import (
 	"os"
 	"os/user"
 	"path/filepath"
+	"strings"
 
 	"github.com/caarlos0/sshmarshal"
-	"github.com/mitchellh/go-homedir"
 	"golang.org/x/crypto/ssh"
 )
 
@@ -32,6 +31,11 @@ const (
 	ECDSA   KeyType = "ecdsa"
 )
 
+// String implements the Stringer interface for KeyType.
+func (k KeyType) String() string {
+	return string(k)
+}
+
 const rsaDefaultBits = 4096
 
 // ErrMissingSSHKeys indicates we're missing some keys that we expected to
@@ -41,14 +45,18 @@ var ErrMissingSSHKeys = errors.New("missing one or more keys; did something happ
 // ErrUnsupportedKeyType indicates an unsupported key type.
 type ErrUnsupportedKeyType struct {
 	keyType string
+	ecName  string
 }
 
-// Error implements the error interface for ErrUnsupportedKeyType
+// Error implements the error interface for ErrUnsupportedKeyType.
 func (e ErrUnsupportedKeyType) Error() string {
 	err := "unsupported key type"
 	if e.keyType != "" {
 		err += fmt.Sprintf(": %s", e.keyType)
 	}
+	if e.ecName != "" {
+		err += fmt.Sprintf(" (ECDSA curve: %s)", e.ecName)
+	}
 	return err
 }
 
@@ -79,81 +87,147 @@ type SSHKeysAlreadyExistErr struct {
 // SSHKeyPair holds a pair of SSH keys and associated methods.
 type SSHKeyPair struct {
 	path       string // private key filename path; public key will have .pub appended
+	writeKeys  bool
 	passphrase []byte
+	rsaBitSize int
+	ec         elliptic.Curve
 	keyType    KeyType
 	privateKey crypto.PrivateKey
 }
 
 func (s SSHKeyPair) privateKeyPath() string {
-	p := fmt.Sprintf("%s_%s", s.path, s.keyType)
-	return p
+	return s.path
 }
 
 func (s SSHKeyPair) publicKeyPath() string {
 	return s.privateKeyPath() + ".pub"
 }
 
+// Option is a functional option for SSHKeyPair.
+type Option func(*SSHKeyPair)
+
+// WithPassphrase sets the passphrase for the private key.
+func WithPassphrase(passphrase string) Option {
+	return func(s *SSHKeyPair) {
+		s.passphrase = []byte(passphrase)
+	}
+}
+
+// WithKeyType sets the key type for the key pair.
+// Available key types are RSA, Ed25519, and ECDSA.
+func WithKeyType(keyType KeyType) Option {
+	return func(s *SSHKeyPair) {
+		s.keyType = keyType
+	}
+}
+
+// WithBitSize sets the key size for the RSA key pair.
+// This option is ignored for other key types.
+func WithBitSize(bits int) Option {
+	return func(s *SSHKeyPair) {
+		s.rsaBitSize = bits
+	}
+}
+
+// WithWrite writes the key pair to disk if it doesn't exist.
+func WithWrite() Option {
+	return func(s *SSHKeyPair) {
+		s.writeKeys = true
+	}
+}
+
+// WithEllipticCurve sets the elliptic curve for the ECDSA key pair.
+// Supported curves are P-256, P-384, and P-521.
+// The default curve is P-384.
+// This option is ignored for other key types.
+func WithEllipticCurve(curve elliptic.Curve) Option {
+	return func(s *SSHKeyPair) {
+		s.ec = curve
+	}
+}
+
 // New generates an SSHKeyPair, which contains a pair of SSH keys.
-func New(path string, passphrase []byte, keyType KeyType) (*SSHKeyPair, error) {
+//
+// If the key pair already exists, it will be loaded from disk, otherwise, a
+// new SSH key pair is generated.
+// If no key type is specified, Ed25519 will be used.
+func New(path string, opts ...Option) (*SSHKeyPair, error) {
 	var err error
 	s := &SSHKeyPair{
-		path:       path,
-		keyType:    keyType,
-		passphrase: passphrase,
+		path:       expandPath(path),
+		rsaBitSize: rsaDefaultBits,
+		ec:         elliptic.P384(),
+		keyType:    Ed25519,
+	}
+
+	for _, opt := range opts {
+		opt(s)
 	}
+
+	ecName := s.ec.Params().Name
+	switch ecName {
+	case "P-256", "P-384", "P-521":
+	default:
+		return nil, ErrUnsupportedKeyType{keyType: ecName, ecName: ecName}
+	}
+
 	if s.KeyPairExists() {
 		privData, err := ioutil.ReadFile(s.privateKeyPath())
 		if err != nil {
 			return nil, err
 		}
+
 		var k interface{}
-		if len(passphrase) > 0 {
-			k, err = ssh.ParseRawPrivateKeyWithPassphrase(privData, passphrase)
+		if len(s.passphrase) > 0 {
+			k, err = ssh.ParseRawPrivateKeyWithPassphrase(privData, s.passphrase)
 		} else {
 			k, err = ssh.ParseRawPrivateKey(privData)
 		}
+
 		if err != nil {
 			return nil, err
 		}
+
 		switch k := k.(type) {
-		case *rsa.PrivateKey, *ecdsa.PrivateKey, *ed25519.PrivateKey:
+		case *rsa.PrivateKey:
+			s.keyType = RSA
+			s.privateKey = k
+		case *ecdsa.PrivateKey:
+			s.keyType = ECDSA
+			s.privateKey = k
+		case *ed25519.PrivateKey:
+			s.keyType = Ed25519
 			s.privateKey = k
 		default:
-			return nil, ErrUnsupportedKeyType{fmt.Sprintf("%T", k)}
+			return nil, ErrUnsupportedKeyType{keyType: fmt.Sprintf("%T", k)}
 		}
+
 		return s, nil
 	}
-	switch keyType {
+
+	switch s.keyType {
 	case Ed25519:
 		err = s.generateEd25519Keys()
 	case RSA:
-		err = s.generateRSAKeys(rsaDefaultBits)
+		err = s.generateRSAKeys(s.rsaBitSize)
 	case ECDSA:
-		err = s.generateECDSAKeys(elliptic.P384())
+		err = s.generateECDSAKeys(s.ec)
 	default:
-		return nil, ErrUnsupportedKeyType{string(keyType)}
+		return nil, ErrUnsupportedKeyType{keyType: string(s.keyType)}
 	}
-	if err != nil {
-		return nil, err
-	}
-	return s, nil
-}
 
-// NewWithWrite generates an SSHKeyPair and writes it to disk if not exist.
-func NewWithWrite(path string, passphrase []byte, keyType KeyType) (*SSHKeyPair, error) {
-	s, err := New(path, passphrase, keyType)
 	if err != nil {
 		return nil, err
 	}
-	if !s.KeyPairExists() {
-		if err = s.WriteKeys(); err != nil {
-			return nil, err
-		}
+
+	if s.writeKeys {
+		return s, s.WriteKeys()
 	}
+
 	return s, nil
 }
 
-// PrivateKey returns the unencrypted private key.
+// PrivateKey returns the unencrypted crypto.PrivateKey.
 func (s *SSHKeyPair) PrivateKey() crypto.PrivateKey {
 	switch s.keyType {
 	case RSA, Ed25519, ECDSA:
@@ -163,49 +237,115 @@ func (s *SSHKeyPair) PrivateKey() crypto.PrivateKey {
 	}
 }
 
-// PrivateKeyPEM returns the unencrypted private key in OPENSSH PEM format.
-func (s *SSHKeyPair) PrivateKeyPEM() []byte {
-	block, err := s.pemBlock(nil)
-	if err != nil {
-		return nil
-	}
-	return pem.EncodeToMemory(block)
+// Ensure that SSHKeyPair implements crypto.Signer.
+// This is used to ensure that the private key is a valid crypto.Signer to be
+// passed to ssh.NewSignerFromKey.
+var (
+	_ crypto.Signer = (*rsa.PrivateKey)(nil)
+	_ crypto.Signer = (*ecdsa.PrivateKey)(nil)
+	_ crypto.Signer = (*ed25519.PrivateKey)(nil)
+)
+
+// Signer returns an ssh.Signer for the key pair.
+func (s *SSHKeyPair) Signer() ssh.Signer {
+	sk, _ := ssh.NewSignerFromKey(s.PrivateKey())
+	return sk
+}
+
+// PublicKey returns the ssh.PublicKey for the key pair.
+func (s *SSHKeyPair) PublicKey() ssh.PublicKey {
+	p, _ := ssh.NewPublicKey(s.cryptoPublicKey())
+	return p
 }
 
-// PublicKey returns the SSH public key (RFC 4253). Ready to be used in an
-// OpenSSH authorized_keys file.
-func (s *SSHKeyPair) PublicKey() []byte {
-	var pk crypto.PublicKey
-	// Prepare public key
+func (s *SSHKeyPair) cryptoPublicKey() crypto.PublicKey {
 	switch s.keyType {
 	case RSA:
 		key, ok := s.privateKey.(*rsa.PrivateKey)
 		if !ok {
 			return nil
 		}
-		pk = key.Public()
+		return key.Public()
 	case Ed25519:
 		key, ok := s.privateKey.(*ed25519.PrivateKey)
 		if !ok {
 			return nil
 		}
-		pk = key.Public()
+		return key.Public()
 	case ECDSA:
 		key, ok := s.privateKey.(*ecdsa.PrivateKey)
 		if !ok {
 			return nil
 		}
-		pk = key.Public()
+		return key.Public()
 	default:
 		return nil
 	}
-	p, err := ssh.NewPublicKey(pk)
+}
+
+// CryptoPublicKey returns the crypto.PublicKey of the SSH key pair.
+func (s *SSHKeyPair) CryptoPublicKey() crypto.PublicKey {
+	return s.cryptoPublicKey()
+}
+
+// RawAuthorizedKey returns the underlying SSH public key (RFC 4253) in OpenSSH
+// authorized_keys format.
+func (s *SSHKeyPair) RawAuthorizedKey() []byte {
+	bts, err := os.ReadFile(s.publicKeyPath())
+	if err != nil {
+		return []byte(s.AuthorizedKey())
+	}
+
+	_, c, opts, _, err := ssh.ParseAuthorizedKey(bts)
+	if err != nil {
+		return []byte(s.AuthorizedKey())
+	}
+
+	ak := s.authorizedKey(s.PublicKey())
+	if len(opts) > 0 {
+		ak = fmt.Sprintf("%s %s", strings.Join(opts, ","), ak)
+	}
+
+	if c != "" {
+		ak = fmt.Sprintf("%s %s", ak, c)
+	}
+
+	return []byte(ak)
+}
+
+func (s *SSHKeyPair) authorizedKey(pk ssh.PublicKey) string {
+	if pk == nil {
+		return ""
+	}
+
+	// serialize authorized key
+	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk)))
+}
+
+// AuthorizedKey returns the SSH public key (RFC 4253) in OpenSSH authorized_keys
+// format. The returned string is trimmed of sshd options and comments.
+func (s *SSHKeyPair) AuthorizedKey() string {
+	return s.authorizedKey(s.PublicKey())
+}
+
+// RawPrivateKey returns the raw unencrypted private key bytes in PEM format.
+func (s *SSHKeyPair) RawPrivateKey() []byte {
+	return s.rawPrivateKey(nil)
+}
+
+// RawProtectedPrivateKey returns the raw password protected private key bytes
+// in PEM format.
+func (s *SSHKeyPair) RawProtectedPrivateKey() []byte {
+	return s.rawPrivateKey(s.passphrase)
+}
+
+func (s *SSHKeyPair) rawPrivateKey(pass []byte) []byte {
+	block, err := s.pemBlock(pass)
 	if err != nil {
 		return nil
 	}
-	// serialize public key
-	ak := ssh.MarshalAuthorizedKey(p)
-	return pubKeyWithMemo(ak)
+
+	return pem.EncodeToMemory(block)
 }
 
 func (s *SSHKeyPair) pemBlock(passphrase []byte) (*pem.Block, error) {
@@ -220,7 +360,7 @@ func (s *SSHKeyPair) pemBlock(passphrase []byte) (*pem.Block, error) {
 		}
 		return sshmarshal.MarshalPrivateKey(key, "")
 	default:
-		return nil, ErrUnsupportedKeyType{string(s.keyType)}
+		return nil, ErrUnsupportedKeyType{keyType: s.keyType.String()}
 	}
 }
 
@@ -273,7 +413,7 @@ func (s *SSHKeyPair) prepFilesystem() error {
 
 	keyDir := filepath.Dir(s.path)
 	if keyDir != "" {
-		keyDir, err = homedir.Expand(keyDir)
+		keyDir, err = filepath.Abs(keyDir)
 		if err != nil {
 			return err
 		}
@@ -314,20 +454,11 @@ func (s *SSHKeyPair) prepFilesystem() error {
 // WriteKeys writes the SSH key pair to disk.
 func (s *SSHKeyPair) WriteKeys() error {
 	var err error
-	priv := s.PrivateKeyPEM()
-	pub := s.PublicKey()
-	if priv == nil || pub == nil {
+	priv := s.RawProtectedPrivateKey()
+	if priv == nil {
 		return ErrMissingSSHKeys
 	}
 
-	// Encrypt private key with passphrase
-	if len(s.passphrase) > 0 {
-		block, err := s.pemBlock(s.passphrase)
-		if err != nil {
-			return err
-		}
-		priv = pem.EncodeToMemory(block)
-	}
 	if err = s.prepFilesystem(); err != nil {
 		return err
 	}
@@ -335,7 +466,12 @@ func (s *SSHKeyPair) WriteKeys() error {
 	if err := writeKeyToFile(priv, s.privateKeyPath()); err != nil {
 		return err
 	}
-	if err := writeKeyToFile(pub, s.publicKeyPath()); err != nil {
+
+	ak := s.AuthorizedKey()
+	if memo := pubKeyMemo(); memo != "" {
+		ak = fmt.Sprintf("%s %s", ak, memo)
+	}
+	if err := writeKeyToFile([]byte(ak), s.publicKeyPath()); err != nil {
 		return err
 	}
 
@@ -365,19 +501,33 @@ func fileExists(path string) bool {
 	return true
 }
 
+// expandPath resolves the tilde `~` and any environment variables in path.
+func expandPath(path string) string {
+	if len(path) > 0 && path[0] == '~' {
+		userdir, err := os.UserHomeDir()
+		if err != nil {
+			return path
+		}
+
+		path = filepath.Join(userdir, path[1:])
+	}
+
+	return os.ExpandEnv(path)
+}
+
 // attaches a user@host suffix to a serialized public key. returns the original
 // pubkey if we can't get the username or host.
-func pubKeyWithMemo(pubKey []byte) []byte {
+func pubKeyMemo() string {
 	u, err := user.Current()
 	if err != nil {
-		return pubKey
+		return ""
 	}
 	hostname, err := os.Hostname()
 	if err != nil {
-		return pubKey
+		return ""
 	}
 
-	return append(bytes.TrimRight(pubKey, "\n"), []byte(fmt.Sprintf(" %s@%s\n", u.Username, hostname))...)
+	return fmt.Sprintf("%s@%s\n", u.Username, hostname)
 }
 
 // Error returns the a human-readable error message for SSHKeysAlreadyExistErr.
diff --git a/keygen_test.go b/keygen_test.go
index c5c5b35..8188a95 100644
--- a/keygen_test.go
+++ b/keygen_test.go
@@ -1,6 +1,7 @@
 package keygen
 
 import (
+	"bytes"
 	"crypto/elliptic"
 	"fmt"
 	"io/ioutil"
@@ -10,11 +11,85 @@ import (
 )
 
 func TestNewSSHKeyPair(t *testing.T) {
-	p := filepath.Join(t.TempDir(), "test")
-	_, err := NewWithWrite(p, []byte(""), RSA)
+	kp, err := New("")
 	if err != nil {
 		t.Errorf("error creating SSH key pair: %v", err)
 	}
+	if kp.keyType != Ed25519 {
+		t.Errorf("expected default key type to be Ed25519, got %s", kp.keyType)
+	}
+}
+
+func nilTest(t testing.TB, kp *SSHKeyPair) {
+	t.Helper()
+	if kp == nil {
+		t.Error("expected key pair to be non-nil")
+	}
+	if kp.PrivateKey() == nil {
+		t.Error("expected private key to be non-nil")
+	}
+	if kp.PublicKey() == nil {
+		t.Error("expected public key to be non-nil")
+	}
+	if kp.RawPrivateKey() == nil {
+		t.Error("expected raw private key to be non-nil")
+	}
+	if kp.RawProtectedPrivateKey() == nil {
+		t.Error("expected raw protected private key to be non-nil")
+	}
+	if kp.AuthorizedKey() == "" {
+		t.Error("expected authorized key to be non-nil")
+	}
+	if kp.Signer() == nil {
+		t.Error("expected signer to be non-nil")
+	}
+}
+
+func TestNilSSHKeyPair(t *testing.T) {
+	for _, kt := range []KeyType{RSA, Ed25519, ECDSA} {
+		t.Run(fmt.Sprintf("test nil key pair for %s", kt), func(t *testing.T) {
+			kp, err := New("", WithKeyType(kt))
+			if err != nil {
+				t.Errorf("error creating SSH key pair: %v", err)
+			}
+			nilTest(t, kp)
+		})
+	}
+}
+
+func TestNilSSHKeyPairWithPassphrase(t *testing.T) {
+	for _, kt := range []KeyType{RSA, Ed25519, ECDSA} {
+		t.Run(fmt.Sprintf("test nil key pair for %s", kt), func(t *testing.T) {
+			kp, err := New("", WithKeyType(kt), WithPassphrase("test"))
+			if err != nil {
+				t.Errorf("error creating SSH key pair: %v", err)
+			}
+			nilTest(t, kp)
+		})
+	}
+}
+
+func TestNilSSHKeyPairTestdata(t *testing.T) {
+	for _, kt := range []KeyType{RSA, Ed25519, ECDSA} {
+		t.Run(fmt.Sprintf("test nil key pair for %s", kt), func(t *testing.T) {
+			kp, err := New(filepath.Join("testdata", "test_"+kt.String()), WithPassphrase("test"), WithKeyType(kt))
+			if err != nil {
+				t.Errorf("error creating SSH key pair: %v", err)
+			}
+			nilTest(t, kp)
+		})
+	}
+}
+
+func TestUnsupportedCurve(t *testing.T) {
+	_, err := New("", WithKeyType(ECDSA), WithEllipticCurve(elliptic.P224()))
+	if err == nil {
+		t.Error("expected error for unsupported curve")
+	}
+	_, err = New("", WithKeyType(ECDSA), WithEllipticCurve(elliptic.P256()))
+	if err != nil {
+		t.Errorf("expected no error for supported curve, got %v", err)
+	}
 }
 
 func TestGenerateEd25519Keys(t *testing.T) {
@@ -35,11 +110,11 @@ func TestGenerateEd25519Keys(t *testing.T) {
 
 		// TODO: is there a good way to validate these? Lengths seem to vary a bit,
 		// so far now we're just asserting that the keys indeed exist.
-		if len(k.PrivateKeyPEM()) == 0 {
+		if len(k.RawPrivateKey()) == 0 {
 			t.Error("error creating SSH private key PEM; key is 0 bytes")
 		}
-		if len(k.PublicKey()) == 0 {
-			t.Error("error creating SSH public key; key is 0 bytes")
+		if len(k.AuthorizedKey()) == 0 {
+			t.Error("error creating SSH authorized key; key is 0 bytes")
 		}
 	})
 
@@ -91,20 +166,21 @@ func TestGenerateECDSAKeys(t *testing.T) {
 	k := &SSHKeyPair{
 		path:    filepath.Join(dir, filename),
 		keyType: ECDSA,
+		ec:      elliptic.P384(),
 	}
 
 	t.Run("test generate SSH keys", func(t *testing.T) {
-		err := k.generateECDSAKeys(elliptic.P384())
+		err := k.generateECDSAKeys(k.ec)
 		if err != nil {
 			t.Errorf("error creating SSH key pair: %v", err)
 		}
 
 		// TODO: is there a good way to validate these? Lengths seem to vary a bit,
 		// so far now we're just asserting that the keys indeed exist.
-		if len(k.PrivateKeyPEM()) == 0 {
+		if len(k.RawPrivateKey()) == 0 {
 			t.Error("error creating SSH private key PEM; key is 0 bytes")
 		}
-		if len(k.PublicKey()) == 0 {
+		if len(k.AuthorizedKey()) == 0 {
 			t.Error("error creating SSH public key; key is 0 bytes")
 		}
 	})
@@ -173,13 +249,13 @@ func createEmptyFile(t *testing.T, path string) (ok bool) {
 
 func TestGeneratePublicKeyWithEmptyDir(t *testing.T) {
 	for _, keyType := range []KeyType{RSA, ECDSA, Ed25519} {
-		func(t *testing.T) {
-			k, err := NewWithWrite("testkey", nil, keyType)
+		t.Run("test generate public key with empty dir", func(t *testing.T) {
+			fp := filepath.Join(t.TempDir(), "testkey")
+			k, err := New(fp, WithKeyType(keyType), WithWrite())
 			if err != nil {
 				t.Fatalf("error creating SSH key pair: %v", err)
 			}
-			fn := fmt.Sprintf("testkey_%s", keyType)
-			f, err := os.Open(fn + ".pub")
+			f, err := os.Open(fp + ".pub")
 			if err != nil {
 				t.Fatalf("error opening SSH key file: %v", err)
 			}
@@ -188,25 +264,27 @@ func TestGeneratePublicKeyWithEmptyDir(t *testing.T) {
 			if err != nil {
 				t.Fatalf("error reading SSH key file: %v", err)
 			}
-			defer os.Remove(fn)
-			defer os.Remove(fn + ".pub")
-			if string(k.PublicKey()) != string(fc) {
+			if bytes.Equal(k.RawAuthorizedKey(), fc) {
 				t.Errorf("error key mismatch\nprivate key:\n%s\n\nactual file:\n%s", k.PrivateKey(), string(fc))
 			}
-		}(t)
+			t.Cleanup(func() {
+				os.Remove(fp)
+				os.Remove(fp + ".pub")
+			})
+		})
 	}
 }
 
 func TestGenerateKeyWithPassphrase(t *testing.T) {
+	ph := "testpass"
 	for _, keyType := range []KeyType{RSA, ECDSA, Ed25519} {
-		ph := "testpass"
-		func(t *testing.T) {
-			_, err := NewWithWrite("testph", []byte(ph), keyType)
+		t.Run("test generate key with passphrase", func(t *testing.T) {
+			fp := filepath.Join(t.TempDir(), "testph")
+			_, err := New(fp, WithKeyType(keyType), WithPassphrase(ph), WithWrite())
 			if err != nil {
 				t.Fatalf("error creating SSH key pair: %v", err)
 			}
-			fn := fmt.Sprintf("testph_%s", keyType)
-			f, err := os.Open(fn)
+			f, err := os.Open(fp)
 			if err != nil {
 				t.Fatalf("error opening SSH key file: %v", err)
 			}
@@ -215,25 +293,67 @@ func TestGenerateKeyWithPassphrase(t *testing.T) {
 			if err != nil {
 				t.Fatalf("error reading SSH key file: %v", err)
 			}
-			defer os.Remove(fn)
-			defer os.Remove(fn + ".pub")
-			k, err := New("testph", []byte(ph), keyType)
+			k, err := New(fp, WithKeyType(keyType), WithPassphrase(ph))
 			if err != nil {
 				t.Fatalf("error reading SSH key pair: %v", err)
 			}
-			if string(k.PrivateKeyPEM()) == string(fc) {
+			if bytes.Equal(k.RawPrivateKey(), fc) {
 				t.Errorf("encrypted private key matches file contents")
 			}
-		}(t)
+			t.Cleanup(func() {
+				os.Remove(fp)
+				os.Remove(fp + ".pub")
+			})
+		})
 	}
 }
 
 func TestReadingKeyWithPassphrase(t *testing.T) {
 	for _, keyType := range []KeyType{RSA, ECDSA, Ed25519} {
 		kp := filepath.Join("testdata", "test")
-		_, err := New(kp, []byte("test"), keyType)
+		_, err := New(kp, WithKeyType(keyType), WithPassphrase("test"))
 		if err != nil {
 			t.Fatalf("error reading SSH key pair: %v", err)
 		}
 	}
 }
+
+func TestKeynameSuffix(t *testing.T) {
+	for _, keyType := range []KeyType{RSA, ECDSA, Ed25519} {
+		t.Run("test keyname suffix", func(t *testing.T) {
+			fp := filepath.Join(t.TempDir(), "testkey_"+keyType.String())
+			_, err := New(fp, WithKeyType(keyType), WithWrite())
+			if err != nil {
+				t.Fatalf("error creating SSH key pair: %v", err)
+			}
+			if _, err := os.Stat(fp); os.IsNotExist(err) {
+				t.Errorf("private key file %s does not exist", fp)
+			}
+			t.Cleanup(func() {
+				os.Remove(fp)
+				os.Remove(fp + ".pub")
+			})
+		})
+	}
+}
+
+func TestExpandPath(t *testing.T) {
+	tmpdir := t.TempDir()
+	os.Setenv("TEMP", tmpdir)
+	defer os.Unsetenv("TEMP")
+
+	// Test environment variable expansion
+	if fp := expandPath(filepath.Join("$TEMP", "testkey")); fp != filepath.Join(tmpdir, "testkey") {
+		t.Errorf("error expanding path: %s", fp)
+	}
+
+	// Test tilde expansion
+	homedir, err := os.UserHomeDir()
+	if err != nil {
+		t.Fatalf("error getting user home directory: %v", err)
+	}
+
+	if fp := expandPath(filepath.Join("~", "testkey")); fp != filepath.Join(homedir, "testkey") {
+		t.Errorf("error expanding path: %s", fp)
+	}
+}

More details

Full run details

Historical runs