Skip to content

Commit 06f426b

Browse files
committed
Add explicit support for identities stored on hardware keys (like Yubikey)
Since Apple no longer support enumerating certificates stored on hardware keys in the Keychain Access application, this PR explicitly tries enumerate certificates stored in the "signature" slot for hardware keys that support PIV applets. Implementation details: - The hardware key PIN is prompted for at the beginning to make sure we don't interfere with the output git expects while signing - To make this as easy as possible, this PR adds a new struct called `PivIdentity` which implements `certstore.Identity` interface - The `PivIdentity` struct has an open handle to a `*piv.Yubikey` and needs to be closed properly when done using it
1 parent 3e90229 commit 06f426b

File tree

10 files changed

+312
-7
lines changed

10 files changed

+312
-7
lines changed

command_sign.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,6 @@ func commandSign() error {
2323
return fmt.Errorf("could not find identity matching specified user-id: %s", *localUserOpt)
2424
}
2525

26-
// Git is looking for "\n[GNUPG:] SIG_CREATED ", meaning we need to print a
27-
// line before SIG_CREATED. BEGIN_SIGNING seems appropraite. GPG emits this,
28-
// though GPGSM does not.
29-
sBeginSigning.emit()
30-
3126
cert, err := userIdent.Certificate()
3227
if err != nil {
3328
return errors.Wrap(err, "failed to get idenity certificate")
@@ -60,6 +55,10 @@ func commandSign() error {
6055
if err = sd.Sign([]*x509.Certificate{cert}, signer); err != nil {
6156
return errors.Wrap(err, "failed to sign message")
6257
}
58+
// Git is looking for "\n[GNUPG:] SIG_CREATED ", meaning we need to print a
59+
// line before SIG_CREATED. BEGIN_SIGNING seems appropraite. GPG emits this,
60+
// though GPGSM does not.
61+
sBeginSigning.emit()
6362
if *detachSignFlag {
6463
sd.Detached()
6564
}

command_sign_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"crypto/x509"
55
"testing"
66

7-
"github.com/github/ietf-cms/protocol"
87
"github.com/github/ietf-cms"
8+
"github.com/github/ietf-cms/protocol"
99
"github.com/stretchr/testify/require"
1010
)
1111

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ require (
88
github.com/github/certstore v0.1.0
99
github.com/github/fakeca v0.1.0
1010
github.com/github/ietf-cms v0.1.0
11+
github.com/go-piv/piv-go v1.7.0 // indirect
1112
github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b
1213
github.com/pkg/errors v0.8.1
1314
github.com/pmezard/go-difflib v1.0.0
1415
github.com/stretchr/testify v1.3.0
1516
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734
17+
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect
1618
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
99
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
1010
github.com/github/ietf-cms v0.1.0 h1:D+O9re6xDeWTYRpAFTfM0dm5NqJUcXZKFGOQg5Iq6Ls=
1111
github.com/github/ietf-cms v0.1.0/go.mod h1:eJEmhqWUqjpuS6OoXiqtuTmzOx4u81npQrXOzt/sPqo=
12+
github.com/go-piv/piv-go v1.7.0 h1:rfjdFdASfGV5KLJhSjgpGJ5lzVZVtRWn8ovy/H9HQ/U=
13+
github.com/go-piv/piv-go v1.7.0/go.mod h1:ON2WvQncm7dIkCQ7kYJs+nc3V4jHGfrrJnSF8HKy7Gk=
1214
github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b h1:K1wa7ads2Bu1PavI6LfBRMYSy6Zi+Rky0OhWBfrmkmY=
1315
github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
1416
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
@@ -27,4 +29,8 @@ golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8U
2729
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
2830
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
2931
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
32+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
33+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
34+
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
35+
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
3036
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

main.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,22 @@ func runCommand() error {
8080
defer store.Close()
8181

8282
// Get list of identities
83-
idents, err = store.Identities()
83+
pivIdents, err := PivIdentities()
84+
if err != nil {
85+
fmt.Fprintln(os.Stderr, "skipping hardware keys")
86+
}
87+
for _, pivIdent := range pivIdents {
88+
idents = append(idents, &pivIdent)
89+
}
90+
91+
storeIdents, err := store.Identities()
8492
if err != nil {
8593
return errors.Wrap(err, "failed to get identities from certificate store")
8694
}
95+
for _, ident := range storeIdents {
96+
idents = append(idents, ident)
97+
}
98+
8799
for _, ident := range idents {
88100
defer ident.Close()
89101
}

pinentry/pinentry.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package pinentry
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
)
11+
12+
// Pinentry gets the PIN from the user to access the smart card or hardware key
13+
type Pinentry struct {
14+
path string
15+
}
16+
17+
// NewPinentry initializes the pinentry program used to get the PIN
18+
func NewPinentry() (*Pinentry, error) {
19+
fromEnv := os.Getenv("SMIMESIGM_PINENTRY")
20+
if len(fromEnv) > 0 {
21+
pinentryFromEnv, err := exec.LookPath(fromEnv)
22+
if err == nil && len(pinentryFromEnv) > 0 {
23+
return &Pinentry{path: pinentryFromEnv}, nil
24+
}
25+
}
26+
27+
executables := pinentryPaths()
28+
for _, programName := range executables {
29+
pinentry, err := exec.LookPath(programName)
30+
if err == nil && len(pinentry) > 0 {
31+
return &Pinentry{path: pinentry}, nil
32+
}
33+
}
34+
35+
return nil, fmt.Errorf("failed to find suitable program to enter pin")
36+
}
37+
38+
// Get executes the pinentry program and returns the PIN entered by the user
39+
// see https://www.gnupg.org/documentation/manuals/assuan/Introduction.html for more details
40+
func (pin *Pinentry) Get(prompt string) (string, error) {
41+
cmd := exec.Command(pin.path)
42+
stdin, err := cmd.StdinPipe()
43+
if err != nil {
44+
return "", err
45+
}
46+
47+
stdout, err := cmd.StdoutPipe()
48+
if err != nil {
49+
return "", err
50+
}
51+
52+
err = cmd.Start()
53+
if err != nil {
54+
return "", err
55+
}
56+
57+
bufferReader := bufio.NewReader(stdout)
58+
lineBytes, _, err := bufferReader.ReadLine()
59+
if err != nil {
60+
return "", err
61+
}
62+
63+
line := string(lineBytes)
64+
if !strings.HasPrefix(line, "OK") {
65+
return "", fmt.Errorf("failed to initialize pinentry, got response: %v", line)
66+
}
67+
68+
terminal := os.Getenv("TERM")
69+
if len(terminal) > 0 {
70+
if ok := setOption(stdin, bufferReader, fmt.Sprintf("OPTION ttytype=%s\n", terminal)); !ok {
71+
return "", fmt.Errorf("failed to set ttytype")
72+
}
73+
}
74+
75+
if ok := setOption(stdin, bufferReader, fmt.Sprintf("OPTION ttyname=%v\n", tty())); !ok {
76+
return "", fmt.Errorf("failed to set ttyname")
77+
}
78+
79+
if ok := setOption(stdin, bufferReader, "SETPROMPT PIN:\n"); !ok {
80+
return "", fmt.Errorf("failed to set prompt")
81+
}
82+
if ok := setOption(stdin, bufferReader, "SETTITLE smimesign\n"); !ok {
83+
return "", fmt.Errorf("failed to set title")
84+
}
85+
if ok := setOption(stdin, bufferReader, fmt.Sprintf("SETDESC %s\n", prompt)); !ok {
86+
return "", fmt.Errorf("failed to set description")
87+
}
88+
89+
_, err = fmt.Fprint(stdin, "GETPIN\n")
90+
if err != nil {
91+
return "", err
92+
}
93+
94+
lineBytes, _, err = bufferReader.ReadLine()
95+
if err != nil {
96+
return "", err
97+
}
98+
99+
line = string(lineBytes)
100+
101+
_, err = fmt.Fprint(stdin, "BYE\n")
102+
if err != nil {
103+
return "", err
104+
}
105+
106+
if err = cmd.Wait(); err != nil {
107+
return "", err
108+
}
109+
110+
if !strings.HasPrefix(line, "D ") {
111+
return "", fmt.Errorf(line)
112+
}
113+
114+
return strings.TrimPrefix(line, "D "), nil
115+
}
116+
117+
func setOption(writer io.Writer, bufferedReader *bufio.Reader, option string) bool {
118+
_, err := fmt.Fprintf(writer, option)
119+
lineBytes, _, err := bufferedReader.ReadLine()
120+
if err != nil {
121+
return false
122+
}
123+
124+
line := string(lineBytes)
125+
if !strings.HasPrefix(line, "OK") {
126+
return false
127+
}
128+
return true
129+
}

pinentry/pinentry_darwin.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package pinentry
2+
3+
func pinentryPaths() []string {
4+
return []string{
5+
"pinentry-mac",
6+
"pinentry-curses",
7+
"pinentry",
8+
}
9+
}
10+
11+
func tty() string {
12+
return "/dev/tty"
13+
}

pinentry/pinentry_linux.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package pinentry
2+
3+
func pinentryPaths() []string {
4+
// there are many flavours for the GnuPG pinentry program for different linux distros
5+
// this is a non-exhaustive list of some common implementations
6+
return []string{
7+
"pinentry-gnome3",
8+
"pinentry-gtk",
9+
"pinentry-qy",
10+
"pinentry-tty",
11+
"pinentry",
12+
}
13+
}
14+
15+
func tty() string {
16+
return "/dev/tty"
17+
}

pinentry/pinentry_windows.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package pinentry
2+
3+
func pinentryPaths() []string {
4+
return []string{
5+
"pinentry-gtk-2.exe",
6+
"pinentry-qt4.exe",
7+
"pinentry-w32.exe",
8+
"pinentry.exe",
9+
}
10+
}
11+
12+
func tty() string {
13+
return "windows"
14+
}

piv_identity.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"crypto"
5+
"crypto/x509"
6+
"fmt"
7+
"io"
8+
9+
"github.com/github/certstore"
10+
"github.com/github/smimesign/pinentry"
11+
"github.com/go-piv/piv-go/piv"
12+
"github.com/pkg/errors"
13+
)
14+
15+
// PivIdentities enumerates identities stored in the signature slot inside hardware keys
16+
func PivIdentities() ([]PivIdentity, error) {
17+
cards, err := piv.Cards()
18+
if err != nil {
19+
return nil, err
20+
}
21+
var identities []PivIdentity
22+
for _, card := range cards {
23+
yk, err := piv.Open(card)
24+
if err != nil {
25+
continue
26+
}
27+
cert, err := yk.Certificate(piv.SlotSignature)
28+
if err != nil {
29+
continue
30+
}
31+
if cert != nil {
32+
ident := PivIdentity{card: card, yk: yk}
33+
identities = append(identities, ident)
34+
}
35+
}
36+
return identities, nil
37+
}
38+
39+
// PivIdentity is an entity identity stored in a hardware key PIV applet
40+
type PivIdentity struct {
41+
card string
42+
//pin string
43+
yk *piv.YubiKey
44+
}
45+
46+
var _ certstore.Identity = (*PivIdentity)(nil)
47+
var _ crypto.Signer = (*PivIdentity)(nil)
48+
49+
// Certificate implements the certstore.Identity interface
50+
func (ident *PivIdentity) Certificate() (*x509.Certificate, error) {
51+
return ident.yk.Certificate(piv.SlotSignature)
52+
}
53+
54+
// CertificateChain implements the certstore.Identity interface
55+
func (ident *PivIdentity) CertificateChain() ([]*x509.Certificate, error) {
56+
cert, err := ident.Certificate()
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
return []*x509.Certificate{cert}, nil
62+
}
63+
64+
// Signer implements the certstore.Identity interface
65+
func (ident *PivIdentity) Signer() (crypto.Signer, error) {
66+
return ident, nil
67+
}
68+
69+
// Delete implements the certstore.Identity interface
70+
func (ident *PivIdentity) Delete() error {
71+
panic("deleting identities on PIV applet is not supported")
72+
}
73+
74+
// Close implements the certstore.Identity interface
75+
func (ident *PivIdentity) Close() {
76+
_ = ident.yk.Close()
77+
}
78+
79+
// Public implements the crypto.Signer interface
80+
func (ident *PivIdentity) Public() crypto.PublicKey {
81+
cert, err := ident.Certificate()
82+
if err != nil {
83+
return nil
84+
}
85+
86+
return cert.PublicKey
87+
}
88+
89+
// Sign implements the crypto.Signer interface
90+
func (ident *PivIdentity) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
91+
entry, err := pinentry.NewPinentry()
92+
if err != nil {
93+
return nil, err
94+
}
95+
96+
pin, err := entry.Get(fmt.Sprintf("Enter PIN for \"%v\"", ident.card))
97+
if err != nil {
98+
return nil, err
99+
}
100+
private, err := ident.yk.PrivateKey(piv.SlotSignature, ident.Public(), piv.KeyAuth{
101+
PIN: pin,
102+
})
103+
if err != nil {
104+
return nil, errors.Wrap(err, "failed to get private key for signing")
105+
}
106+
107+
switch private.(type) {
108+
case *piv.ECDSAPrivateKey:
109+
return private.(*piv.ECDSAPrivateKey).Sign(rand, digest, opts)
110+
default:
111+
return nil, fmt.Errorf("invalid key type")
112+
}
113+
}

0 commit comments

Comments
 (0)