From 1502d64d026b9cb1dacb7a3d90c4fa9d5abe7ab8 Mon Sep 17 00:00:00 2001 From: Yoav Amit Date: Mon, 17 May 2021 15:07:22 -0400 Subject: [PATCH] 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 --- command_sign.go | 9 ++- command_sign_test.go | 2 +- go.mod | 2 + go.sum | 6 ++ main.go | 14 +++- pinentry/pinentry.go | 128 +++++++++++++++++++++++++++++++++++ pinentry/pinentry_darwin.go | 9 +++ pinentry/pinentry_linux.go | 11 +++ pinentry/pinentry_windows.go | 10 +++ piv_identity.go | 113 +++++++++++++++++++++++++++++++ 10 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 pinentry/pinentry.go create mode 100644 pinentry/pinentry_darwin.go create mode 100644 pinentry/pinentry_linux.go create mode 100644 pinentry/pinentry_windows.go create mode 100644 piv_identity.go diff --git a/command_sign.go b/command_sign.go index 35e9e27..43ea829 100644 --- a/command_sign.go +++ b/command_sign.go @@ -23,11 +23,6 @@ func commandSign() error { return fmt.Errorf("could not find identity matching specified user-id: %s", *localUserOpt) } - // Git is looking for "\n[GNUPG:] SIG_CREATED ", meaning we need to print a - // line before SIG_CREATED. BEGIN_SIGNING seems appropraite. GPG emits this, - // though GPGSM does not. - sBeginSigning.emit() - cert, err := userIdent.Certificate() if err != nil { return errors.Wrap(err, "failed to get idenity certificate") @@ -60,6 +55,10 @@ func commandSign() error { if err = sd.Sign([]*x509.Certificate{cert}, signer); err != nil { return errors.Wrap(err, "failed to sign message") } + // Git is looking for "\n[GNUPG:] SIG_CREATED ", meaning we need to print a + // line before SIG_CREATED. BEGIN_SIGNING seems appropraite. GPG emits this, + // though GPGSM does not. + sBeginSigning.emit() if *detachSignFlag { sd.Detached() } diff --git a/command_sign_test.go b/command_sign_test.go index 85c9c4d..84364f8 100644 --- a/command_sign_test.go +++ b/command_sign_test.go @@ -4,8 +4,8 @@ import ( "crypto/x509" "testing" - "github.com/github/ietf-cms/protocol" "github.com/github/ietf-cms" + "github.com/github/ietf-cms/protocol" "github.com/stretchr/testify/require" ) diff --git a/go.mod b/go.mod index 514abfd..e1d79f8 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/github/certstore v0.1.0 github.com/github/fakeca v0.1.0 github.com/github/ietf-cms v0.1.0 + github.com/go-piv/piv-go v1.7.0 // indirect github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b github.com/pkg/errors v0.8.1 github.com/pmezard/go-difflib v1.0.0 github.com/stretchr/testify v1.3.0 golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 + golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect ) diff --git a/go.sum b/go.sum index cf43f3c..9f530b2 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/github/ietf-cms v0.1.0 h1:D+O9re6xDeWTYRpAFTfM0dm5NqJUcXZKFGOQg5Iq6Ls= github.com/github/ietf-cms v0.1.0/go.mod h1:eJEmhqWUqjpuS6OoXiqtuTmzOx4u81npQrXOzt/sPqo= +github.com/go-piv/piv-go v1.7.0 h1:rfjdFdASfGV5KLJhSjgpGJ5lzVZVtRWn8ovy/H9HQ/U= +github.com/go-piv/piv-go v1.7.0/go.mod h1:ON2WvQncm7dIkCQ7kYJs+nc3V4jHGfrrJnSF8HKy7Gk= github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b h1:K1wa7ads2Bu1PavI6LfBRMYSy6Zi+Rky0OhWBfrmkmY= github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 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 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index 8d57535..240a298 100644 --- a/main.go +++ b/main.go @@ -80,10 +80,22 @@ func runCommand() error { defer store.Close() // Get list of identities - idents, err = store.Identities() + pivIdents, err := PivIdentities() + if err != nil { + fmt.Fprintln(os.Stderr, "skipping hardware keys") + } + for _, pivIdent := range pivIdents { + idents = append(idents, &pivIdent) + } + + storeIdents, err := store.Identities() if err != nil { return errors.Wrap(err, "failed to get identities from certificate store") } + for _, ident := range storeIdents { + idents = append(idents, ident) + } + for _, ident := range idents { defer ident.Close() } diff --git a/pinentry/pinentry.go b/pinentry/pinentry.go new file mode 100644 index 0000000..d54ef19 --- /dev/null +++ b/pinentry/pinentry.go @@ -0,0 +1,128 @@ +package pinentry + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// Pinentry gets the PIN from the user to access the smart card or hardware key +type Pinentry struct { + path string +} + +// NewPinentry initializes the pinentry program used to get the PIN +func NewPinentry() (*Pinentry, error) { + fromEnv := os.Getenv("SMIMESIGM_PINENTRY") + if len(fromEnv) > 0 { + pinentryFromEnv, err := exec.LookPath(fromEnv) + if err == nil && len(pinentryFromEnv) > 0 { + return &Pinentry{path: pinentryFromEnv}, nil + } + } + + for _, programName := range paths { + pinentry, err := exec.LookPath(programName) + if err == nil && len(pinentry) > 0 { + return &Pinentry{path: pinentry}, nil + } + } + + return nil, fmt.Errorf("failed to find suitable program to enter pin") +} + +// Get executes the pinentry program and returns the PIN entered by the user +// see https://www.gnupg.org/documentation/manuals/assuan/Introduction.html for more details +func (pin *Pinentry) Get(prompt string) (string, error) { + cmd := exec.Command(pin.path) + stdin, err := cmd.StdinPipe() + if err != nil { + return "", err + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + + err = cmd.Start() + if err != nil { + return "", err + } + + bufferReader := bufio.NewReader(stdout) + lineBytes, _, err := bufferReader.ReadLine() + if err != nil { + return "", err + } + + line := string(lineBytes) + if !strings.HasPrefix(line, "OK") { + return "", fmt.Errorf("failed to initialize pinentry, got response: %v", line) + } + + terminal := os.Getenv("TERM") + if len(terminal) > 0 { + if ok := setOption(stdin, bufferReader, fmt.Sprintf("OPTION ttytype=%s\n", terminal)); !ok { + return "", fmt.Errorf("failed to set ttytype") + } + } + + if ok := setOption(stdin, bufferReader, fmt.Sprintf("OPTION ttyname=%v\n", tty)); !ok { + return "", fmt.Errorf("failed to set ttyname") + } + + if ok := setOption(stdin, bufferReader, "SETPROMPT PIN:\n"); !ok { + return "", fmt.Errorf("failed to set prompt") + } + if ok := setOption(stdin, bufferReader, "SETTITLE smimesign\n"); !ok { + return "", fmt.Errorf("failed to set title") + } + if ok := setOption(stdin, bufferReader, fmt.Sprintf("SETDESC %s\n", prompt)); !ok { + return "", fmt.Errorf("failed to set description") + } + + _, err = fmt.Fprint(stdin, "GETPIN\n") + if err != nil { + return "", err + } + + lineBytes, _, err = bufferReader.ReadLine() + if err != nil { + return "", err + } + + line = string(lineBytes) + + _, err = fmt.Fprint(stdin, "BYE\n") + if err != nil { + return "", err + } + + if err = cmd.Wait(); err != nil { + return "", err + } + + if !strings.HasPrefix(line, "D ") { + return "", fmt.Errorf(line) + } + + return strings.TrimPrefix(line, "D "), nil +} + +func setOption(writer io.Writer, bufferedReader *bufio.Reader, option string) bool { + _, err := fmt.Fprintf(writer, option) + lineBytes, _, err := bufferedReader.ReadLine() + if err != nil { + return false + } + + line := string(lineBytes) + if !strings.HasPrefix(line, "OK") { + return false + } + return true +} diff --git a/pinentry/pinentry_darwin.go b/pinentry/pinentry_darwin.go new file mode 100644 index 0000000..599f3a0 --- /dev/null +++ b/pinentry/pinentry_darwin.go @@ -0,0 +1,9 @@ +package pinentry + +var paths = []string{ + "pinentry-mac", + "pinentry-curses", + "pinentry", +} + +const tty = "/dev/tty" diff --git a/pinentry/pinentry_linux.go b/pinentry/pinentry_linux.go new file mode 100644 index 0000000..982fdbe --- /dev/null +++ b/pinentry/pinentry_linux.go @@ -0,0 +1,11 @@ +package pinentry + +var paths = []string{ + "pinentry-gnome3", + "pinentry-gtk", + "pinentry-qy", + "pinentry-tty", + "pinentry", +} + +const tty = "/dev/tty" diff --git a/pinentry/pinentry_windows.go b/pinentry/pinentry_windows.go new file mode 100644 index 0000000..6674877 --- /dev/null +++ b/pinentry/pinentry_windows.go @@ -0,0 +1,10 @@ +package pinentry + +var paths = []string{ + "pinentry-gtk-2.exe", + "pinentry-qt4.exe", + "pinentry-w32.exe", + "pinentry.exe", +} + +const tty = "windows" diff --git a/piv_identity.go b/piv_identity.go new file mode 100644 index 0000000..6165318 --- /dev/null +++ b/piv_identity.go @@ -0,0 +1,113 @@ +package main + +import ( + "crypto" + "crypto/x509" + "fmt" + "io" + + "github.com/github/certstore" + "github.com/github/smimesign/pinentry" + "github.com/go-piv/piv-go/piv" + "github.com/pkg/errors" +) + +// PivIdentities enumerates identities stored in the signature slot inside hardware keys +func PivIdentities() ([]PivIdentity, error) { + cards, err := piv.Cards() + if err != nil { + return nil, err + } + var identities []PivIdentity + for _, card := range cards { + yk, err := piv.Open(card) + if err != nil { + continue + } + cert, err := yk.Certificate(piv.SlotSignature) + if err != nil { + continue + } + if cert != nil { + ident := PivIdentity{card: card, yk: yk} + identities = append(identities, ident) + } + } + return identities, nil +} + +// PivIdentity is an entity identity stored in a hardware key PIV applet +type PivIdentity struct { + card string + //pin string + yk *piv.YubiKey +} + +var _ certstore.Identity = (*PivIdentity)(nil) +var _ crypto.Signer = (*PivIdentity)(nil) + +// Certificate implements the certstore.Identity interface +func (ident *PivIdentity) Certificate() (*x509.Certificate, error) { + return ident.yk.Certificate(piv.SlotSignature) +} + +// CertificateChain implements the certstore.Identity interface +func (ident *PivIdentity) CertificateChain() ([]*x509.Certificate, error) { + cert, err := ident.Certificate() + if err != nil { + return nil, err + } + + return []*x509.Certificate{cert}, nil +} + +// Signer implements the certstore.Identity interface +func (ident *PivIdentity) Signer() (crypto.Signer, error) { + return ident, nil +} + +// Delete implements the certstore.Identity interface +func (ident *PivIdentity) Delete() error { + panic("deleting identities on PIV applet is not supported") +} + +// Close implements the certstore.Identity interface +func (ident *PivIdentity) Close() { + _ = ident.yk.Close() +} + +// Public implements the crypto.Signer interface +func (ident *PivIdentity) Public() crypto.PublicKey { + cert, err := ident.Certificate() + if err != nil { + return nil + } + + return cert.PublicKey +} + +// Sign implements the crypto.Signer interface +func (ident *PivIdentity) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + entry, err := pinentry.NewPinentry() + if err != nil { + return nil, err + } + + pin, err := entry.Get(fmt.Sprintf("Enter PIN for \"%v\"", ident.card)) + if err != nil { + return nil, err + } + private, err := ident.yk.PrivateKey(piv.SlotSignature, ident.Public(), piv.KeyAuth{ + PIN: pin, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to get private key for signing") + } + + switch private.(type) { + case *piv.ECDSAPrivateKey: + return private.(*piv.ECDSAPrivateKey).Sign(rand, digest, opts) + default: + return nil, fmt.Errorf("invalid key type") + } +}