|
| 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