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") + } +}