Skip to content

Commit 33df492

Browse files
authored
Merge pull request #5 from go-git/auto
objectsigner: Add auto ObjectSigner plugin
2 parents 9547ecb + c8838f7 commit 33df492

6 files changed

Lines changed: 1193 additions & 0 deletions

File tree

.entire/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"enabled": true,
3+
"strategy": "manual-commit",
4+
"strategy_options": {
5+
"checkpoint_remote": {
6+
"provider": "github",
7+
"repo": "go-git/entire"
8+
}
9+
}
10+
}

.golangci.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ linters:
55
- depguard
66
- wsl
77
settings:
8+
gomoddirectives:
9+
replace-local: true
810
govet:
911
enable-all: true
1012
revive:

plugin/objectsigner/auto/auto.go

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
// Package auto constructs a [Signer] from the git signing configuration
2+
// fields gpg.format and user.signingKey. It supports OpenPGP and SSH signing.
3+
//
4+
// For SSH signing the resolution logic closely mirrors the git CLI: file
5+
// paths, key:: literals, and .pub paths matched via an SSH agent are all
6+
// supported. For OpenPGP signing the behaviour differs from git: the git
7+
// CLI expects a key ID or fingerprint and shells out to gpg(1), whereas
8+
// this package expects a file path to an armored private-key ring and
9+
// signs natively in Go.
10+
//
11+
// The underlying signing process takes place via Go native libraries, as
12+
// opposed to shelling out to binaries.
13+
//
14+
// Passphrase-protected keys are not supported directly. Expose such keys
15+
// through an SSH agent instead, or use the lower-level gpg and ssh sibling
16+
// packages when full control over key loading is required.
17+
package auto
18+
19+
import (
20+
"bytes"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"os"
25+
"path/filepath"
26+
"strings"
27+
28+
"github.com/ProtonMail/go-crypto/openpgp"
29+
billy "github.com/go-git/go-billy/v6"
30+
"github.com/go-git/go-billy/v6/osfs"
31+
"github.com/go-git/go-billy/v6/util"
32+
gpgpkg "github.com/go-git/x/plugin/objectsigner/gpg"
33+
sshpkg "github.com/go-git/x/plugin/objectsigner/ssh"
34+
gossh "golang.org/x/crypto/ssh"
35+
"golang.org/x/crypto/ssh/agent"
36+
)
37+
38+
var (
39+
// ErrPassphraseUnsupported is returned when the SSH private key on disk
40+
// is protected by a passphrase.
41+
ErrPassphraseUnsupported = errors.New("passphrase-protected SSH keys are not supported")
42+
// ErrEncryptedKeyUnsupported is returned when every private key in an
43+
// OpenPGP key ring is encrypted and no unencrypted alternative exists.
44+
ErrEncryptedKeyUnsupported = errors.New("encrypted GPG private keys are not supported")
45+
// ErrNoPrivateKey is returned when no usable private key can be found:
46+
// the key ring contains no private-key material, or no SigningKey or
47+
// agent was provided.
48+
ErrNoPrivateKey = errors.New("no private key found")
49+
// ErrNoPrivateKeyInAgent is returned when the SSH agent holds no keys.
50+
ErrNoPrivateKeyInAgent = errors.New("no private key found in SSH agent")
51+
// ErrUnsupportedFormat is returned for unrecognized signing formats.
52+
ErrUnsupportedFormat = errors.New("unsupported signing format")
53+
)
54+
55+
// Format represents the signing format as configured by gpg.format.
56+
type Format string
57+
58+
const (
59+
// FormatOpenPGP selects OpenPGP (GPG) signing. This is the default
60+
// when no format is configured.
61+
FormatOpenPGP Format = "openpgp"
62+
// FormatSSH selects SSH signing.
63+
FormatSSH Format = "ssh"
64+
)
65+
66+
// Config holds the git signing configuration values needed to construct
67+
// the appropriate signer.
68+
type Config struct {
69+
// FS is the filesystem used to read key files. When nil, it defaults
70+
// to the OS root filesystem.
71+
FS billy.Basic
72+
73+
// SSHAgent is an optional SSH agent for SSH signing. It is consulted when
74+
// SigningKey is a key:: literal, a .pub file path, or empty. For any other
75+
// path, the private key is read from FS directly and the agent is ignored.
76+
SSHAgent agent.Agent
77+
78+
// SigningKey is the value of user.signingKey.
79+
//
80+
// For SSH format:
81+
// - Path to a private key file (e.g. ~/.ssh/id_ed25519).
82+
// - Path to a public key file ending in .pub (e.g. ~/.ssh/id_ed25519.pub)
83+
// when SSHAgent is set; the agent is queried for the matching signer.
84+
// - A key:: literal (e.g. "key::ssh-ed25519 AAAA...") when SSHAgent is
85+
// set; the agent is queried for the matching signer.
86+
// - Empty string when SSHAgent is set; the first agent signer is used.
87+
//
88+
// For OpenPGP format: path to an armored private-key file.
89+
//
90+
// A leading ~/ is expanded to the current user's home directory.
91+
// ~username/ prefixes are not expanded.
92+
SigningKey string
93+
94+
// Format is the value of gpg.format.
95+
// Supported: FormatSSH, FormatOpenPGP. Defaults to FormatOpenPGP when empty.
96+
Format Format
97+
}
98+
99+
// Signer signs a message read from an io.Reader and returns the raw signature
100+
// bytes.
101+
type Signer interface {
102+
Sign(message io.Reader) ([]byte, error)
103+
}
104+
105+
// FromConfig returns a [Signer] configured according to the provided Config.
106+
// It reads the signing key from disk and selects the appropriate signer
107+
// implementation based on the format.
108+
//
109+
//nolint:ireturn // Signer is the package's own exported interface; callers always use it as Signer.
110+
func FromConfig(cfg Config) (Signer, error) {
111+
if cfg.FS == nil {
112+
cfg.FS = osfs.Default
113+
}
114+
115+
signingKey, err := expandHome(cfg.SigningKey)
116+
if err != nil {
117+
return nil, err
118+
}
119+
120+
cfg.SigningKey = signingKey
121+
122+
switch cfg.Format {
123+
case FormatSSH:
124+
return newSSHSigner(cfg.FS, cfg.SigningKey, cfg.SSHAgent)
125+
case "", FormatOpenPGP:
126+
return newGPGSigner(cfg.FS, cfg.SigningKey)
127+
default:
128+
return nil, fmt.Errorf("%w: %q", ErrUnsupportedFormat, cfg.Format)
129+
}
130+
}
131+
132+
// expandHome replaces a leading ~/ with the current user's home directory.
133+
// Paths that do not start with ~/ are returned unchanged. ~username/ prefixes
134+
// are not expanded.
135+
func expandHome(path string) (string, error) {
136+
if !strings.HasPrefix(path, "~/") {
137+
return path, nil
138+
}
139+
140+
home, err := os.UserHomeDir()
141+
if err != nil {
142+
return "", fmt.Errorf("expanding ~/ in signing key path: %w", err)
143+
}
144+
145+
return filepath.Join(home, path[2:]), nil
146+
}
147+
148+
// newSSHSigner resolves keyInfoOrPath and returns an SSH signer.
149+
// Resolution order:
150+
//
151+
// 1. key:: prefix – keyInfoOrPath holds a literal public key; sshAgent must
152+
// be non-nil and is queried for the matching signer.
153+
// 2. .pub suffix + sshAgent – the public key is read from fsys and the
154+
// matching agent signer is returned.
155+
// 3. Any other non-empty path – the file is read from fsys as a private key;
156+
// sshAgent is not consulted.
157+
// 4. Empty keyInfoOrPath + sshAgent – the first available agent signer is used.
158+
// 5. Empty keyInfoOrPath, no sshAgent – error.
159+
//
160+
//nolint:ireturn // Signer is the package's own exported interface.
161+
func newSSHSigner(fsys billy.Basic, keyInfoOrPath string, sshAgent agent.Agent) (Signer, error) {
162+
if len(keyInfoOrPath) > 0 {
163+
return sshSignerFromKey(fsys, keyInfoOrPath, sshAgent)
164+
}
165+
166+
// No SigningKey: use the first available key from the agent.
167+
if sshAgent != nil {
168+
return sshFromAgent(sshAgent, nil)
169+
}
170+
171+
return nil, fmt.Errorf("%w: %s", ErrNoPrivateKey, "missing signingKey or active ssh-agent")
172+
}
173+
174+
// sshSignerFromKey resolves a non-empty keyInfoOrPath to an SSH signer.
175+
//
176+
//nolint:ireturn // Signer is the package's own exported interface.
177+
func sshSignerFromKey(fsys billy.Basic, keyInfoOrPath string, sshAgent agent.Agent) (Signer, error) {
178+
if key, found := strings.CutPrefix(keyInfoOrPath, "key::"); found {
179+
return sshSignerFromLiteral(sshAgent, key)
180+
}
181+
182+
if strings.HasSuffix(keyInfoOrPath, ".pub") && sshAgent != nil {
183+
return sshSignerFromPubFile(fsys, keyInfoOrPath, sshAgent)
184+
}
185+
186+
// Not a key:: literal or agent-matched .pub path: read as a private key.
187+
return sshSignerFromPrivateKeyFile(fsys, keyInfoOrPath)
188+
}
189+
190+
// sshSignerFromLiteral resolves a key:: literal against the SSH agent.
191+
//
192+
//nolint:ireturn // Signer is the package's own exported interface.
193+
func sshSignerFromLiteral(sshAgent agent.Agent, key string) (Signer, error) {
194+
if sshAgent == nil {
195+
return nil, fmt.Errorf("%w: signingKey must be a file path when ssh-agent is not being used", ErrNoPrivateKey)
196+
}
197+
198+
pubKey, err := parseAuthorizedKey([]byte(sshTrimIdentifier(key)))
199+
if err != nil {
200+
return nil, fmt.Errorf("parsing SSH public key from signingKey literal: %w", err)
201+
}
202+
203+
return sshFromAgent(sshAgent, pubKey.Marshal())
204+
}
205+
206+
// sshSignerFromPubFile reads a .pub file from fsys and matches it against the SSH agent.
207+
//
208+
//nolint:ireturn // Signer is the package's own exported interface.
209+
func sshSignerFromPubFile(fsys billy.Basic, keyInfoOrPath string, sshAgent agent.Agent) (Signer, error) {
210+
pubData, err := util.ReadFile(fsys, keyInfoOrPath)
211+
if err != nil {
212+
return nil, fmt.Errorf("reading SSH public key: %w", err)
213+
}
214+
215+
pubKey, err := parseAuthorizedKey(pubData)
216+
if err != nil {
217+
return nil, fmt.Errorf("parsing SSH public key: %w", err)
218+
}
219+
220+
return sshFromAgent(sshAgent, pubKey.Marshal())
221+
}
222+
223+
// sshSignerFromPrivateKeyFile reads a private key from fsys and returns a signer.
224+
//
225+
//nolint:ireturn // Signer is the package's own exported interface.
226+
func sshSignerFromPrivateKeyFile(fsys billy.Basic, keyInfoOrPath string) (Signer, error) {
227+
data, err := util.ReadFile(fsys, keyInfoOrPath)
228+
if err != nil {
229+
return nil, fmt.Errorf("reading SSH private key: %w", err)
230+
}
231+
232+
signer, err := gossh.ParsePrivateKey(data)
233+
if err != nil {
234+
var passErr *gossh.PassphraseMissingError
235+
if errors.As(err, &passErr) {
236+
return nil, ErrPassphraseUnsupported
237+
}
238+
239+
return nil, fmt.Errorf("parsing SSH private key: %w", err)
240+
}
241+
242+
result, fErr := sshpkg.FromKey(signer, sshpkg.WithHashAlgorithm(sshpkg.SHA512))
243+
if fErr != nil {
244+
return nil, fmt.Errorf("creating SSH signer: %w", fErr)
245+
}
246+
247+
return result, nil
248+
}
249+
250+
// parseAuthorizedKey parses a single entry from an authorized_keys file,
251+
// returning only the public key. It wraps gossh.ParseAuthorizedKey,
252+
// discarding the unused return values.
253+
//
254+
//nolint:ireturn // gossh.PublicKey is an external interface; no concrete type is accessible.
255+
func parseAuthorizedKey(data []byte) (gossh.PublicKey, error) {
256+
//nolint:dogsled // API returns 5 values; only key+err are needed.
257+
pubKey, _, _, _, err := gossh.ParseAuthorizedKey(data)
258+
if err != nil {
259+
return nil, fmt.Errorf("parsing authorized key: %w", err)
260+
}
261+
262+
return pubKey, nil
263+
}
264+
265+
// sshAuthorizedKeyFields is the minimum number of space-separated fields in
266+
// an authorized-key string (key-type + base64-encoded key).
267+
const sshAuthorizedKeyFields = 2
268+
269+
// sshTrimIdentifier strips any trailing comment from an authorized-key string,
270+
// returning only the key type and base64-encoded key (e.g.
271+
// "ssh-ed25519 AAAA... comment" → "ssh-ed25519 AAAA...").
272+
func sshTrimIdentifier(key string) string {
273+
fields := strings.Fields(key)
274+
if len(fields) < sshAuthorizedKeyFields {
275+
return key
276+
}
277+
278+
return strings.Join(fields[:sshAuthorizedKeyFields], " ")
279+
}
280+
281+
// sshFromAgent returns a Signer backed by the agent signer whose public-key
282+
// wire encoding matches pubKeyBytes. When pubKeyBytes is nil or empty, the
283+
// first available signer is returned without filtering.
284+
//
285+
//nolint:ireturn // Signer is the package's own exported interface.
286+
func sshFromAgent(sshAgent agent.Agent, pubKeyBytes []byte) (Signer, error) {
287+
signers, err := sshAgent.Signers()
288+
if err != nil {
289+
return nil, fmt.Errorf("listing agent signers: %w", err)
290+
}
291+
292+
for _, s := range signers {
293+
if len(pubKeyBytes) == 0 || bytes.Equal(s.PublicKey().Marshal(), pubKeyBytes) {
294+
signer, sErr := sshpkg.FromKey(s, sshpkg.WithHashAlgorithm(sshpkg.SHA512))
295+
if sErr != nil {
296+
return nil, fmt.Errorf("creating SSH signer from agent key: %w", sErr)
297+
}
298+
299+
return signer, nil
300+
}
301+
}
302+
303+
if len(pubKeyBytes) != 0 {
304+
return nil, fmt.Errorf("%w: no keys found matching signingKey", ErrNoPrivateKey)
305+
}
306+
307+
return nil, ErrNoPrivateKeyInAgent
308+
}
309+
310+
// newGPGSigner reads an armored OpenPGP private-key ring from keyPath and
311+
// returns a signer backed by the first unencrypted private key found.
312+
// Entities without private-key material are skipped. Encrypted private keys
313+
// are also skipped; ErrEncryptedKeyUnsupported is returned only when no
314+
// unencrypted key exists in the ring.
315+
//
316+
//nolint:ireturn // Signer is the package's own exported interface.
317+
func newGPGSigner(fsys billy.Basic, keyPath string) (Signer, error) {
318+
if keyPath == "" {
319+
return nil, fmt.Errorf("%w: %s", ErrNoPrivateKey, "missing signingKey")
320+
}
321+
322+
data, err := util.ReadFile(fsys, keyPath)
323+
if err != nil {
324+
return nil, fmt.Errorf("reading GPG private key: %w", err)
325+
}
326+
327+
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
328+
if err != nil {
329+
return nil, fmt.Errorf("parsing GPG key ring: %w", err)
330+
}
331+
332+
var hasEncrypted bool
333+
334+
for _, entity := range entities {
335+
if entity.PrivateKey == nil {
336+
continue
337+
}
338+
339+
if entity.PrivateKey.Encrypted {
340+
hasEncrypted = true
341+
342+
continue
343+
}
344+
345+
signer, sErr := gpgpkg.FromKey(entity)
346+
if sErr != nil {
347+
return nil, fmt.Errorf("creating GPG signer: %w", sErr)
348+
}
349+
350+
return signer, nil
351+
}
352+
353+
if hasEncrypted {
354+
return nil, ErrEncryptedKeyUnsupported
355+
}
356+
357+
return nil, ErrNoPrivateKey
358+
}

0 commit comments

Comments
 (0)