diff --git a/.goreleaser.ubuntu-latest.yml b/.goreleaser.ubuntu-latest.yml index cdfa692..561d2a5 100644 --- a/.goreleaser.ubuntu-latest.yml +++ b/.goreleaser.ubuntu-latest.yml @@ -1,3 +1,8 @@ +archives: +- files: + - deploy/* + - LICENSE + - README.md builds: - dir: cmd/piv-agent goos: diff --git a/Makefile b/Makefile index 2852255..6d04b88 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,15 @@ +.PHONY: build +build: test + go build ./cmd/piv-agent + +.PHONY: test test: mod-tidy generate go test -v ./... -mod-tidy: +.PHONY: mod-tidy +mod-tidy: generate go mod tidy +.PHONY: generate generate: go generate ./... diff --git a/README.md b/README.md index 031ed2b..c29d904 100644 --- a/README.md +++ b/README.md @@ -5,78 +5,143 @@ ## About -An SSH agent which you can use with your PIV smartcard / security key. +* `piv-agent` is a replacement for `ssh-agent` and `gpg-agent` which you can use with your smartcard or security key that implements [PIV](https://csrc.nist.gov/projects/piv/piv-standards-and-supporting-documentation) (e.g. a [Yubikey](https://developers.yubico.com/yubico-piv-tool/YubiKey_PIV_introduction.html)). +* `piv-agent` originated as a reimplementation of [yubikey-agent](https://github.com/FiloSottile/yubikey-agent) because I wanted a couple of extra features and also to gain a better understanding of the PIV applet on security keys, and the Go [`x/crypto/ssh/agent`](https://pkg.go.dev/golang.org/x/crypto/ssh/agent) package. It has since grown in features (good) and complexity (bad). +* `piv-agent` is built on Go standard library and supplementary `crypto` packages, as well as [`piv-go`](https://github.com/go-piv/piv-go/) and [`pcsclite`](https://pcsclite.apdu.fr/). Thanks for the great software! -`piv-agent` is based almost entirely on ideas and cryptography from https://github.com/FiloSottile/yubikey-agent. +--- +**DISCLAIMER** -**IMPORTANT NOTE**: I am not a cryptographer and I make no assertion about the security or otherwise of this software. +I make no assertion about the security or otherwise of this software and I am not a cryptographer. +If you are, please take a look at the code and send PRs or issues. :green_heart: -### What is wrong with yubikey-agent? +--- -Nothing! -I just wanted to gain a better understanding of how the PIV applet on security keys works, and how the Go ssh-agent library works. -I also added a couple of features that I wanted that yubikey-agent lacks, such as: +### Some features of piv-agent +* implements both `ssh-agent` and `gpg-agent` functionality * support for multiple security keys * support for multiple slots in those keys * support for multiple touch policies -* a way to list existing SSH keys -* support loading key files from disk +* list existing keys on a security key in SSH and OpenPGP format * socket activation (systemd-compatible) - * as a result, automatically drop the transaction on the security key after some period of disuse + * as a result, automatically drop the transaction on the security key and cached passphrases after some period of disuse +* provides "fall-back" to traditional SSH and OpenPGP keyfiles -### Philosophy +### Design philosophy This agent should require no interaction and in general do the right thing when security keys are plugged/unplugged, laptop is power cycled, etc. It is highly opinionated: -* Only supports elliptic curve crypto * Only supports 256-bit EC keys on hardware tokens -* Only supports ed25519 ssh keys on disk (`~/.ssh/id_ed25519`) -* Assumes socket activation +* Only supports ed25519 SSH keys on disk (`~/.ssh/id_ed25519`) +* Requires socket activation + +It makes some concession to practicality with OpenPGP: + +* Supports RSA signing and decryption for OpenPGP keyfiles. + RSA OpenPGP keys are widespread and Debian in particular [only documents RSA keys](https://wiki.debian.org/Keysigning). + +It tries to strike a balance between security and usability: + +* Takes a persistent transaction on the hardware token, effectively caching the PIN. +* Caches passphrases for on-disk keys (i.e. `~/.ssh/id_ed25519`) in memory, so these only need to be provided once after the agent starts. +* After a period of inactivity (32 minutes by default) it exits, dropping both of these. + Socket activation restarts it automatically as required. ### Hardware support Tested with: -* YubiKey 5C, firmware 5.2.4 +* [YubiKey 5C](https://www.yubico.com/au/product/yubikey-5c/), firmware 5.2.4 + +Will be tested with (once it ships!): + +* [Solo V2](https://www.kickstarter.com/projects/conorpatrick/solo-v2-safety-net-against-phishing/) + +Any device implementing the SCard API (PC/SC), and supported by [`piv-go`](https://github.com/go-piv/piv-go/) / [`pcsclite`](https://pcsclite.apdu.fr/) may work. +If you have tested another device with `piv-agent` successfully, please send a PR adding it to this list. ### Platform support -Currently tested on Linux and systemd. -The macOS binaries built for releases are experimental, and not tested. +Currently tested on Linux with `systemd`. + +If you have a Mac, I'd love to add support for `launchd` socket activation. See issue https://github.com/smlx/piv-agent/issues/12. + +### Protocol / Encryption Algorithm support + +| Supported | Not Supported | Support Planned (maybe) | +| --- | --- | --- | +| ✅ | ❌ | ⏳ | + +#### ssh-agent + +| | Security Key | Keyfile | +| --- | --- | --- | +| ecdsa-sha2-nistp256 | ✅ | ❌ | +| ssh-ed25519 | ⏳ | ✅ | + + +#### gpg-agent + +| | Security Key | Keyfile | +| --- | --- | --- | +| ECDSA Sign | ✅ | ✅ | +| ECDH Encrypt | ⏳ | ❌ | +| ECDH Decrypt | ⏳ | ❌ | +| RSA Sign | ❌ | ✅ | +| RSA Encrypt | ❌ | ❌ | +| RSA Decrypt | ❌ | ✅ | ## Install ### Prerequisites -`piv-agent` uses [`piv-go`](https://github.com/go-piv/piv-go#installation), so has dependencies on [`pcsclite`](https://pcsclite.apdu.fr/). +#### Consider redundancy + +It is important to understand that if you lose access to your security key there is no way to regain the keys stored on it. +For that reason it is highly recommended that you use fallback keyfiles with `piv-agent`. + +#### Install pcsclite + +`piv-agent` has transitive dependencies through [`piv-go`](https://github.com/go-piv/piv-go#installation), on [`pcsclite`](https://pcsclite.apdu.fr/). ``` -# debian/ubuntu -sudo apt install pcscd +# debian / ubuntu +sudo apt install libpcsclite1 ``` -### `piv-agent` - -`piv-agent` currently requires systemd socket activation. -Similar configuration may be possible on macOS (see [issue #12](https://github.com/smlx/piv-agent/issues/12)) or other systems. PRs welcome! +### piv-agent -`piv-agent.service` looks for `$HOME/go/bin/piv-agent` by default. -If the binary is in a different location you'll have to edit the service file. +Download the latest [release](https://github.com/smlx/piv-agent/releases), and extract it to a temporary location. +Copy the `piv-agent` binary into your `$PATH`, and the systemd unit files to the correct location: ``` cp deploy/piv-agent.{socket,service} ~/.config/systemd/user/ systemctl --user daemon-reload -systemctl --user enable --now piv-agent.socket ``` -## Set up security key +--- +**NOTE** + +`ssh-agent` and `gpg-agent` functionality are enabled by default. +Edit the systemd unit files to disable one or the other. + +--- + +## Setup + +### Hardware + +--- +**NOTE** + +This procedure is only required once per security key, and wipes any existing keys from PIV slots. -IMPORTANT NOTE: This procedure generally is only required once per security key, and wipes any existing keys from PIV slots. +--- -By default, `piv-agent` uses three slots on your security key to set up keys with different touch policies: never required, cached (required once per transaction), and always. +By default, `piv-agent` uses three slots on your security key to set up keys with different [touch policies](https://docs.yubico.com/yesdk/users-manual/application-piv/pin-touch-policies.html): never required, cached (for 15 seconds), and always. ``` # find the name of the security keys (cards) @@ -87,11 +152,20 @@ piv-agent setup --pin=123456 --card='Yubico YubiKey FIDO+CCID 01 00' --reset-sec piv-agent list ``` -## Use +### SSH + +#### List keys -Generally, add the SSH key from the security token(s) _and_ the your key file SSH key to all services for redundancy. +List your hardware SSH keys: -### Set `SSH_AUTH_SOCK` +``` +piv-agent list +``` + +Add the SSH key with the touch policy you want from the list, to any SSH service. +It's a good idea to generate an `ed25519` keyfile and add that to all SSH services too for redundancy. + +#### Set `SSH_AUTH_SOCK` Export the `SSH_AUTH_SOCK` variable in your shell. @@ -99,41 +173,116 @@ Export the `SSH_AUTH_SOCK` variable in your shell. export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/piv-agent/ssh.socket ``` -### Prefer the SSH keys on the hardware token +#### Prefer keys on the security key By default, `ssh` will offer [keyfiles it finds on disk](https://manpages.debian.org/testing/openssh-client/ssh_config.5.en.html#IdentityFile) _before_ those from the agent. This is a problem because `piv-agent` is designed to offer keys from the hardware token first, and only fall back to local keyfiles if token keys are refused. -To get `ssh` to ignore local keyfiles and only talk to `piv-agent`, add this line to your `ssh_config`. +To get `ssh` to ignore local keyfiles and only talk to `piv-agent`, add this line to your `ssh_config`, for all hosts: ``` IdentityFile /dev/null ``` -### PIN / Passphrase caching +### GPG + +#### Import public keys + +`gpg` requires public keys to be imported for any private keys stored by the agent, so the `list` command will synthesize a public key based on the private key stored on the hardware. +This public key contains a [User ID packet](https://datatracker.ietf.org/doc/html/rfc4880#section-5.11), which must be signed by the private key, so: + +* you should provide a name and email which will be embedded in the synthesized public key +* `list --key-formats=gpg` requires a touch of the security key to perform signing on the keys associated with those slots + +``` +piv-agent list --key-formats=ssh,gpg --pgp-name='Scott Leggett' --pgp-email='scott@sl.id.au' +``` + +Paste these public keys into a `key.asc` file, and run `gpg --import key.asc`. + +#### Export fallback keys + +--- +**NOTE** + +This step requires `gpg-agent` to be running, not `piv-agent`. + +--- + +Private keys to be used by `piv-agent` must be exported to `~/.gnupg/piv-agent.secring/`: + +``` +# set umask for user-only permissions +umask 77 +mkdir -p ~/.gnupg/piv-agent.secring +gpg --export-secret-key 0xB346A434C7652C02 > ~/.gnupg/piv-agent.secring/key@example.com.gpg +``` + +#### Disable gpg-agent + +Because `piv-agent` takes over the role of `gpg-agent`, the latter should be disabled: + +* Add `no-autostart` to `~/.gnupg/gpg.conf`. +* `systemctl --user disable --now gpg-agent.socket gpg-agent.service; pkill gpg-agent` + +## Use -`piv-agent` is designed to minimise the need to store secret keys permanently in memory while also being highly usable: +Start the agent sockets, and test: -* it takes a persistent transaction on the hardware token, effectively caching the PIN. -* it also caches passphrases for on-disk keys (i.e. `~/.ssh/id_ed25519`). +``` +systemctl --user enable --now piv-agent.socket +ssh-add -l +gpg -K +``` -After a period of inactivity (32 min by default) it exits, dropping both of these. -Socket activation restarts it automatically as required. +#### PIN / Passphrase caching -If your pinentry supports storing credentials I recommend storing the PIN, but not the passphrase, as a decent usability/security tradeoff. +If your pinentry supports storing credentials I recommend storing the PIN of the security key, but not the passphrase of any fallback keys, as a decent usability/security tradeoff. This ensures that at least the encrypted key file and its passphrase aren't stored together. -It also has the advantage of ensuring that you don't forget your passphrase. +It also has the advantage of ensuring that you don't forget your keyfile passphrase. But you might forget your PIN, so maybe don't store that either if you're concerned about that possibility? 🤷 -## Build and Test +#### Add Security Key as a OpenPGP signing subkey + +--- +**NOTE** + +There is currently a [bug](https://dev.gnupg.org/T5555) in GnuPG which doesn't allow ECDSA keys to be added as subkeys correctly. +For now you need to apply the patch described in the bug report to work around this limitation. + +--- + +Adding a `piv-agent` OpenPGP key as a signing subkey of an existing OpenPGP key is a convenient way to integrate a physical Security Key with your existing `gpg` workflow. +This allows you to do things like sign `git` commits using your Yubikey, while keeping the same OpenPGP key ID. +Adding a subkey requires cross-signing, so you need to export the master secret key of your existing OpenPGP key as described above to make it available to `piv-agent`. +There are instructions for adding an existing key as a subkey [here](https://security.stackexchange.com/a/160847). + +--- +**NOTE** + +`gpg` will choose the _newest_ available subkey to perform an action. So it will automatically prefer a newly added `piv-agent` subkey over any existing keyfile subkeys, but fall back to keyfiles if e.g. the Yubikey is not plugged in. -`piv-agent` has dependencies through [`piv-go`](https://github.com/go-piv/piv-go#installation). +--- + +## Develop + +### Prerequisites + +Install build dependencies: ``` # debian/ubuntu -sudo apt install libpcsclite-dev pcscd +sudo apt install libpcsclite-dev ``` -The dbus variable is required for `pinentry` to use a graphical prompt. +### Build and test + +``` +make +``` + +### Build and test manually + +This D-Bus variable is required for `pinentry` to use a graphical prompt: ``` go build ./cmd/piv-agent && systemd-socket-activate -l /tmp/piv-agent.sock -E DBUS_SESSION_BUS_ADDRESS ./piv-agent serve --debug diff --git a/cmd/piv-agent/list.go b/cmd/piv-agent/list.go index f19154c..969874b 100644 --- a/cmd/piv-agent/list.go +++ b/cmd/piv-agent/list.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/smlx/piv-agent/internal/pivservice" + "github.com/smlx/piv-agent/internal/keyservice/piv" "go.uber.org/zap" ) @@ -17,7 +17,7 @@ type ListCmd struct { // Run the list command. func (cmd *ListCmd) Run(l *zap.Logger) error { - p := pivservice.New(l) + p := piv.New(l) securityKeys, err := p.SecurityKeys() if err != nil { return fmt.Errorf("couldn't get security keys: %w", err) diff --git a/cmd/piv-agent/main.go b/cmd/piv-agent/main.go index 6afbd3c..80e2c79 100644 --- a/cmd/piv-agent/main.go +++ b/cmd/piv-agent/main.go @@ -28,7 +28,7 @@ func main() { var log *zap.Logger var err error if cli.Debug { - log, err = zap.NewDevelopment() + log, err = zap.NewDevelopment(zap.AddStacktrace(zap.ErrorLevel)) } else { log, err = zap.NewProduction() } diff --git a/cmd/piv-agent/serve.go b/cmd/piv-agent/serve.go index 51cf72c..cd66c97 100644 --- a/cmd/piv-agent/serve.go +++ b/cmd/piv-agent/serve.go @@ -3,10 +3,13 @@ package main import ( "context" "fmt" + "os" + "path/filepath" "time" "github.com/coreos/go-systemd/activation" - "github.com/smlx/piv-agent/internal/pivservice" + "github.com/smlx/piv-agent/internal/keyservice/piv" + "github.com/smlx/piv-agent/internal/pinentry" "github.com/smlx/piv-agent/internal/server" "github.com/smlx/piv-agent/internal/ssh" "go.uber.org/zap" @@ -45,7 +48,7 @@ func (flagAgents *agentTypeFlag) AfterApply() error { func (cmd *ServeCmd) Run(log *zap.Logger) error { log.Info("startup", zap.String("version", version), zap.String("build date", date)) - p := pivservice.New(log) + p := piv.New(log) // use systemd socket activation ls, err := activation.Listeners() if err != nil { @@ -72,10 +75,16 @@ func (cmd *ServeCmd) Run(log *zap.Logger) error { return err }) } + // start GPG agent if given in agent-type flag + home, err := os.UserHomeDir() + if err != nil { + log.Warn("couldn't determine $HOME", zap.Error(err)) + } + fallbackKeys := filepath.Join(home, ".gnupg", "piv-agent.secring") if _, ok := cmd.AgentTypes["gpg"]; ok { log.Debug("starting GPG server") g.Go(func() error { - s := server.NewGPG(p, log) + s := server.NewGPG(p, &pinentry.PINEntry{}, log, fallbackKeys) err := s.Serve(ctx, ls[cmd.AgentTypes["gpg"]], exit, cmd.ExitTimeout) if err != nil { log.Debug("exiting GPG server", zap.Error(err)) diff --git a/deploy/piv-agent.service b/deploy/piv-agent.service index ceb88f9..4a644d3 100644 --- a/deploy/piv-agent.service +++ b/deploy/piv-agent.service @@ -2,4 +2,4 @@ Description=piv-agent service [Service] -ExecStart=%h/go/bin/piv-agent serve --debug +ExecStart=piv-agent serve --debug --agent-types=ssh=0;gpg=1 diff --git a/deploy/piv-agent.socket b/deploy/piv-agent.socket index 5d18c7b..3487545 100644 --- a/deploy/piv-agent.socket +++ b/deploy/piv-agent.socket @@ -1,8 +1,9 @@ [Unit] -Description=piv-agent socket +Description=piv-agent socket activation [Socket] ListenStream=%t/piv-agent/ssh.socket +ListenStream=%t/gnupg/S.gpg-agent [Install] WantedBy=sockets.target diff --git a/go.mod b/go.mod index 9b6fd6b..5d47ed2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.15 require ( github.com/alecthomas/kong v0.2.17 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + github.com/davecgh/go-spew v1.1.1 github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28 github.com/go-piv/piv-go v1.8.0 github.com/golang/mock v1.6.0 diff --git a/internal/assuan/assuan.go b/internal/assuan/assuan.go index 22c90e5..31c0389 100644 --- a/internal/assuan/assuan.go +++ b/internal/assuan/assuan.go @@ -3,44 +3,40 @@ package assuan //go:generate mockgen -source=assuan.go -destination=../mock/mock_assuan.go -package=mock import ( + "bufio" "bytes" "crypto" - "crypto/ecdsa" - "crypto/rand" "encoding/hex" "fmt" "io" - "math/big" + "regexp" "strconv" "strings" "github.com/smlx/fsm" - "github.com/smlx/piv-agent/internal/gpg" - "github.com/smlx/piv-agent/internal/notify" - "github.com/smlx/piv-agent/internal/pivservice" - "golang.org/x/crypto/cryptobyte" - "golang.org/x/crypto/cryptobyte/asn1" + "go.uber.org/zap" + "golang.org/x/crypto/openpgp/s2k" ) -// The PIVService interface provides PIV functions used by the Assuan FSM. -type PIVService interface { - SecurityKeys() ([]pivservice.SecurityKey, error) +// The KeyService interface provides functions used by the Assuan FSM. +type KeyService interface { + Name() string + HaveKey([][]byte) (bool, []byte, error) + GetSigner([]byte) (crypto.Signer, error) + GetDecrypter([]byte) (crypto.Decrypter, error) } -// hashFunction maps the code used by assuan to the relevant hash function. -var hashFunction = map[uint64]crypto.Hash{ - 8: crypto.SHA256, - 10: crypto.SHA512, -} +var ciphertextRegex = regexp.MustCompile( + `^D \(7:enc-val\(3:rsa\(1:a(\d+):(.+)\)\)\)$`) // New initialises a new gpg-agent server assuan FSM. // It returns a *fsm.Machine configured in the ready state. -func New(w io.Writer, p PIVService) *Assuan { - var err error +func New(rw io.ReadWriter, log *zap.Logger, ks ...KeyService) *Assuan { var keyFound bool - var keygrip, signature []byte + var signature []byte var keygrips, hash [][]byte assuan := Assuan{ + reader: bufio.NewReader(rw), Machine: fsm.Machine{ State: fsm.State(ready), Transitions: assuanTransitions, @@ -49,23 +45,25 @@ func New(w io.Writer, p PIVService) *Assuan { assuan.OnEntry = map[fsm.State][]fsm.TransitionFunc{ fsm.State(connected): { func(e fsm.Event, _ fsm.State) error { + var err error switch Event(e) { case connect: // acknowledge connection using the format expected by the client - _, err = io.WriteString(w, + _, err = io.WriteString(rw, "OK Pleased to meet you, process 123456789\n") case reset: - assuan.signingPrivKey = nil + assuan.signer = nil + assuan.decrypter = nil assuan.hashAlgo = 0 assuan.hash = []byte{} - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") case option: // ignore option values - piv-agent doesn't use them - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") case getinfo: if bytes.Equal(assuan.data[0], []byte("version")) { // masquerade as a compatible gpg-agent - _, err = io.WriteString(w, "D 2.2.27\nOK\n") + _, err = io.WriteString(rw, "D 2.2.27\nOK\n") } else { err = fmt.Errorf("unknown getinfo command: %q", assuan.data[0]) } @@ -77,43 +75,61 @@ func New(w io.Writer, p PIVService) *Assuan { if err != nil { return fmt.Errorf("couldn't decode keygrips: %v", err) } - keyFound, _, err = haveKey(p, keygrips) + keyFound, _, err = haveKey(ks, keygrips) if err != nil { - _, _ = io.WriteString(w, "ERR 1 couldn't check for keygrip\n") - return fmt.Errorf("couldn't check for keygrip: %v", err) + _, _ = io.WriteString(rw, "ERR 1 couldn't check for keygrip\n") + return fmt.Errorf("couldn't check keygrips: %v", err) } if keyFound { - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") } else { - _, err = io.WriteString(w, "No_Secret_Key\n") + _, err = io.WriteString(rw, "No_Secret_Key\n") } case keyinfo: - // KEYINFO arguments are a list of keygrips - // if _any_ key is available, we return OK, otherwise - // No_Secret_Key. + err = doKeyinfo(rw, assuan.data, ks) + case scd: + // ignore scdaemon requests + _, err = io.WriteString(rw, "ERR 100696144 No such device \n") + case readkey: + // READKEY argument is a keygrip + // return information about the given key keygrips, err = hexDecode(assuan.data...) if err != nil { return fmt.Errorf("couldn't decode keygrips: %v", err) } - keyFound, keygrip, err = haveKey(p, keygrips) - if err != nil { - _, _ = io.WriteString(w, "ERR 1 couldn't check for keygrip\n") - return fmt.Errorf("couldn't check for keygrip: %v", err) + var signer crypto.Signer + for _, k := range ks { + signer, err = k.GetSigner(keygrips[0]) + if err == nil { + break + } } - if keyFound { - _, err = io.WriteString(w, - fmt.Sprintf("S KEYINFO %s D - - - P - - -\nOK\n", - strings.ToUpper(hex.EncodeToString(keygrip)))) - } else { - _, err = io.WriteString(w, "No_Secret_Key\n") + if signer == nil { + _, _ = io.WriteString(rw, "ERR 1 couldn't match keygrip\n") + return fmt.Errorf("couldn't match keygrip: %v", err) + } + var data string + data, err = readKeyData(signer.Public()) + if err != nil { + _, _ = io.WriteString(rw, "ERR 1 couldn't get key data\n") + return fmt.Errorf("couldn't get key data: %v", err) } + _, err = io.WriteString(rw, data) + case setkeydesc: + // ignore this event since we don't currently use the client's + // description in the prompt + _, err = io.WriteString(rw, "OK\n") + case passwd: + // ignore this event since we assume that if the key is decrypted the + // user has permissions + _, err = io.WriteString(rw, "OK\n") default: return fmt.Errorf("unknown event: %v", e) } return err }, }, - fsm.State(keyIsSet): { + fsm.State(signingKeyIsSet): { func(e fsm.Event, _ fsm.State) error { var err error switch Event(e) { @@ -124,16 +140,21 @@ func New(w io.Writer, p PIVService) *Assuan { if err != nil { return fmt.Errorf("couldn't decode keygrips: %v", err) } - assuan.signingPrivKey, err = getKey(p, keygrips[0]) + for _, k := range ks { + assuan.signer, err = k.GetSigner(keygrips[0]) + if err == nil { + break + } + } if err != nil { - _, _ = io.WriteString(w, "ERR 1 couldn't get key from keygrip\n") - return fmt.Errorf("couldn't get key from keygrip: %v", err) + _, _ = io.WriteString(rw, "ERR 1 couldn't get key for keygrip\n") + return fmt.Errorf("couldn't get key for keygrip: %v", err) } - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") case setkeydesc: // ignore this event since we don't currently use the client's // description in the prompt - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") default: return fmt.Errorf("unknown event: %v", Event(e)) } @@ -151,29 +172,124 @@ func New(w io.Writer, p PIVService) *Assuan { if err != nil { return fmt.Errorf("couldn't parse uint %s: %v", assuan.data[0], err) } - if assuan.hashAlgo = hashFunction[n]; assuan.hashAlgo == 0 { - return fmt.Errorf("invalid hash algorithm value: %v", n) + var ok bool + if assuan.hashAlgo, ok = s2k.HashIdToHash(byte(n)); !ok { + return fmt.Errorf("invalid hash algorithm value: %x", n) } hash, err = hexDecode(assuan.data[1:]...) if err != nil { return fmt.Errorf("couldn't decode hash: %v", err) } assuan.hash = hash[0] - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") case pksign: signature, err = assuan.sign() if err != nil { return fmt.Errorf("couldn't sign: %v", err) } - _, err = w.Write(signature) + _, err = rw.Write(signature) if err != nil { return fmt.Errorf("couldn't write signature: %v", err) } - _, err = io.WriteString(w, "\n") + _, err = io.WriteString(rw, "\n") if err != nil { return fmt.Errorf("couldn't write newline: %v", err) } - _, err = io.WriteString(w, "OK\n") + _, err = io.WriteString(rw, "OK\n") + case keyinfo: + err = doKeyinfo(rw, assuan.data, ks) + default: + return fmt.Errorf("unknown event: %v", Event(e)) + } + return err + }, + }, + fsm.State(decryptingKeyIsSet): { + func(e fsm.Event, _ fsm.State) error { + var err error + switch Event(e) { + case setkey: + // SETKEY has a single argument: a keygrip indicating the key which + // will be used for subsequent decrypting operations + keygrips, err = hexDecode(assuan.data...) + if err != nil { + return fmt.Errorf("couldn't decode keygrips: %v", err) + } + for _, k := range ks { + assuan.decrypter, err = k.GetDecrypter(keygrips[0]) + if err == nil { + break + } + } + if err != nil { + _, _ = io.WriteString(rw, "ERR 1 couldn't get key for keygrip\n") + return fmt.Errorf("couldn't get key for keygrip: %v", err) + } + _, err = io.WriteString(rw, "OK\n") + case setkeydesc: + // ignore this event since we don't currently use the client's + // description in the prompt + _, err = io.WriteString(rw, "OK\n") + default: + return fmt.Errorf("unknown event: %v", Event(e)) + } + return err + }, + }, + fsm.State(waitingForCiphertext): { + func(e fsm.Event, _ fsm.State) error { + var err error + switch Event(e) { + case pkdecrypt: + // once we receive PKDECRYPT we enter a "reversed" state where the + // agent drives the client by sending commands. + // ask for ciphertext + _, err = io.WriteString(rw, + "S INQUIRE_MAXLEN 4096\nINQUIRE CIPHERTEXT\n") + if err != nil { + return err + } + var chunk []byte + var chunks [][]byte + scanner := bufio.NewScanner(assuan.reader) + for { + if !scanner.Scan() { + break + } + chunk = scanner.Bytes() + if bytes.Equal([]byte("END"), chunk) { + break // end of ciphertext + } + chunks = append(chunks, chunk) + } + if len(chunks) < 1 { + return fmt.Errorf("invalid ciphertext format") + } + sexp := bytes.Join(chunks[:], []byte("\n")) + matches := ciphertextRegex.FindAllSubmatch(sexp, -1) + var plaintext, ciphertext []byte + ciphertext = matches[0][2] + log.Debug("raw ciphertext", + zap.Binary("sexp", sexp), zap.Binary("ciphertext", ciphertext)) + // undo the buggy encoding sent by gpg + ciphertext = percentDecodeSExp(ciphertext) + log.Debug("normalised ciphertext", + zap.Binary("ciphertext", ciphertext)) + plaintext, err = assuan.decrypter.Decrypt(nil, ciphertext, nil) + if err != nil { + return fmt.Errorf("couldn't decrypt: %v", err) + } + // gnupg uses the pre-buggy-encoding length in the sexp + plaintextLen := len(plaintext) + // apply the buggy encoding as expected by gpg + plaintext = percentEncodeSExp(plaintext) + plaintextSexp := fmt.Sprintf("D (5:value%d:%s)\x00\nOK\n", + plaintextLen, plaintext) + _, err = io.WriteString(rw, plaintextSexp) + case setkeydesc: + // ignore this event since we don't currently use the client's + // description in the prompt + _, err = io.WriteString(rw, "OK\n") default: return fmt.Errorf("unknown event: %v", Event(e)) } @@ -184,72 +300,53 @@ func New(w io.Writer, p PIVService) *Assuan { return &assuan } -// haveKey returns true if any of the keygrips refer to keys held by the local -// PIVService, and false otherwise. -// It takes keygrips in raw byte format, so keygrip in hex-encoded form must -// first be decoded before being passed to this function. -func haveKey(p PIVService, keygrips [][]byte) (bool, []byte, error) { - securityKeys, err := p.SecurityKeys() +// doKeyinfo checks for key availability by keygrip, writing the result to rw. +func doKeyinfo(rw io.ReadWriter, data [][]byte, ks []KeyService) error { + // KEYINFO arguments are a list of keygrips + // if _any_ key is available, we return info, otherwise + // No_Secret_Key. + keygrips, err := hexDecode(data...) if err != nil { - return false, nil, fmt.Errorf("couldn't get security keys: %w", err) + return fmt.Errorf("couldn't decode keygrips: %v", err) } - for _, sk := range securityKeys { - for _, signingKey := range sk.SigningKeys() { - ecdsaPubKey, ok := signingKey.Public.(*ecdsa.PublicKey) - if !ok { - // TODO: handle other key types - continue - } - thisKeygrip, err := gpg.Keygrip(ecdsaPubKey) - if err != nil { - return false, nil, fmt.Errorf("couldn't get keygrip: %w", err) - } - for _, kg := range keygrips { - if bytes.Equal(thisKeygrip, kg) { - return true, thisKeygrip, nil - } - } - } + keyFound, keygrip, err := haveKey(ks, keygrips) + if err != nil { + _, _ = io.WriteString(rw, "ERR 1 couldn't match keygrip\n") + return fmt.Errorf("couldn't match keygrip: %v", err) } - return false, nil, nil + if keyFound { + _, err = io.WriteString(rw, + fmt.Sprintf("S KEYINFO %s D - - - - - - -\nOK\n", + strings.ToUpper(hex.EncodeToString(keygrip)))) + return err + } + _, err = io.WriteString(rw, "No_Secret_Key\n") + return err } -// getKey returns the security key associated with the given keygrip. -// If the keygrip doesn't match any known key, err will be non-nil. -// It takes a keygrip in raw byte format, so a keygrip in hex-encoded form must -// first be decoded before being passed to this function. -func getKey(p PIVService, keygrip []byte) (crypto.Signer, error) { - securityKeys, err := p.SecurityKeys() - if err != nil { - return nil, fmt.Errorf("couldn't get security keys: %w", err) - } - for _, k := range securityKeys { - for _, signingKey := range k.SigningKeys() { - ecdsaPubKey, ok := signingKey.Public.(*ecdsa.PublicKey) - if !ok { - // TODO: handle other key types - continue - } - thisKeygrip, err := gpg.Keygrip(ecdsaPubKey) - if err != nil { - return nil, fmt.Errorf("couldn't get keygrip: %w", err) - } - if bytes.Equal(thisKeygrip, keygrip) { - cryptoPrivKey, err := k.PrivateKey(&signingKey) - if err != nil { - return nil, fmt.Errorf("couldn't get private key from slot") - } - signingPrivKey, ok := cryptoPrivKey.(crypto.Signer) - if !ok { - return nil, fmt.Errorf("private key is invalid type") - } - return signingPrivKey, nil - } +// haveKey returns true if any of the keygrips refer to keys known locally, and +// false otherwise. +// It takes keygrips in raw byte format, so keygrip in hex-encoded form must +// first be decoded before being passed to this function. It returns the +// keygrip found. +func haveKey(ks []KeyService, keygrips [][]byte) (bool, []byte, error) { + var keyFound bool + var keygrip []byte + var err error + for _, k := range ks { + keyFound, keygrip, err = k.HaveKey(keygrips) + if err != nil { + return false, nil, fmt.Errorf("couldn't check %s keygrips: %v", k.Name(), err) + } + if keyFound { + return true, keygrip, nil } } - return nil, fmt.Errorf("no matching key") + return false, nil, nil } +// hexDecode take a list of hex-encoded bytestring values and converts them to +// their raw byte representation. func hexDecode(data ...[]byte) ([][]byte, error) { var decoded [][]byte for _, d := range data { @@ -263,35 +360,28 @@ func hexDecode(data ...[]byte) ([][]byte, error) { return decoded, nil } -// sign performs signing of the specified "hash" data, using the specified -// "hashAlgo" hash algorithm. It then encodes the response into an s-expression -// and returns it as a byte slice. +// Work around bug(?) in gnupg where some byte sequences are +// percent-encoded in the sexp. Yes, really. NFI what to do if the +// percent-encoded byte sequences themselves are part of the +// ciphertext. Yikes. // -// This function's complexity is due to the fact that while Sign() returns the -// r and s components of the signature ASN1-encoded, gpg expects them to be -// separately s-exp encoded. So we have to decode the ASN1 signature, extract -// the params, and re-encode them into the s-exp. Ugh. -func (a *Assuan) sign() ([]byte, error) { - cancel := notify.Touch(nil) - defer cancel() - signature, err := a.signingPrivKey.Sign(rand.Reader, a.hash, a.hashAlgo) - if err != nil { - return nil, fmt.Errorf("couldn't sign: %v", err) - } - var sig cryptobyte.String = signature - var b []byte - if !sig.ReadASN1Bytes(&b, asn1.SEQUENCE) { - return nil, fmt.Errorf("couldn't read asn1.SEQUENCE") - } - var rawInts cryptobyte.String = b - var r, s big.Int - if !rawInts.ReadASN1Integer(&r) { - return nil, fmt.Errorf("couldn't read r as asn1.Integer") - } - if !rawInts.ReadASN1Integer(&s) { - return nil, fmt.Errorf("couldn't read s as asn1.Integer") - } - // encode the params (r, s) into s-exp - return []byte(fmt.Sprintf(`D (7:sig-val(5:ecdsa(1:r32#%X#)(1:s32#%X#)))`, - r.Bytes(), s.Bytes())), nil +// These two functions represent over a week of late nights stepping through +// debug builds of libcrypt in gdb :-( + +// percentDecodeSExp replaces the percent-encoded byte sequences with their raw +// byte values. +func percentDecodeSExp(data []byte) []byte { + data = bytes.ReplaceAll(data, []byte{0x25, 0x32, 0x35}, []byte{0x25}) // % + data = bytes.ReplaceAll(data, []byte{0x25, 0x30, 0x41}, []byte{0x0a}) // \n + data = bytes.ReplaceAll(data, []byte{0x25, 0x30, 0x44}, []byte{0x0d}) // \r + return data +} + +// percentEncodeSExp replaces the raw byte values with their percent-encoded +// byte sequences. +func percentEncodeSExp(data []byte) []byte { + data = bytes.ReplaceAll(data, []byte{0x25}, []byte{0x25, 0x32, 0x35}) + data = bytes.ReplaceAll(data, []byte{0x0a}, []byte{0x25, 0x30, 0x41}) + data = bytes.ReplaceAll(data, []byte{0x0d}, []byte{0x25, 0x30, 0x44}) + return data } diff --git a/internal/assuan/assuan_test.go b/internal/assuan/assuan_test.go index 28d56c6..31aee80 100644 --- a/internal/assuan/assuan_test.go +++ b/internal/assuan/assuan_test.go @@ -4,17 +4,20 @@ import ( "bytes" "crypto" "crypto/ecdsa" + "encoding/hex" "fmt" "io" "math/big" "os" "testing" + "github.com/davecgh/go-spew/spew" "github.com/golang/mock/gomock" "github.com/smlx/piv-agent/internal/assuan" + "github.com/smlx/piv-agent/internal/keyservice/gpg" "github.com/smlx/piv-agent/internal/mock" - "github.com/smlx/piv-agent/internal/pivservice" "github.com/smlx/piv-agent/internal/securitykey" + "go.uber.org/zap" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte/asn1" "golang.org/x/crypto/openpgp" @@ -37,6 +40,21 @@ func (s *MockCryptoSigner) Sign(_ io.Reader, _ []byte, return s.Signature, nil } +// MockConn mocks a network connection, storing the read and write bytes +// internally to allow inspection. It implements io.ReadWriter. +type MockConn struct { + ReadBuf bytes.Buffer + WriteBuf bytes.Buffer +} + +func (c *MockConn) Read(p []byte) (int, error) { + return c.ReadBuf.Read(p) +} + +func (c *MockConn) Write(p []byte) (int, error) { + return c.WriteBuf.Write(p) +} + func TestSign(t *testing.T) { var testCases = map[string]struct { keyPath string @@ -116,34 +134,31 @@ func TestSign(t *testing.T) { if err != nil { tt.Fatal(err) } - var mockSecurityKey = mock.NewMockSecurityKey(ctrl) - mockSecurityKey.EXPECT().PrivateKey(gomock.Any()).Return(&MockCryptoSigner{ + keyService := mock.NewMockKeyService(ctrl) + keyService.EXPECT().HaveKey(gomock.Any()).AnyTimes().Return(true, nil, nil) + keyService.EXPECT().GetSigner(gomock.Any()).Return(&MockCryptoSigner{ PubKey: pubKey, Signature: signature, }, nil) - mockSecurityKey.EXPECT().SigningKeys().AnyTimes().Return( - []securitykey.SigningKey{{Public: pubKey}}) - pivService := mock.NewMockPIVService(ctrl) - pivService.EXPECT().SecurityKeys().AnyTimes().Return( - []pivservice.SecurityKey{mockSecurityKey}, nil) - // writeBuf is the buffer that the assuan statemachine writes to - writeBuf := bytes.Buffer{} - // readBuf is the buffer that the assuan statemachine reads from - readBuf := bytes.Buffer{} - a := assuan.New(&writeBuf, pivService) + mockConn := MockConn{} + log, err := zap.NewDevelopment() + if err != nil { + tt.Fatal(err) + } + a := assuan.New(&mockConn, log, keyService) // write all the lines into the statemachine for _, in := range tc.input { - if _, err := readBuf.WriteString(in); err != nil { + if _, err := mockConn.ReadBuf.WriteString(in); err != nil { tt.Fatal(err) } } // start the state machine - if err := a.Run(&readBuf); err != nil { + if err := a.Run(); err != nil { tt.Fatal(err) } // check the responses for _, expected := range tc.expect { - line, err := writeBuf.ReadString(byte('\n')) + line, err := mockConn.WriteBuf.ReadString(byte('\n')) if err != nil && err != io.EOF { tt.Fatal(err) } @@ -158,11 +173,13 @@ func TestSign(t *testing.T) { func TestKeyinfo(t *testing.T) { var testCases = map[string]struct { keyPath string + keyGrip string input []string expect []string }{ "keyinfo": { keyPath: "testdata/C54A8868468BC138.asc", + keyGrip: "38F053358EFD6C923D08EE4FC4CEB208CBCDF73C", input: []string{ "RESET\n", "KEYINFO 38F053358EFD6C923D08EE4FC4CEB208CBCDF73C\n", @@ -170,7 +187,7 @@ func TestKeyinfo(t *testing.T) { expect: []string{ "OK Pleased to meet you, process 123456789\n", "OK\n", - "S KEYINFO 38F053358EFD6C923D08EE4FC4CEB208CBCDF73C D - - - P - - -\n", + "S KEYINFO 38F053358EFD6C923D08EE4FC4CEB208CBCDF73C D - - - - - - -\n", "OK\n", }, }, @@ -182,32 +199,39 @@ func TestKeyinfo(t *testing.T) { if err != nil { tt.Fatal(err) } + keygrip, err := hex.DecodeString(tc.keyGrip) + if err != nil { + tt.Fatal(err) + } ctrl := gomock.NewController(tt) defer ctrl.Finish() var mockSecurityKey = mock.NewMockSecurityKey(ctrl) mockSecurityKey.EXPECT().SigningKeys().AnyTimes().Return( []securitykey.SigningKey{{Public: pubKey}}) - pivService := mock.NewMockPIVService(ctrl) - pivService.EXPECT().SecurityKeys().AnyTimes().Return( - []pivservice.SecurityKey{mockSecurityKey}, nil) - // writeBuf is the buffer that the assuan statemachine writes to - writeBuf := bytes.Buffer{} - // readBuf is the buffer that the assuan statemachine reads from - readBuf := bytes.Buffer{} - a := assuan.New(&writeBuf, pivService) + keyService := mock.NewMockKeyService(ctrl) + keyService.EXPECT().HaveKey(gomock.Any()).AnyTimes().Return( + true, keygrip, nil) + // mockConn is a pair of buffers that the assuan statemachine reads/write + // to/from. + mockConn := MockConn{} + log, err := zap.NewDevelopment() + if err != nil { + tt.Fatal(err) + } + a := assuan.New(&mockConn, log, keyService) // write all the lines into the statemachine for _, in := range tc.input { - if _, err := readBuf.WriteString(in); err != nil { + if _, err := mockConn.ReadBuf.WriteString(in); err != nil { tt.Fatal(err) } } // start the state machine - if err := a.Run(&readBuf); err != nil { + if err := a.Run(); err != nil { tt.Fatal(err) } // check the responses for _, expected := range tc.expect { - line, err := writeBuf.ReadString(byte('\n')) + line, err := mockConn.WriteBuf.ReadString(byte('\n')) if err != nil && err != io.EOF { tt.Fatal(err) } @@ -247,3 +271,286 @@ func ecdsaPubKeyLoad(path string) (*ecdsa.PublicKey, error) { } return eccKey, nil } + +func TestDecryptRSAKeyfile(t *testing.T) { + var testCases = map[string]struct { + keyPath string + input []string + expect []string + }{ + // test data is taken from a successful decrypt by gpg-agent + "decrypt file": { + keyPath: "testdata/private/foo@example.com.gpg", + input: []string{ + "RESET\n", + "OPTION ttyname=/dev/pts/1\n", + "OPTION ttytype=screen\n", + "OPTION lc-ctype=C.UTF-8\n", + "OPTION lc-messages=C\n", + "GETINFO version\n", + "OPTION allow-pinentry-notify\n", + "OPTION agent-awareness=2.1.0\n", + "HAVEKEY FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "HAVEKEY FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "HAVEKEY FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "RESET\n", + "SETKEY FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "SETKEYDESC Please+enter+the+passphrase+to+unlock+the+OpenPGP+secret+key:%0A%22foo@example.com%22%0A3072-bit+RSA+key,+ID+8D0381C18D1E7CA6,%0Acreated+2021-08-04.%0A\n", + "PKDECRYPT\n", + "\x44\x20\x28\x37\x3a\x65\x6e\x63\x2d\x76\x61\x6c\x28\x33\x3a\x72\x73\x61\x28\x31\x3a\x61\x33\x38\x34\x3a\x59\xd1\x22\xac\x32\xf2\x15\xc7\xc6\xd8\x9c\xfa\xec\xf7\xd4\x71\x4f\x6f\xa7\x65\xf7\x7c\x38\x16\xff\x91\x7e\x7f\xb5\xc7\x6b\xb6\xf4\xcc\x24\x8b\xd8\x8e\x44\x25\x30\x44\xab\xf7\x79\x12\x8f\xe3\x06\x89\x7c\x2a\x31\xc3\x25\x30\x44\x46\xdf\xb5\x67\xde\x20\xc8\xce\xad\x72\x14\x5a\x2e\x0e\xfd\x25\x32\x35\x42\x25\x30\x41\x5d\x41\x3c\xb4\x75\xb3\xf0\x58\xd2\xd5\xe7\x2d\x1f\x12\xbc\x29\x59\x4a\xe1\x16\x16\xdf\x5a\x9a\x63\x48\xec\x00\x2f\x68\xa6\x82\x32\x70\x36\xbc\x4c\xf1\x0b\x69\x60\x06\xbd\x04\x37\xc1\x2c\x34\x8f\x13\xd8\x23\xbf\x86\x8c\xcd\x6c\xfa\xb1\xfa\x59\x28\x46\xcd\x55\x27\xa9\x80\x67\xd2\x7d\x63\xf5\xe6\x15\x14\x00\x97\x36\x70\x37\xde\xd9\x49\xa6\xbd\x4d\x44\x48\x69\x28\x25\x32\x35\xf4\x06\xeb\xbf\x89\x39\xbb\xb9\x0f\x8e\x92\x5a\x57\x15\xdc\x85\x87\x39\xae\x3d\xeb\x5c\x02\x7c\x08\xcc\x31\x0e\x55\x4d\x3e\xda\xb4\xba\x42\xce\x9a\xa5\x8d\xec\x4b\x45\x8c\x3a\xa2\x92\x70\xbe\x30\x48\x86\xae\x52\x2f\x83\x00\xba\x99\xcf\xdd\x8d\x69\x23\x8b\x25\x30\x41\x3b\x39\x7b\xa0\xc4\x81\x65\x32\xed\xa9\x37\x23\x12\xcb\x8d\xe9\xeb\xa6\x6e\x05\x03\x3f\x5f\x9d\x72\x29\xe0\x27\x17\x2a\x23\x34\xad\x83\xb2\xbc\x5e\x0e\x8e\x0e\xe5\xfb\xbd\xd6\x25\x30\x41\x63\x7e\x9a\x12\x15\x14\x8b\x98\x56\x0c\x2e\x50\xe3\xbb\xb4\x19\x7b\x1b\x6a\xd8\xdc\xa8\xbe\x8b\x38\xa8\x09\x07\xeb\x00\x60\x66\xf0\xd1\xb8\xe2\x37\x7e\x7f\xa4\x78\x62\xcb\xb6\xcb\x8c\xad\x73\x90\xcd\x4b\xb7\xb4\xf2\xb1\x80\x38\x23\x6f\x11\x11\xe4\x83\x6d\x93\x4f\x22\x26\xff\x60\xda\xdb\x85\x1b\x25\x30\x44\xa4\x3c\x26\xd9\x09\x86\xd9\xa3\x5f\x7c\xb4\xb5\xf5\x6a\x3d\xbe\x96\x25\x30\x41\x49\xbc\x92\x84\x02\xac\x0c\x30\x17\x9f\xb2\xd2\x11\x93\xfa\x1d\x37\x9c\x29\x29\x29\x0a", + "END\n", + }, + expect: []string{ + "OK Pleased to meet you, process 123456789\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "D 2.2.27\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "S INQUIRE_MAXLEN 4096\n", + "INQUIRE CIPHERTEXT\n", + "\x44\x20\x28\x35\x3a\x76\x61\x6c\x75\x65\x33\x38\x33\x3a\x02\xfd\x56\x90\x50\xc0\x73\xcf\x96\x6a\x12\xfb\xc7\x25\x30\x44\xa2\xc6\x0f\x4c\x3b\xd4\x0f\x2a\x89\xff\x66\x3f\x28\xe6\xd1\x39\x17\x78\x87\x25\x32\x35\x32\x0c\x9d\x2d\x73\xe3\xab\x79\xe5\x03\xc3\x78\x88\x5e\x11\x98\x4b\x44\x42\xd1\xfc\x75\xe4\xfb\xbf\x2f\x9f\x79\x3a\xf1\xe7\xa6\xe3\x23\xea\xcf\xed\x1f\x29\x77\x67\x50\x42\xba\xe9\x98\x78\x30\x07\x44\x73\x9c\x15\x16\xd3\x7a\x9a\xe3\xe9\x36\xf2\x8a\x29\xf4\x3d\xb0\xa5\x18\xf2\x45\xf2\x33\xd4\x25\x30\x41\xb2\xe5\x18\x1b\xad\x55\xec\x8d\x16\x66\xce\xf9\xe5\x3d\xcd\x21\x6e\x57\xd0\x61\xf1\xb5\xc9\x16\x40\x06\x59\x64\xaa\x15\xcf\x01\xf7\xd2\x4c\x21\x3e\xd7\xe4\xeb\xbe\xf1\x8f\xb9\x50\xef\x14\x39\xb6\x9c\x12\xac\x8a\x1e\x1c\xe6\x0e\x45\xa8\x81\x4f\xbf\xc4\x9d\xb4\xb1\x50\x28\xbb\x14\x7b\xb3\xbb\xd9\x37\x38\xb3\x11\x43\xbc\xab\x32\xf2\x74\x67\xf3\x36\xb8\x11\x5f\x97\x7e\x91\x42\x6c\xee\x23\xe4\x81\x8b\xf8\x5a\xd7\x18\x27\x03\x6f\xa6\xff\xa2\x4b\x54\x18\x20\x74\x12\x21\x5c\x7a\x5e\x26\x25\x30\x41\xc6\xd3\x58\x94\x45\x3b\x90\x63\x7f\xf7\x9a\xb3\x30\x9d\x0e\xfe\xa7\xa9\xb5\xff\x92\x38\x15\x8b\x13\x46\x48\xd8\x9e\xca\xc4\xc2\xae\x65\x4d\xbb\xc1\xe5\x36\xf0\x56\x27\x96\x2b\x45\x4d\xc4\xed\xe5\x6f\x0e\x2b\x2f\x52\x47\x7f\x60\x09\x27\x0b\x30\xcb\x14\x65\x4e\xd2\xff\x9b\xdf\xd9\xb9\x0b\x7e\x07\x29\xba\x78\x47\x8e\x9d\x4a\x37\x0c\xee\x02\xb3\x65\xd7\x15\xba\xbb\xeb\x4b\xbd\xed\xd0\xcf\xae\x90\x31\x8a\x2d\x47\xfa\xc6\x1a\xac\xee\xf5\x82\x77\x28\x46\xce\x8a\x50\xc6\x00\x09\x9e\xf9\xb9\x35\x26\xbb\x2d\xcb\x9b\x60\x8d\x2e\xd3\x04\x95\xc7\xf5\x64\x97\xe6\x90\xf4\x7a\xb0\x50\xf4\x96\x99\x67\x36\xe6\x2f\x11\xf0\x29\x00\x0a", + "OK\n", + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + ctrl := gomock.NewController(tt) + defer ctrl.Finish() + // no securityKeys available + mockPES := mock.NewMockPINEntryService(ctrl) + log, err := zap.NewDevelopment() + if err != nil { + tt.Fatal(err) + } + keyfileService, err := gpg.New(log, mockPES, tc.keyPath) + if err != nil { + tt.Fatal(err) + } + // mockConn is a pair of buffers that the assuan statemachine reads/write + // to/from. + mockConn := MockConn{} + a := assuan.New(&mockConn, log, keyfileService) + // write all the lines into the statemachine + for _, in := range tc.input { + if _, err := mockConn.ReadBuf.WriteString(in); err != nil { + tt.Fatal(err) + } + } + // start the state machine + if err := a.Run(); err != nil { + tt.Fatal(err) + } + // check the responses + for _, expected := range tc.expect { + //spew.Dump(mockConn.WriteBuf.String()) + line, err := mockConn.WriteBuf.ReadString(byte('\n')) + if err != nil && err != io.EOF { + tt.Fatal(err) + } + if line != expected { + fmt.Println("got") + spew.Dump(line) + fmt.Println("expected") + spew.Dump(expected) + tt.Fatalf("error") + } + } + }) + } +} + +func TestSignRSAKeyfile(t *testing.T) { + var testCases = map[string]struct { + keyPath string + input []string + expect []string + }{ + // test data is taken from a successful decrypt by gpg-agent + "decrypt file": { + keyPath: "testdata/private/foo@example.com.gpg", + input: []string{ + "RESET\n", + "OPTION ttyname=/dev/pts/1\n", + "OPTION ttytype=screen\n", + "OPTION lc-ctype=C.UTF-8\n", + "OPTION lc-messages=C\n", + "GETINFO version\n", + "OPTION allow-pinentry-notify\n", + "OPTION agent-awareness=2.1.0\n", + "SCD SERIALNO\n", + "HAVEKEY FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "KEYINFO FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "RESET\n", + "SIGKEY FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D\n", + "SETKEYDESC Please+enter+the+passphrase+to+unlock+the+OpenPGP+secret+key:%0A%22foo@example.com%22%0A3072-bit+RSA+key,+ID+8D0381C18D1E7CA6,%0Acreated+2021-08-04.%0A\n", + "SETHASH 8 5963E1FA635CA32A85CA43CDCE3CB7A0CB0429B0EB1A94D1AEF08801D3BEB465\n", + "PKSIGN\n", + }, + expect: []string{ + "OK Pleased to meet you, process 123456789\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "D 2.2.27\n", + "OK\n", + "OK\n", + "OK\n", + "ERR 100696144 No such device \n", + "OK\n", + "S KEYINFO FC0F9A401ADDB33C0F7225CCA83BFC14E7FEBC7D D - - - - - - -\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "OK\n", + "\x44\x20\x28\x37\x3a\x73\x69\x67\x2d\x76\x61\x6c\x28\x33\x3a\x72\x73\x61\x28\x31\x3a\x73\x33\x38\x34\x3a\xb3\x26\x74\x5f\x59\xb5\x50\x8a\x46\x37\xa0\xc0\x91\x3a\x4b\x18\x61\xcb\x4f\xd2\x52\x5d\xbc\xe5\x51\x41\x00\x25\x30\x44\x08\x20\x25\x30\x41\xac\x0b\xff\x3e\xed\x6a\xa4\xf0\xdc\xb9\x1f\x8f\x76\xf1\x30\x8f\xce\xdc\xf5\x79\x2d\x2f\x06\x52\x3b\x49\xd5\x7d\xa1\x4a\xa2\x38\x81\x56\x6c\x59\xb0\x56\x22\xd8\x13\xeb\x7a\xee\xb1\xc5\xd6\xe9\xa0\x3a\xf4\x1b\x12\xa0\x85\x74\xe9\x93\x80\x7d\x7f\x24\xc8\x59\x9d\xb2\x8a\xe6\xc3\x95\xee\x50\x4c\x12\x4a\x1d\x84\x46\x3f\xa2\xc8\x96\xc2\xdf\xb7\x3d\x54\xa0\x55\x4a\x46\x4b\x35\x9f\xf0\x32\x9a\xd9\x0e\xe8\xa3\xa9\xb1\x3b\xa6\x52\x63\x02\xce\x36\x8f\x94\x18\x39\x3e\x11\x26\xb0\xa9\x71\xb8\x1c\x35\x47\xe8\x78\x8d\x12\xcf\x42\x96\xc7\x37\x25\x30\x41\x16\xa4\xbb\x83\x42\xe0\xa7\xed\x11\x35\x84\x5b\x40\xcd\x52\xc5\xd2\xf4\xe2\x86\x8b\x23\x42\x54\xda\xd1\xcd\xfc\x3e\xb2\x84\x1e\x2b\x04\xfb\x72\x04\x2f\xa9\x80\xf7\xa3\x13\x9a\xee\xe0\x26\x17\x6f\xdb\x57\x91\x85\xce\xbc\x5a\x97\x62\x8b\xa4\xa2\x54\x1c\x03\xc0\x3a\x9b\x8e\x4b\x32\x5e\x39\x71\x25\x30\x44\x8e\xae\x14\x09\x05\xcb\x77\x8d\x61\x2a\x4b\x1f\x19\x21\x8a\x68\x80\xd0\x4e\x53\x30\xc3\xab\x03\xd3\x79\x77\x55\xff\x2e\x46\xe3\x08\x03\x86\xef\xe1\xed\x34\x20\x08\x7a\xee\x1f\x0e\xd6\xf0\xbe\xe7\xdd\xab\xf6\x46\xec\xce\xd5\xa6\xc4\xf4\x02\x58\x5a\xcb\x6d\x9f\x2e\xf7\x24\x71\x9e\x13\x24\x22\x42\xe4\x48\xd5\x25\x32\x35\x1f\xac\xfc\x2c\xe2\x5c\x7c\xdb\xaf\xd2\x45\x3c\x99\xe1\xba\xd3\xd4\x95\x9d\xf8\xa1\x21\xca\x3f\xf9\x7b\x08\x50\x75\x13\x7a\x3d\xc9\x48\x9d\x4a\x93\xb6\xb5\x7a\x15\xef\xa6\x4d\xa9\x87\x41\x0e\xde\x25\x32\x35\x04\x18\x41\xa9\x4d\x9c\xbf\x12\x1f\x48\xc0\xa8\x92\xfd\x37\x7d\xec\x29\x29\x29\x0a", + "OK\n", + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + ctrl := gomock.NewController(tt) + defer ctrl.Finish() + // no securityKeys available + mockPES := mock.NewMockPINEntryService(ctrl) + log, err := zap.NewDevelopment() + if err != nil { + tt.Fatal(err) + } + keyfileService, err := gpg.New(log, mockPES, tc.keyPath) + if err != nil { + tt.Fatal(err) + } + // mockConn is a pair of buffers that the assuan statemachine reads/write + // to/from. + mockConn := MockConn{} + a := assuan.New(&mockConn, log, keyfileService) + // write all the lines into the statemachine + for _, in := range tc.input { + if _, err := mockConn.ReadBuf.WriteString(in); err != nil { + tt.Fatal(err) + } + } + // start the state machine + if err := a.Run(); err != nil { + tt.Fatal(err) + } + // check the responses + for _, expected := range tc.expect { + //spew.Dump(mockConn.WriteBuf.String()) + line, err := mockConn.WriteBuf.ReadString(byte('\n')) + if err != nil && err != io.EOF { + tt.Fatal(err) + } + if line != expected { + fmt.Println("got") + spew.Dump(line) + fmt.Println("expected") + spew.Dump(expected) + tt.Fatalf("error") + } + } + }) + } +} + +func TestReadKey(t *testing.T) { + var testCases = map[string]struct { + keyPath string + input []string + expect []string + }{ + "rsa": { + keyPath: "testdata/private-subkeys", + input: []string{ + "RESET\n", + "READKEY EA8E47C68880D1620FF10CC7CB91E5605758CC8D\n", + "SETKEYDESC Please+enter+the+passphrase+to+unlock+the+OpenPGP+secret+key:%0A%22foo@example.com%22%0A3072-bit+RSA+key,+ID+AD024955495A860B,%0Acreated+2021-08-07.%0A\n", + "PASSWD --verify B242AADA8260B77F0F5069F127D6B7E4F44B5FAA\n", + }, + expect: []string{ + "OK Pleased to meet you, process 123456789\n", + "OK\n", + "\x44\x20\x28\x31\x30\x3a\x70\x75\x62\x6c\x69\x63\x2d\x6b\x65\x79\x28\x33\x3a\x72\x73\x61\x28\x31\x3a\x6e\x33\x38\x35\x3a\x00\xbe\xe3\x07\x13\x3c\xae\xd7\x10\xe4\xdd\x84\x20\xc3\x96\xba\xdc\xe0\x09\x6d\xce\xbf\xc2\x55\xe3\x24\x4b\x96\x76\xf5\xd9\xcf\x02\x58\xbf\x69\x16\xcf\x2a\xa4\xdc\x8c\x82\x57\xb0\x5a\x16\x74\xf6\xd5\x21\xee\xdc\xce\x89\x64\xcd\x66\xf5\xee\x89\x09\xa6\x44\xce\x9d\x03\xc0\x44\x4d\x90\xdf\x60\x07\xc6\xf8\x2f\x98\x07\x9b\x95\xb3\xe5\x16\xb8\x1d\x59\xd1\x19\x97\x4c\x36\xbd\xce\xc7\xe1\x17\x7d\x6a\xdc\xa0\x16\x93\x2c\x91\x70\x7c\xf2\x1b\xd9\x5b\x4a\xd5\x46\x65\x9e\x09\xcc\x38\xbe\x86\xbd\xdd\xbf\x91\x7c\x04\x6c\xba\x38\xaf\xe6\xb4\xbb\x38\xa0\x3b\x3b\x07\x60\x2e\xbb\x6d\x45\x31\x1b\x0e\x37\x85\xdb\xa0\x93\xa5\x5c\xf6\xde\x69\x9e\x66\x3e\xa2\x3c\xf9\x59\x4b\x18\xc5\x5b\xdb\x4d\xa8\xcb\x80\xe6\xf9\x52\x1e\x2c\xb8\xab\xac\x7b\x14\xe9\xa8\x6a\x6d\xc6\x51\xb1\x74\x02\xa5\x13\x58\x66\x25\x32\x35\x3b\xed\xe3\x63\xb2\x7a\x8f\x93\x9b\x2c\x04\xdd\xf6\x56\xa9\xb2\x40\x34\xa9\x9b\xe6\xe1\x33\x5b\xe2\xa8\x12\x18\x48\x4e\xa6\xb7\xdd\xbf\xf0\xd2\x70\x18\x7b\x9d\xd3\xec\x55\x5f\xb7\xe8\x07\x1a\x90\x1e\xe4\x68\xa9\x67\x5c\xda\xe9\xea\x29\x19\xeb\x4c\x1c\x6a\x44\x06\x39\xea\xa2\xda\x29\x49\xdf\xd1\x00\x86\x5a\xe2\xe2\xe0\xa4\xa6\x2f\x74\x57\xbc\x78\x75\xa9\xd6\x81\xb1\x11\xbd\xca\x08\x17\x56\x9f\x42\xfe\x3f\x1a\xd1\x7e\xb2\x90\x27\x8a\x31\x8c\x88\x32\x3a\x28\x90\x10\xaf\x4d\xf8\x51\x94\x6f\x29\x21\xa4\x74\xfb\x65\x24\xcc\x5f\x48\x68\xdd\xff\x41\xb2\xe4\xa7\xbf\x25\x32\x35\xbe\x8d\xd8\x9f\x95\xd3\x7d\xe8\xf2\x4b\x78\xa1\x93\x29\xa5\x8b\xfa\x8d\x83\x6e\xbf\x9c\x5b\x1e\x38\xe3\x47\x60\xc6\xde\x4a\xd0\x78\x80\x6f\x20\xbf\xfd\x63\x12\x6f\xdd\xa3\x81\xf5\xf9\x29\x28\x31\x3a\x65\x33\x3a\x01\x00\x01\x29\x29\x29\x0a", + "OK\n", + "OK\n", + }, + }, + "ecdsa": { + keyPath: "testdata/private-subkeys", + input: []string{ + "RESET\n", + "READKEY 586A6F8E9CD839FD26D868D084DDFEBB0CCC7EF0\n", + "SETKEYDESC Please+enter+the+passphrase+to+unlock+the+OpenPGP+secret+key:%0A%22foo@example.com%22%0A3072-bit+RSA+key,+ID+AD024955495A860B,%0Acreated+2021-08-07.%0A\n", + "PASSWD --verify B242AADA8260B77F0F5069F127D6B7E4F44B5FAA\n", + }, + expect: []string{ + "OK Pleased to meet you, process 123456789\n", + "OK\n", + "\x44\x20\x28\x31\x30\x3a\x70\x75\x62\x6c\x69\x63\x2d\x6b\x65\x79\x28\x33\x3a\x65\x63\x63\x28\x35\x3a\x63\x75\x72\x76\x65\x31\x30\x3a\x4e\x49\x53\x54\x20\x50\x2d\x32\x35\x36\x29\x28\x31\x3a\x71\x36\x35\x3a\x04\xbf\x06\xac\x95\x31\xae\x04\x93\x98\x21\x03\x83\x35\x9d\x4e\x58\x92\xa2\xe9\x24\x2f\x76\x54\x67\x45\xf0\x35\x28\xf4\x47\x14\x59\x26\x0c\xf9\x1b\x24\x10\x6b\x07\xe3\x33\x05\x4c\xcb\x96\xe2\xdd\x96\xd4\x0f\x3e\x4b\xd7\x67\x44\xdb\x82\x42\x24\xe6\x8b\x7f\xa6\x29\x29\x29\x0a", + "OK\n", + "OK\n", + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + ctrl := gomock.NewController(tt) + defer ctrl.Finish() + // no securityKeys available + mockPES := mock.NewMockPINEntryService(ctrl) + log, err := zap.NewDevelopment() + if err != nil { + tt.Fatal(err) + } + keyfileService, err := gpg.New(log, mockPES, tc.keyPath) + if err != nil { + tt.Fatal(err) + } + // mockConn is a pair of buffers that the assuan statemachine reads/write + // to/from. + mockConn := MockConn{} + a := assuan.New(&mockConn, log, keyfileService) + // write all the lines into the statemachine + for _, in := range tc.input { + if _, err := mockConn.ReadBuf.WriteString(in); err != nil { + tt.Fatal(err) + } + } + // start the state machine + if err := a.Run(); err != nil { + tt.Fatal(err) + } + // check the responses + for _, expected := range tc.expect { + //spew.Dump(mockConn.WriteBuf.String()) + line, err := mockConn.WriteBuf.ReadString(byte('\n')) + if err != nil && err != io.EOF { + tt.Fatal(err) + } + if line != expected { + fmt.Println("got") + spew.Dump(line) + fmt.Println("expected") + spew.Dump(expected) + tt.Fatalf("error") + } + } + }) + } +} diff --git a/internal/assuan/event_enumer.go b/internal/assuan/event_enumer.go index 50002e3..fcd08ff 100644 --- a/internal/assuan/event_enumer.go +++ b/internal/assuan/event_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _EventName = "INVALIDEVENTCONNECTRESETOPTIONGETINFOHAVEKEYKEYINFOSIGKEYSETKEYDESCSETHASHPKSIGN" +const _EventName = "INVALIDEVENTCONNECTRESETOPTIONGETINFOHAVEKEYKEYINFOSIGKEYSETKEYDESCSETHASHPKSIGNSETKEYPKDECRYPTSCDREADKEYPASSWD" -var _EventIndex = [...]uint8{0, 12, 19, 24, 30, 37, 44, 51, 57, 67, 74, 80} +var _EventIndex = [...]uint8{0, 12, 19, 24, 30, 37, 44, 51, 57, 67, 74, 80, 86, 95, 98, 105, 111} -const _EventLowerName = "invalideventconnectresetoptiongetinfohavekeykeyinfosigkeysetkeydescsethashpksign" +const _EventLowerName = "invalideventconnectresetoptiongetinfohavekeykeyinfosigkeysetkeydescsethashpksignsetkeypkdecryptscdreadkeypasswd" func (i Event) String() string { if i < 0 || i >= Event(len(_EventIndex)-1) { @@ -35,33 +35,48 @@ func _EventNoOp() { _ = x[setkeydesc-(8)] _ = x[sethash-(9)] _ = x[pksign-(10)] + _ = x[setkey-(11)] + _ = x[pkdecrypt-(12)] + _ = x[scd-(13)] + _ = x[readkey-(14)] + _ = x[passwd-(15)] } -var _EventValues = []Event{invalidEvent, connect, reset, option, getinfo, havekey, keyinfo, sigkey, setkeydesc, sethash, pksign} +var _EventValues = []Event{invalidEvent, connect, reset, option, getinfo, havekey, keyinfo, sigkey, setkeydesc, sethash, pksign, setkey, pkdecrypt, scd, readkey, passwd} var _EventNameToValueMap = map[string]Event{ - _EventName[0:12]: invalidEvent, - _EventLowerName[0:12]: invalidEvent, - _EventName[12:19]: connect, - _EventLowerName[12:19]: connect, - _EventName[19:24]: reset, - _EventLowerName[19:24]: reset, - _EventName[24:30]: option, - _EventLowerName[24:30]: option, - _EventName[30:37]: getinfo, - _EventLowerName[30:37]: getinfo, - _EventName[37:44]: havekey, - _EventLowerName[37:44]: havekey, - _EventName[44:51]: keyinfo, - _EventLowerName[44:51]: keyinfo, - _EventName[51:57]: sigkey, - _EventLowerName[51:57]: sigkey, - _EventName[57:67]: setkeydesc, - _EventLowerName[57:67]: setkeydesc, - _EventName[67:74]: sethash, - _EventLowerName[67:74]: sethash, - _EventName[74:80]: pksign, - _EventLowerName[74:80]: pksign, + _EventName[0:12]: invalidEvent, + _EventLowerName[0:12]: invalidEvent, + _EventName[12:19]: connect, + _EventLowerName[12:19]: connect, + _EventName[19:24]: reset, + _EventLowerName[19:24]: reset, + _EventName[24:30]: option, + _EventLowerName[24:30]: option, + _EventName[30:37]: getinfo, + _EventLowerName[30:37]: getinfo, + _EventName[37:44]: havekey, + _EventLowerName[37:44]: havekey, + _EventName[44:51]: keyinfo, + _EventLowerName[44:51]: keyinfo, + _EventName[51:57]: sigkey, + _EventLowerName[51:57]: sigkey, + _EventName[57:67]: setkeydesc, + _EventLowerName[57:67]: setkeydesc, + _EventName[67:74]: sethash, + _EventLowerName[67:74]: sethash, + _EventName[74:80]: pksign, + _EventLowerName[74:80]: pksign, + _EventName[80:86]: setkey, + _EventLowerName[80:86]: setkey, + _EventName[86:95]: pkdecrypt, + _EventLowerName[86:95]: pkdecrypt, + _EventName[95:98]: scd, + _EventLowerName[95:98]: scd, + _EventName[98:105]: readkey, + _EventLowerName[98:105]: readkey, + _EventName[105:111]: passwd, + _EventLowerName[105:111]: passwd, } var _EventNames = []string{ @@ -76,6 +91,11 @@ var _EventNames = []string{ _EventName[57:67], _EventName[67:74], _EventName[74:80], + _EventName[80:86], + _EventName[86:95], + _EventName[95:98], + _EventName[98:105], + _EventName[105:111], } // EventString retrieves an enum value from the enum constants string name. diff --git a/internal/assuan/fsm.go b/internal/assuan/fsm.go index e27e6ca..443fade 100644 --- a/internal/assuan/fsm.go +++ b/internal/assuan/fsm.go @@ -1,6 +1,7 @@ package assuan import ( + "bufio" "crypto" "sync" @@ -25,6 +26,11 @@ const ( setkeydesc sethash pksign + setkey + pkdecrypt + scd + readkey + passwd ) //go:generate enumer -type=State -text -transform upper @@ -34,26 +40,31 @@ type State fsm.Event // Enumeration of all possible states in the assuan FSM. // connected is the initial state when the client connects. -// keyIsSet indicates that the client has selected a key. +// signingKeyIsSet indicates that the client has selected a key. // hashIsSet indicates that the client has selected a hash (and key). const ( invalidState State = iota ready connected - keyIsSet + signingKeyIsSet hashIsSet + decryptingKeyIsSet + waitingForCiphertext ) // Assuan is the Assuan protocol FSM. type Assuan struct { fsm.Machine mu sync.Mutex + // buffered IO for linewise reading + reader *bufio.Reader // data is passed during Occur() data [][]byte // remaining fields store Assuan internal state - signingPrivKey crypto.Signer - hashAlgo crypto.Hash - hash []byte + signer crypto.Signer + decrypter crypto.Decrypter + hashAlgo crypto.Hash + hash []byte } // Occur handles an event occurence. @@ -69,50 +80,81 @@ var assuanTransitions = []fsm.Transition{ Src: fsm.State(ready), Event: fsm.Event(connect), Dst: fsm.State(connected), - }, - { + }, { Src: fsm.State(connected), Event: fsm.Event(reset), Dst: fsm.State(connected), - }, - { + }, { Src: fsm.State(connected), Event: fsm.Event(option), Dst: fsm.State(connected), - }, - { + }, { Src: fsm.State(connected), Event: fsm.Event(getinfo), Dst: fsm.State(connected), - }, - { + }, { Src: fsm.State(connected), Event: fsm.Event(havekey), Dst: fsm.State(connected), - }, - { + }, { Src: fsm.State(connected), Event: fsm.Event(keyinfo), Dst: fsm.State(connected), + }, { + Src: fsm.State(connected), + Event: fsm.Event(scd), + Dst: fsm.State(connected), + }, { + Src: fsm.State(connected), + Event: fsm.Event(readkey), + Dst: fsm.State(connected), + }, { + Src: fsm.State(connected), + Event: fsm.Event(setkeydesc), + Dst: fsm.State(connected), + }, { + Src: fsm.State(connected), + Event: fsm.Event(passwd), + Dst: fsm.State(connected), }, + // signing transitions { Src: fsm.State(connected), Event: fsm.Event(sigkey), - Dst: fsm.State(keyIsSet), - }, - { - Src: fsm.State(keyIsSet), + Dst: fsm.State(signingKeyIsSet), + }, { + Src: fsm.State(signingKeyIsSet), Event: fsm.Event(setkeydesc), - Dst: fsm.State(keyIsSet), - }, - { - Src: fsm.State(keyIsSet), + Dst: fsm.State(signingKeyIsSet), + }, { + Src: fsm.State(signingKeyIsSet), Event: fsm.Event(sethash), Dst: fsm.State(hashIsSet), - }, - { + }, { Src: fsm.State(hashIsSet), Event: fsm.Event(pksign), Dst: fsm.State(hashIsSet), + }, { + Src: fsm.State(hashIsSet), + Event: fsm.Event(keyinfo), + Dst: fsm.State(hashIsSet), + }, { + Src: fsm.State(hashIsSet), + Event: fsm.Event(reset), + Dst: fsm.State(connected), + }, + // decrypting transitions + { + Src: fsm.State(connected), + Event: fsm.Event(setkey), + Dst: fsm.State(decryptingKeyIsSet), + }, { + Src: fsm.State(decryptingKeyIsSet), + Event: fsm.Event(setkeydesc), + Dst: fsm.State(decryptingKeyIsSet), + }, { + Src: fsm.State(decryptingKeyIsSet), + Event: fsm.Event(pkdecrypt), + Dst: fsm.State(waitingForCiphertext), }, } diff --git a/internal/assuan/readkey.go b/internal/assuan/readkey.go new file mode 100644 index 0000000..5d7ffa9 --- /dev/null +++ b/internal/assuan/readkey.go @@ -0,0 +1,39 @@ +package assuan + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "fmt" + "math/big" +) + +// readKeyData returns information about the given key in a libgcrypt-specific +// format +func readKeyData(pub crypto.PublicKey) (string, error) { + switch k := pub.(type) { + case *rsa.PublicKey: + n := k.N.Bytes() + nLen := len(n) // need the actual byte length before munging + n = percentEncodeSExp(n) // ugh + ei := new(big.Int) + ei.SetInt64(int64(k.E)) + e := ei.Bytes() + // prefix the key with a null byte for compatibility + return fmt.Sprintf("D (10:public-key(3:rsa(1:n%d:\x00%s)(1:e%d:%s)))\nOK\n", + nLen+1, n, len(e), e), nil + case *ecdsa.PublicKey: + switch k.Curve { + case elliptic.P256(): + q := elliptic.Marshal(k.Curve, k.X, k.Y) + return fmt.Sprintf( + "D (10:public-key(3:ecc(5:curve10:NIST P-256)(1:q%d:%s)))\nOK\n", + len(q), q), nil + default: + return "", fmt.Errorf("unsupported curve: %T", k.Curve) + } + default: + return "", nil + } +} diff --git a/internal/assuan/run.go b/internal/assuan/run.go index d7294d4..dbdfe65 100644 --- a/internal/assuan/run.go +++ b/internal/assuan/run.go @@ -1,23 +1,20 @@ package assuan import ( - "bufio" "bytes" "fmt" "io" ) // Run the event machine loop -func (a *Assuan) Run(conn io.Reader) error { +func (a *Assuan) Run() error { // register connection if err := a.Occur(connect); err != nil { return fmt.Errorf("error handling connect: %w", err) } - // parse incoming messages to events - r := bufio.NewReader(conn) var e Event for { - line, err := r.ReadBytes(byte('\n')) + line, err := a.reader.ReadBytes(byte('\n')) if err != nil { if err == io.EOF { return nil // connection closed diff --git a/internal/assuan/sign.go b/internal/assuan/sign.go new file mode 100644 index 0000000..629099d --- /dev/null +++ b/internal/assuan/sign.go @@ -0,0 +1,66 @@ +package assuan + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "math/big" + + "github.com/smlx/piv-agent/internal/notify" + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" +) + +// sign performs signing of the specified "hash" data, using the specified +// "hashAlgo" hash algorithm. It then encodes the response into an s-expression +// and returns it as a byte slice. +func (a *Assuan) sign() ([]byte, error) { + switch a.signer.Public().(type) { + case *rsa.PublicKey: + return a.signRSA() + default: + // default also handles mock signers in the test suite + return a.signECDSA() + } +} + +// signRSA returns a signature for the given hash. +func (a *Assuan) signRSA() ([]byte, error) { + signature, err := a.signer.Sign(rand.Reader, a.hash, a.hashAlgo) + if err != nil { + return nil, fmt.Errorf("couldn't sign: %v", err) + } + return []byte(fmt.Sprintf(`D (7:sig-val(3:rsa(1:s%d:%s)))`, len(signature), + percentEncodeSExp(signature))), nil +} + +// signECDSA returns a signature for the given hash. +// +// This function's complexity is due to the fact that while Sign() returns the +// r and s components of the signature ASN1-encoded, gpg expects them to be +// separately s-exp encoded. So we have to decode the ASN1 signature, extract +// the params, and re-encode them into the s-exp. Ugh. +func (a *Assuan) signECDSA() ([]byte, error) { + cancel := notify.Touch(nil) + defer cancel() + signature, err := a.signer.Sign(rand.Reader, a.hash, a.hashAlgo) + if err != nil { + return nil, fmt.Errorf("couldn't sign: %v", err) + } + var sig cryptobyte.String = signature + var b []byte + if !sig.ReadASN1Bytes(&b, asn1.SEQUENCE) { + return nil, fmt.Errorf("couldn't read asn1.SEQUENCE") + } + var rawInts cryptobyte.String = b + var r, s big.Int + if !rawInts.ReadASN1Integer(&r) { + return nil, fmt.Errorf("couldn't read r as asn1.Integer") + } + if !rawInts.ReadASN1Integer(&s) { + return nil, fmt.Errorf("couldn't read s as asn1.Integer") + } + // encode the params (r, s) into s-exp + return []byte(fmt.Sprintf(`D (7:sig-val(5:ecdsa(1:r32#%X#)(1:s32#%X#)))`, + r.Bytes(), s.Bytes())), nil +} diff --git a/internal/assuan/state_enumer.go b/internal/assuan/state_enumer.go index 42dd5c3..16c7e4c 100644 --- a/internal/assuan/state_enumer.go +++ b/internal/assuan/state_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _StateName = "INVALIDSTATEREADYCONNECTEDKEYISSETHASHISSET" +const _StateName = "INVALIDSTATEREADYCONNECTEDSIGNINGKEYISSETHASHISSETDECRYPTINGKEYISSETWAITINGFORCIPHERTEXT" -var _StateIndex = [...]uint8{0, 12, 17, 26, 34, 43} +var _StateIndex = [...]uint8{0, 12, 17, 26, 41, 50, 68, 88} -const _StateLowerName = "invalidstatereadyconnectedkeyissethashisset" +const _StateLowerName = "invalidstatereadyconnectedsigningkeyissethashissetdecryptingkeyissetwaitingforciphertext" func (i State) String() string { if i < 0 || i >= State(len(_StateIndex)-1) { @@ -27,11 +27,13 @@ func _StateNoOp() { _ = x[invalidState-(0)] _ = x[ready-(1)] _ = x[connected-(2)] - _ = x[keyIsSet-(3)] + _ = x[signingKeyIsSet-(3)] _ = x[hashIsSet-(4)] + _ = x[decryptingKeyIsSet-(5)] + _ = x[waitingForCiphertext-(6)] } -var _StateValues = []State{invalidState, ready, connected, keyIsSet, hashIsSet} +var _StateValues = []State{invalidState, ready, connected, signingKeyIsSet, hashIsSet, decryptingKeyIsSet, waitingForCiphertext} var _StateNameToValueMap = map[string]State{ _StateName[0:12]: invalidState, @@ -40,18 +42,24 @@ var _StateNameToValueMap = map[string]State{ _StateLowerName[12:17]: ready, _StateName[17:26]: connected, _StateLowerName[17:26]: connected, - _StateName[26:34]: keyIsSet, - _StateLowerName[26:34]: keyIsSet, - _StateName[34:43]: hashIsSet, - _StateLowerName[34:43]: hashIsSet, + _StateName[26:41]: signingKeyIsSet, + _StateLowerName[26:41]: signingKeyIsSet, + _StateName[41:50]: hashIsSet, + _StateLowerName[41:50]: hashIsSet, + _StateName[50:68]: decryptingKeyIsSet, + _StateLowerName[50:68]: decryptingKeyIsSet, + _StateName[68:88]: waitingForCiphertext, + _StateLowerName[68:88]: waitingForCiphertext, } var _StateNames = []string{ _StateName[0:12], _StateName[12:17], _StateName[17:26], - _StateName[26:34], - _StateName[34:43], + _StateName[26:41], + _StateName[41:50], + _StateName[50:68], + _StateName[68:88], } // StateString retrieves an enum value from the enum constants string name. diff --git a/internal/assuan/testdata/private-subkeys/foo-sub-ecdsa@example.com.sub-ecdsa.gpg b/internal/assuan/testdata/private-subkeys/foo-sub-ecdsa@example.com.sub-ecdsa.gpg new file mode 100644 index 0000000..a068a75 Binary files /dev/null and b/internal/assuan/testdata/private-subkeys/foo-sub-ecdsa@example.com.sub-ecdsa.gpg differ diff --git a/internal/assuan/testdata/private-subkeys/foo-sub@example.com.sub-rsa.gpg b/internal/assuan/testdata/private-subkeys/foo-sub@example.com.sub-rsa.gpg new file mode 100644 index 0000000..e15a9a0 Binary files /dev/null and b/internal/assuan/testdata/private-subkeys/foo-sub@example.com.sub-rsa.gpg differ diff --git a/internal/assuan/testdata/private-subkeys/foo@example.com.primary-rsa.gpg b/internal/assuan/testdata/private-subkeys/foo@example.com.primary-rsa.gpg new file mode 100644 index 0000000..6e4862a Binary files /dev/null and b/internal/assuan/testdata/private-subkeys/foo@example.com.primary-rsa.gpg differ diff --git a/internal/assuan/testdata/private/foo@example.com.gpg b/internal/assuan/testdata/private/foo@example.com.gpg new file mode 100644 index 0000000..6e95044 Binary files /dev/null and b/internal/assuan/testdata/private/foo@example.com.gpg differ diff --git a/internal/keyservice/gpg/keyfile.go b/internal/keyservice/gpg/keyfile.go new file mode 100644 index 0000000..1e058b4 --- /dev/null +++ b/internal/keyservice/gpg/keyfile.go @@ -0,0 +1,92 @@ +package gpg + +import ( + "fmt" + "io" + "os" + "path" + + "golang.org/x/crypto/openpgp/errors" + "golang.org/x/crypto/openpgp/packet" +) + +// keyfilePrivateKeys reads the given path and returns any private keys found. +func keyfilePrivateKeys(p string) ([]privateKeyfile, error) { + f, err := os.Open(p) + if err != nil { + return nil, fmt.Errorf("couldn't open path %s: %v", p, err) + } + fileInfo, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("couldn't stat path %s: %v", p, err) + + } + switch { + case fileInfo.Mode().IsRegular(): + pk, err := keysFromFile(f) + return []privateKeyfile{*pk}, err + case fileInfo.IsDir(): + // enumerate files in directory + dirents, err := f.ReadDir(0) + if err != nil { + return nil, fmt.Errorf("couldn't read directory") + } + // get any private keys from each file + var privKeys []privateKeyfile + for _, dirent := range dirents { + direntInfo, err := dirent.Info() + if err != nil { + return nil, fmt.Errorf("couldn't stat directory entry") + } + // ignore subdirectories + if direntInfo.Mode().IsRegular() { + subPath := path.Join(p, dirent.Name()) + ff, err := os.Open(subPath) + if err != nil { + return nil, fmt.Errorf("couldn't open path %s: %v", subPath, err) + } + subPrivKeys, err := keysFromFile(ff) + if err != nil { + return nil, + fmt.Errorf("couldn't get keys from file %s: %v", subPath, err) + } + privKeys = append(privKeys, *subPrivKeys) + } + } + return privKeys, nil + default: + return nil, fmt.Errorf("invalid file type for path to keyfiles") + } +} + +// keysFromFile read a file and return any private keys found +func keysFromFile(f *os.File) (*privateKeyfile, error) { + var err error + var pkt packet.Packet + var uid *packet.UserId + var privKeys []*packet.PrivateKey + reader := packet.NewReader(f) + for pkt, err = reader.Next(); err != io.EOF; pkt, err = reader.Next() { + if _, ok := err.(errors.UnsupportedError); ok { + continue // gpg writes some non-standard cruft + } + if err != nil { + return nil, fmt.Errorf("couldn't get next packet: %v", err) + } + switch k := pkt.(type) { + case *packet.PrivateKey: + privKeys = append(privKeys, k) + case *packet.UserId: + uid = k + default: + continue + } + } + if uid == nil { + uid = packet.NewUserId("n/a", "n/a", "n/a") + } + return &privateKeyfile{ + uid: uid, + keys: privKeys, + }, nil +} diff --git a/internal/gpg/keygrip.go b/internal/keyservice/gpg/keygrip.go similarity index 83% rename from internal/gpg/keygrip.go rename to internal/keyservice/gpg/keygrip.go index 1cb899a..ab2fc7f 100644 --- a/internal/gpg/keygrip.go +++ b/internal/keyservice/gpg/keygrip.go @@ -3,6 +3,7 @@ package gpg import ( "bytes" "crypto/ecdsa" + "crypto/rsa" "crypto/sha1" "fmt" "math/big" @@ -13,7 +14,7 @@ type part struct { value []byte } -// Keygrip calculates a keygrip for an ECDSA public key. This is a SHA1 hash of +// KeygripECDSA calculates a keygrip for an ECDSA public key. This is a SHA1 hash of // public key parameters. It is pretty much undocumented outside of the // libgcrypt codebase. // @@ -22,7 +23,7 @@ type part struct { // key is byte-encoded, the parts are s-exp encoded in a particular order, and // then the s-exp is sha1-hashed to produced the keygrip, which is generally // displayed hex-encoded. -func Keygrip(pubKey *ecdsa.PublicKey) ([]byte, error) { +func KeygripECDSA(pubKey *ecdsa.PublicKey) ([]byte, error) { if pubKey == nil { return nil, fmt.Errorf("nil key") } @@ -85,3 +86,11 @@ func compute(parts []part) ([]byte, error) { s := sha1.Sum(h.Bytes()) return s[:], nil } + +// keygripRSA calculates a keygrip for an RSA public key. +func keygripRSA(pubKey *rsa.PublicKey) []byte { + keygrip := sha1.New() + keygrip.Write([]byte{0}) + keygrip.Write(pubKey.N.Bytes()) + return keygrip.Sum(nil) +} diff --git a/internal/gpg/keygrip_test.go b/internal/keyservice/gpg/keygrip_test.go similarity index 94% rename from internal/gpg/keygrip_test.go rename to internal/keyservice/gpg/keygrip_test.go index 5c737b2..4837fe7 100644 --- a/internal/gpg/keygrip_test.go +++ b/internal/keyservice/gpg/keygrip_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/smlx/piv-agent/internal/gpg" + "github.com/smlx/piv-agent/internal/keyservice/gpg" "golang.org/x/crypto/openpgp" "golang.org/x/crypto/openpgp/armor" "golang.org/x/crypto/openpgp/packet" @@ -32,7 +32,7 @@ func TestTrezorCompat(t *testing.T) { priv.D = tc.input priv.PublicKey.X, priv.PublicKey.Y = curve.ScalarBaseMult(tc.input.Bytes()) - keygrip, err := gpg.Keygrip(&priv.PublicKey) + keygrip, err := gpg.KeygripECDSA(&priv.PublicKey) if err != nil { tt.Fatal(err) } @@ -90,7 +90,7 @@ func TestKeyGrip(t *testing.T) { tt.Fatal("wrong curve") } - keygrip, err := gpg.Keygrip(eccKey) + keygrip, err := gpg.KeygripECDSA(eccKey) if err != nil { tt.Fatal(err) } diff --git a/internal/keyservice/gpg/keyservice.go b/internal/keyservice/gpg/keyservice.go new file mode 100644 index 0000000..19276f0 --- /dev/null +++ b/internal/keyservice/gpg/keyservice.go @@ -0,0 +1,197 @@ +package gpg + +//go:generate mockgen -source=keyservice.go -destination=../../mock/mock_keyservice.go -package=mock + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "fmt" + + "go.uber.org/zap" + "golang.org/x/crypto/openpgp/packet" +) + +// PINEntryService provides an interface to talk to a pinentry program. +type PINEntryService interface { + GetPGPPassphrase(string, string) ([]byte, error) +} + +type privateKeyfile struct { + uid *packet.UserId + keys []*packet.PrivateKey +} + +// KeyService implements an interface for getting cryptographic keys from +// keyfiles on disk. +type KeyService struct { + // cache passphrases used for decryption + passphrases [][]byte + privKeys []privateKeyfile + log *zap.Logger + pinentry PINEntryService +} + +// New returns a keyservice initialised with keys found at path. +// Path can be a file or directory. +func New(l *zap.Logger, pe PINEntryService, path string) (*KeyService, error) { + p, err := keyfilePrivateKeys(path) + if err != nil { + return nil, err + } + return &KeyService{ + privKeys: p, + log: l, + pinentry: pe, + }, nil +} + +// Name returns the name of the keyservice. +func (*KeyService) Name() string { + return "GPG Keyfile" +} + +// HaveKey takes a list of keygrips, and returns a boolean indicating if any of +// the given keygrips were found, the found keygrip, and an error, if any. +func (g *KeyService) HaveKey(keygrips [][]byte) (bool, []byte, error) { + for _, kg := range keygrips { + key, err := g.getRSAKey(kg) + if err != nil { + return false, nil, err + } + if key != nil { + return true, kg, nil + } + } + return false, nil, nil +} + +// decryptPrivateKey decrypts the given private key. +// Returns nil if successful, or an error if the key could not be decrypted. +func (g *KeyService) decryptPrivateKey(k *packet.PrivateKey, uid string) error { + var pass []byte + var err error + if k.Encrypted { + // try existing passphrases + for _, pass := range g.passphrases { + if err = k.Decrypt(pass); err == nil { + g.log.Debug("decrypted using cached passphrase", + zap.String("fingerprint", k.KeyIdString())) + break + } + } + } + if k.Encrypted { + // ask for a passphrase + pass, err = g.pinentry.GetPGPPassphrase(uid, + fmt.Sprintf("%X %X %X %X", k.Fingerprint[:5], k.Fingerprint[5:10], + k.Fingerprint[10:15], k.Fingerprint[15:])) + if err != nil { + return fmt.Errorf("couldn't get passphrase for key %s: %v", + k.KeyIdString(), err) + } + g.passphrases = append(g.passphrases, pass) + if err = k.Decrypt(pass); err != nil { + return fmt.Errorf("couldn't decrypt key %s: %v", + k.KeyIdString(), err) + } + g.log.Debug("decrypted using passphrase", + zap.String("fingerprint", k.KeyIdString())) + } + return nil +} + +// getRSAKey returns a matching private RSA key if the keygrip matches. If a key +// is returned err will be nil. If no key is found, both values may be nil. +func (g *KeyService) getRSAKey(keygrip []byte) (*rsa.PrivateKey, error) { + var err error + for _, pk := range g.privKeys { + for _, k := range pk.keys { + pubKey, ok := k.PublicKey.PublicKey.(*rsa.PublicKey) + if !ok { + continue + } + if !bytes.Equal(keygrip, keygripRSA(pubKey)) { + continue + } + err = g.decryptPrivateKey(k, + fmt.Sprintf("%s (%s) <%s>", + pk.uid.Name, pk.uid.Comment, pk.uid.Email)) + if err != nil { + return nil, err + } + privKey, ok := k.PrivateKey.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an RSA key %s: %v", + k.KeyIdString(), err) + } + return privKey, nil + } + } + return nil, nil +} + +// getECDSAKey returns a matching private RSA key if the keygrip matches. If a key +// is returned err will be nil. If no key is found, both values may be nil. +func (g *KeyService) getECDSAKey(keygrip []byte) (*ecdsa.PrivateKey, error) { + for _, pk := range g.privKeys { + for _, k := range pk.keys { + pubKey, ok := k.PublicKey.PublicKey.(*ecdsa.PublicKey) + if !ok { + continue + } + pubKeygrip, err := KeygripECDSA(pubKey) + if err != nil { + return nil, fmt.Errorf("couldn't get ECDSA keygrip: %v", err) + } + if !bytes.Equal(keygrip, pubKeygrip) { + continue + } + err = g.decryptPrivateKey(k, + fmt.Sprintf("%s (%s) <%s>", + pk.uid.Name, pk.uid.Comment, pk.uid.Email)) + if err != nil { + return nil, err + } + privKey, ok := k.PrivateKey.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an ECDSA key %s: %v", + k.KeyIdString(), err) + } + return privKey, nil + } + } + return nil, nil +} + +// GetSigner returns a crypto.Signer associated with the given keygrip. +func (g *KeyService) GetSigner(keygrip []byte) (crypto.Signer, error) { + rsaPrivKey, err := g.getRSAKey(keygrip) + if err != nil { + return nil, fmt.Errorf("couldn't getRSAKey: %v", err) + } + if rsaPrivKey != nil { + return &RSAKey{rsa: rsaPrivKey}, nil + } + ecdsaPrivKey, err := g.getECDSAKey(keygrip) + if err != nil { + return nil, fmt.Errorf("couldn't getECDSAKey: %v", err) + } + if ecdsaPrivKey != nil { + return ecdsaPrivKey, nil + } + return nil, fmt.Errorf("couldn't get signer for keygrip %X", keygrip) +} + +// GetDecrypter returns a crypto.Decrypter associated with the given keygrip. +func (g *KeyService) GetDecrypter(keygrip []byte) (crypto.Decrypter, error) { + rsaPrivKey, err := g.getRSAKey(keygrip) + if err != nil { + return nil, fmt.Errorf("couldn't getRSAKey: %v", err) + } + if rsaPrivKey == nil { + return nil, fmt.Errorf("couldn't get decrypter for keygrip %X", keygrip) + } + return &RSAKey{rsa: rsaPrivKey}, nil +} diff --git a/internal/keyservice/gpg/keyservice_test.go b/internal/keyservice/gpg/keyservice_test.go new file mode 100644 index 0000000..a4c2646 --- /dev/null +++ b/internal/keyservice/gpg/keyservice_test.go @@ -0,0 +1,59 @@ +package gpg_test + +import ( + "encoding/hex" + "testing" + + "github.com/golang/mock/gomock" + "github.com/smlx/piv-agent/internal/keyservice/gpg" + "github.com/smlx/piv-agent/internal/mock" + "go.uber.org/zap" +) + +func hexMustDecode(s string) []byte { + raw, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return raw +} + +func TestGetSigner(t *testing.T) { + var testCases = map[string]struct { + path string + keygrip []byte + protected bool + }{ + "unprotected key": { + path: "testdata/private/bar@example.com.gpg", + keygrip: hexMustDecode("9128BB9362750577445FAAE9E737684EBB74FD6C"), + }, + "protected key": { + path: "testdata/private/bar-protected@example.com.gpg", + keygrip: hexMustDecode("75B7C5A35213E71BA282F64317DDB90EC5C3FEE0"), + protected: true, + }, + } + log, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + ctrl := gomock.NewController(tt) + defer ctrl.Finish() + var mockPES = mock.NewMockPINEntryService(ctrl) + if tc.protected { + mockPES.EXPECT().GetPGPPassphrase(gomock.Any(), gomock.Any()). + Return([]byte("trustno1"), nil) + } + ks, err := gpg.New(log, mockPES, tc.path) + if err != nil { + tt.Fatal(err) + } + if _, err := ks.GetSigner(tc.keygrip); err != nil { + tt.Fatal(err) + } + }) + } +} diff --git a/internal/keyservice/gpg/rsakey.go b/internal/keyservice/gpg/rsakey.go new file mode 100644 index 0000000..db5d6c3 --- /dev/null +++ b/internal/keyservice/gpg/rsakey.go @@ -0,0 +1,46 @@ +package gpg + +import ( + "crypto" + "crypto/rsa" + "io" + "math/big" +) + +// RSAKey represents a GPG key loaded from a keyfile. +// It implements the crypto.Decrypter and crypto.Signer interfaces. +type RSAKey struct { + rsa *rsa.PrivateKey +} + +// Decrypt performs RSA decryption as per gpg-agent. +// +// Terrible things about this function (not exhaustive): +// * rolling my own crypto +// * makes well-known RSA implementation mistakes +// * RSA in 2021 +// +// I'd love to not have to do this, but hey, it's for gnupg compatibility. +// Get in touch if you know how to improve this function. +func (k *RSAKey) Decrypt(_ io.Reader, ciphertext []byte, + _ crypto.DecrypterOpts) ([]byte, error) { + c := new(big.Int) + c.SetBytes(ciphertext) + // TODO: libgcrypt does this, not sure if required? + c.Rem(c, k.rsa.N) + // perform arithmetic manually + c.Exp(c, k.rsa.D, k.rsa.N) + return c.Bytes(), nil +} + +// Public implements the other required method of the crypto.Decrypter and +// crypto.Signer interfaces. +func (k *RSAKey) Public() crypto.PublicKey { + return k.rsa.Public() +} + +// Sign performs RSA signing as per gpg-agent. +func (k *RSAKey) Sign(r io.Reader, digest []byte, + o crypto.SignerOpts) ([]byte, error) { + return rsa.SignPKCS1v15(r, k.rsa, o.HashFunc(), digest) +} diff --git a/internal/gpg/testdata/key1.asc b/internal/keyservice/gpg/testdata/key1.asc similarity index 100% rename from internal/gpg/testdata/key1.asc rename to internal/keyservice/gpg/testdata/key1.asc diff --git a/internal/gpg/testdata/key2.asc b/internal/keyservice/gpg/testdata/key2.asc similarity index 100% rename from internal/gpg/testdata/key2.asc rename to internal/keyservice/gpg/testdata/key2.asc diff --git a/internal/gpg/testdata/key3.asc b/internal/keyservice/gpg/testdata/key3.asc similarity index 100% rename from internal/gpg/testdata/key3.asc rename to internal/keyservice/gpg/testdata/key3.asc diff --git a/internal/gpg/testdata/key4.asc b/internal/keyservice/gpg/testdata/key4.asc similarity index 100% rename from internal/gpg/testdata/key4.asc rename to internal/keyservice/gpg/testdata/key4.asc diff --git a/internal/keyservice/gpg/testdata/private/bar-protected@example.com.gpg b/internal/keyservice/gpg/testdata/private/bar-protected@example.com.gpg new file mode 100644 index 0000000..e82ae1b Binary files /dev/null and b/internal/keyservice/gpg/testdata/private/bar-protected@example.com.gpg differ diff --git a/internal/keyservice/gpg/testdata/private/bar@example.com.gpg b/internal/keyservice/gpg/testdata/private/bar@example.com.gpg new file mode 100644 index 0000000..26dd30d Binary files /dev/null and b/internal/keyservice/gpg/testdata/private/bar@example.com.gpg differ diff --git a/internal/keyservice/piv/keyservice.go b/internal/keyservice/piv/keyservice.go new file mode 100644 index 0000000..c7c403e --- /dev/null +++ b/internal/keyservice/piv/keyservice.go @@ -0,0 +1,99 @@ +package piv + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "fmt" + "sync" + + "github.com/smlx/piv-agent/internal/keyservice/gpg" + "go.uber.org/zap" +) + +// KeyService represents a collection of tokens and slots accessed by the +// Personal Identity Verifaction card interface. +type KeyService struct { + mu sync.Mutex + log *zap.Logger + securityKeys []SecurityKey +} + +// New constructs a PIV and returns it. +func New(l *zap.Logger) *KeyService { + return &KeyService{ + log: l, + } +} + +// Name returns the name of the keyservice. +func (*KeyService) Name() string { + return "PIV" +} + +// HaveKey takes a list of keygrips, and returns a boolean indicating if any of +// the given keygrips were found, the found keygrip, and an error, if any. +func (p *KeyService) HaveKey(keygrips [][]byte) (bool, []byte, error) { + securityKeys, err := p.SecurityKeys() + if err != nil { + return false, nil, fmt.Errorf("couldn't get security keys: %w", err) + } + for _, sk := range securityKeys { + for _, signingKey := range sk.SigningKeys() { + ecdsaPubKey, ok := signingKey.Public.(*ecdsa.PublicKey) + if !ok { + // TODO: handle other key types + continue + } + thisKeygrip, err := gpg.KeygripECDSA(ecdsaPubKey) + if err != nil { + return false, nil, fmt.Errorf("couldn't get keygrip: %w", err) + } + for _, kg := range keygrips { + if bytes.Equal(thisKeygrip, kg) { + return true, thisKeygrip, nil + } + } + } + } + return false, nil, nil +} + +// GetSigner returns a crypto.Signer associated with the given keygrip. +func (p *KeyService) GetSigner(keygrip []byte) (crypto.Signer, error) { + securityKeys, err := p.SecurityKeys() + if err != nil { + return nil, fmt.Errorf("couldn't get security keys: %w", err) + } + for _, sk := range securityKeys { + for _, signingKey := range sk.SigningKeys() { + ecdsaPubKey, ok := signingKey.Public.(*ecdsa.PublicKey) + if !ok { + // TODO: handle other key types + continue + } + thisKeygrip, err := gpg.KeygripECDSA(ecdsaPubKey) + if err != nil { + return nil, fmt.Errorf("couldn't get keygrip: %w", err) + } + if bytes.Equal(thisKeygrip, keygrip) { + cryptoPrivKey, err := sk.PrivateKey(&signingKey) + if err != nil { + return nil, fmt.Errorf("couldn't get private key from slot") + } + signingPrivKey, ok := cryptoPrivKey.(crypto.Signer) + if !ok { + return nil, fmt.Errorf("private key is invalid type") + } + return signingPrivKey, nil + } + } + } + return nil, fmt.Errorf("couldn't find keygrip") +} + +// GetDecrypter returns a crypto.Decrypter associated with the given keygrip. +func (p *KeyService) GetDecrypter(keygrip []byte) (crypto.Decrypter, error) { + // TODO: implement this + return nil, fmt.Errorf("not implemented") +} diff --git a/internal/pivservice/list.go b/internal/keyservice/piv/list.go similarity index 82% rename from internal/pivservice/list.go rename to internal/keyservice/piv/list.go index 9a80c91..bb995a2 100644 --- a/internal/pivservice/list.go +++ b/internal/keyservice/piv/list.go @@ -1,6 +1,6 @@ -package pivservice +package piv -//go:generate mockgen -source=list.go -destination=../mock/mock_pivservice.go -package=mock +//go:generate mockgen -source=list.go -destination=../../mock/mock_pivservice.go -package=mock import ( "crypto" @@ -19,14 +19,14 @@ type SecurityKey interface { AttestationCertificate() (*x509.Certificate, error) Card() string Close() error - PrivateKey(s *securitykey.SigningKey) (crypto.PrivateKey, error) - Serial() uint32 + Comment(*securitykey.SlotSpec) string + PrivateKey(*securitykey.SigningKey) (crypto.PrivateKey, error) SigningKeys() []securitykey.SigningKey StringsGPG(string, string) ([]string, error) StringsSSH() []string } -func (p *PIVService) reloadSecurityKeys() error { +func (p *KeyService) reloadSecurityKeys() error { // try to clean up and reset state for _, k := range p.securityKeys { _ = k.Close() @@ -47,13 +47,13 @@ func (p *PIVService) reloadSecurityKeys() error { p.securityKeys = append(p.securityKeys, sk) } if len(p.securityKeys) == 0 { - return fmt.Errorf("no valid security keys found") + p.log.Warn("no valid security keys found") } return nil } // SecurityKeys returns a slice containing all available security keys. -func (p *PIVService) SecurityKeys() ([]SecurityKey, error) { +func (p *KeyService) SecurityKeys() ([]SecurityKey, error) { p.mu.Lock() defer p.mu.Unlock() var err error diff --git a/internal/mock/mock_assuan.go b/internal/mock/mock_assuan.go index 4509530..6dffc68 100644 --- a/internal/mock/mock_assuan.go +++ b/internal/mock/mock_assuan.go @@ -5,46 +5,91 @@ package mock import ( + crypto "crypto" reflect "reflect" gomock "github.com/golang/mock/gomock" - pivservice "github.com/smlx/piv-agent/internal/pivservice" ) -// MockPIVService is a mock of PIVService interface. -type MockPIVService struct { +// MockKeyService is a mock of KeyService interface. +type MockKeyService struct { ctrl *gomock.Controller - recorder *MockPIVServiceMockRecorder + recorder *MockKeyServiceMockRecorder } -// MockPIVServiceMockRecorder is the mock recorder for MockPIVService. -type MockPIVServiceMockRecorder struct { - mock *MockPIVService +// MockKeyServiceMockRecorder is the mock recorder for MockKeyService. +type MockKeyServiceMockRecorder struct { + mock *MockKeyService } -// NewMockPIVService creates a new mock instance. -func NewMockPIVService(ctrl *gomock.Controller) *MockPIVService { - mock := &MockPIVService{ctrl: ctrl} - mock.recorder = &MockPIVServiceMockRecorder{mock} +// NewMockKeyService creates a new mock instance. +func NewMockKeyService(ctrl *gomock.Controller) *MockKeyService { + mock := &MockKeyService{ctrl: ctrl} + mock.recorder = &MockKeyServiceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockPIVService) EXPECT() *MockPIVServiceMockRecorder { +func (m *MockKeyService) EXPECT() *MockKeyServiceMockRecorder { return m.recorder } -// SecurityKeys mocks base method. -func (m *MockPIVService) SecurityKeys() ([]pivservice.SecurityKey, error) { +// GetDecrypter mocks base method. +func (m *MockKeyService) GetDecrypter(arg0 []byte) (crypto.Decrypter, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SecurityKeys") - ret0, _ := ret[0].([]pivservice.SecurityKey) + ret := m.ctrl.Call(m, "GetDecrypter", arg0) + ret0, _ := ret[0].(crypto.Decrypter) ret1, _ := ret[1].(error) return ret0, ret1 } -// SecurityKeys indicates an expected call of SecurityKeys. -func (mr *MockPIVServiceMockRecorder) SecurityKeys() *gomock.Call { +// GetDecrypter indicates an expected call of GetDecrypter. +func (mr *MockKeyServiceMockRecorder) GetDecrypter(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SecurityKeys", reflect.TypeOf((*MockPIVService)(nil).SecurityKeys)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDecrypter", reflect.TypeOf((*MockKeyService)(nil).GetDecrypter), arg0) +} + +// GetSigner mocks base method. +func (m *MockKeyService) GetSigner(arg0 []byte) (crypto.Signer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSigner", arg0) + ret0, _ := ret[0].(crypto.Signer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSigner indicates an expected call of GetSigner. +func (mr *MockKeyServiceMockRecorder) GetSigner(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSigner", reflect.TypeOf((*MockKeyService)(nil).GetSigner), arg0) +} + +// HaveKey mocks base method. +func (m *MockKeyService) HaveKey(arg0 [][]byte) (bool, []byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HaveKey", arg0) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].([]byte) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// HaveKey indicates an expected call of HaveKey. +func (mr *MockKeyServiceMockRecorder) HaveKey(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HaveKey", reflect.TypeOf((*MockKeyService)(nil).HaveKey), arg0) +} + +// Name mocks base method. +func (m *MockKeyService) Name() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Name") + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockKeyServiceMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockKeyService)(nil).Name)) } diff --git a/internal/mock/mock_keyservice.go b/internal/mock/mock_keyservice.go new file mode 100644 index 0000000..c22e200 --- /dev/null +++ b/internal/mock/mock_keyservice.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: keyservice.go + +// Package mock is a generated GoMock package. +package mock + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockPINEntryService is a mock of PINEntryService interface. +type MockPINEntryService struct { + ctrl *gomock.Controller + recorder *MockPINEntryServiceMockRecorder +} + +// MockPINEntryServiceMockRecorder is the mock recorder for MockPINEntryService. +type MockPINEntryServiceMockRecorder struct { + mock *MockPINEntryService +} + +// NewMockPINEntryService creates a new mock instance. +func NewMockPINEntryService(ctrl *gomock.Controller) *MockPINEntryService { + mock := &MockPINEntryService{ctrl: ctrl} + mock.recorder = &MockPINEntryServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPINEntryService) EXPECT() *MockPINEntryServiceMockRecorder { + return m.recorder +} + +// GetPGPPassphrase mocks base method. +func (m *MockPINEntryService) GetPGPPassphrase(arg0, arg1 string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPGPPassphrase", arg0, arg1) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPGPPassphrase indicates an expected call of GetPGPPassphrase. +func (mr *MockPINEntryServiceMockRecorder) GetPGPPassphrase(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPGPPassphrase", reflect.TypeOf((*MockPINEntryService)(nil).GetPGPPassphrase), arg0, arg1) +} diff --git a/internal/mock/mock_pivservice.go b/internal/mock/mock_pivservice.go index 373ae73..f3cba20 100644 --- a/internal/mock/mock_pivservice.go +++ b/internal/mock/mock_pivservice.go @@ -79,19 +79,33 @@ func (mr *MockSecurityKeyMockRecorder) Close() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockSecurityKey)(nil).Close)) } +// Comment mocks base method. +func (m *MockSecurityKey) Comment(arg0 *securitykey.SlotSpec) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Comment", arg0) + ret0, _ := ret[0].(string) + return ret0 +} + +// Comment indicates an expected call of Comment. +func (mr *MockSecurityKeyMockRecorder) Comment(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Comment", reflect.TypeOf((*MockSecurityKey)(nil).Comment), arg0) +} + // PrivateKey mocks base method. -func (m *MockSecurityKey) PrivateKey(s *securitykey.SigningKey) (crypto.PrivateKey, error) { +func (m *MockSecurityKey) PrivateKey(arg0 *securitykey.SigningKey) (crypto.PrivateKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PrivateKey", s) + ret := m.ctrl.Call(m, "PrivateKey", arg0) ret0, _ := ret[0].(crypto.PrivateKey) ret1, _ := ret[1].(error) return ret0, ret1 } // PrivateKey indicates an expected call of PrivateKey. -func (mr *MockSecurityKeyMockRecorder) PrivateKey(s interface{}) *gomock.Call { +func (mr *MockSecurityKeyMockRecorder) PrivateKey(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrivateKey", reflect.TypeOf((*MockSecurityKey)(nil).PrivateKey), s) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrivateKey", reflect.TypeOf((*MockSecurityKey)(nil).PrivateKey), arg0) } // Serial mocks base method. diff --git a/internal/pinentry/pinentry.go b/internal/pinentry/pinentry.go index 5869ed3..24cc6e5 100644 --- a/internal/pinentry/pinentry.go +++ b/internal/pinentry/pinentry.go @@ -12,6 +12,47 @@ type SecurityKey interface { Serial() uint32 } +// PINEntry implements useful pinentry service methods. +type PINEntry struct{} + +// GetPGPPassphrase uses pinentry to get the passphrase of the key with the +// given fingerprint. +func (*PINEntry) GetPGPPassphrase(userID, fingerprint string) ([]byte, error) { + p, err := pinentry.New() + if err != nil { + return []byte{}, fmt.Errorf("couldn't get pinentry client: %w", err) + } + defer p.Close() + err = p.Set("title", "piv-agent Passphrase Prompt") + if err != nil { + return nil, + fmt.Errorf("couldn't set title on passphrase pinentry: %w", err) + } + err = p.Set("prompt", "Please enter passphrase") + if err != nil { + return nil, + fmt.Errorf("couldn't set prompt on passphrase pinentry: %w", err) + } + err = p.Set("desc", fmt.Sprintf("UserID: %s, Fingerprint: %s", userID, + fingerprint)) + if err != nil { + return nil, + fmt.Errorf("couldn't set desc on passphrase pinentry: %w", err) + } + // optional PIN cache + err = p.Option("allow-external-password-cache") + if err != nil { + return nil, + fmt.Errorf("couldn't set option on passphrase pinentry: %w", err) + } + err = p.Set("KEYINFO", fingerprint) + if err != nil { + return nil, + fmt.Errorf("couldn't set KEYINFO on passphrase pinentry: %w", err) + } + return p.GetPin() +} + // GetPin uses pinentry to get the pin of the given token. func GetPin(k SecurityKey) func() (string, error) { return func() (string, error) { diff --git a/internal/pivservice/pivservice.go b/internal/pivservice/pivservice.go deleted file mode 100644 index cab2a6f..0000000 --- a/internal/pivservice/pivservice.go +++ /dev/null @@ -1,22 +0,0 @@ -package pivservice - -import ( - "sync" - - "go.uber.org/zap" -) - -// PIVService represents a collection of tokens and slots accessed by the -// Personal Identity Verifaction card interface. -type PIVService struct { - mu sync.Mutex - log *zap.Logger - securityKeys []SecurityKey -} - -// New constructs a PIV and returns it. -func New(l *zap.Logger) *PIVService { - return &PIVService{ - log: l, - } -} diff --git a/internal/securitykey/string.go b/internal/securitykey/string.go index 286b0d9..2da8b78 100644 --- a/internal/securitykey/string.go +++ b/internal/securitykey/string.go @@ -29,14 +29,19 @@ type Entity struct { SigningKey } +// Comment returns a comment suitable for e.g. the SSH public key format +func (k *SecurityKey) Comment(ss *SlotSpec) string { + return fmt.Sprintf("%v #%v, touch policy: %s", k.card, k.serial, + touchStringMap[ss.TouchPolicy]) +} + // StringsSSH returns an array of commonly formatted SSH keys as strings. func (k *SecurityKey) StringsSSH() []string { var ss []string for _, s := range k.SigningKeys() { ss = append(ss, fmt.Sprintf("%s %s\n", strings.TrimSuffix(string(ssh.MarshalAuthorizedKey(s.PubSSH)), "\n"), - fmt.Sprintf("%v #%v, touch policy: %s", k.Card(), k.Serial(), - touchStringMap[s.SlotSpec.TouchPolicy]))) + k.Comment(s.SlotSpec))) } return ss } @@ -46,10 +51,6 @@ func (k *SecurityKey) StringsSSH() []string { // on the yubikey for slots with touch policies that require it. func (k *SecurityKey) synthesizeEntities(name, email string) ([]Entity, error) { now := time.Now() - uid := packet.NewUserId(name, "piv-agent synthesized user ID", email) - if uid == nil { - return nil, errors.InvalidArgumentError("invalid characters in user ID") - } var entities []Entity for _, signingKey := range k.SigningKeys() { cryptoPrivKey, err := k.PrivateKey(&signingKey) @@ -60,6 +61,12 @@ func (k *SecurityKey) synthesizeEntities(name, email string) ([]Entity, error) { if !ok { return nil, fmt.Errorf("private key is invalid type") } + comment := fmt.Sprintf("piv-agent synthesized; touch-policy %s", + touchStringMap[signingKey.SlotSpec.TouchPolicy]) + uid := packet.NewUserId(name, comment, email) + if uid == nil { + return nil, errors.InvalidArgumentError("invalid characters in user ID") + } ecdsaPubKey, ok := signingKey.Public.(*ecdsa.PublicKey) if !ok { // TODO: handle ed25519 keys @@ -72,6 +79,7 @@ func (k *SecurityKey) synthesizeEntities(name, email string) ([]Entity, error) { CreationTime: now, SigType: packet.SigTypePositiveCert, // TODO: determine the key type + // TODO: support ECDH PubKeyAlgo: packet.PubKeyAlgoECDSA, Hash: crypto.SHA256, IssuerKeyId: &pub.KeyId, @@ -112,7 +120,7 @@ func (k *SecurityKey) StringsGPG(name, email string) ([]string, error) { w, err := armor.Encode(&buf, openpgp.PublicKeyType, map[string]string{ "Comment": fmt.Sprintf("%v #%v, touch policy: %s", - k.Card(), k.Serial(), touchStringMap[e.SlotSpec.TouchPolicy]), + k.card, k.serial, touchStringMap[e.SlotSpec.TouchPolicy]), }) if err != nil { return nil, fmt.Errorf("couldn't get PGP public key armorer: %w", err) diff --git a/internal/server/gpg.go b/internal/server/gpg.go index 655d708..9cde7ca 100644 --- a/internal/server/gpg.go +++ b/internal/server/gpg.go @@ -7,21 +7,29 @@ import ( "time" "github.com/smlx/piv-agent/internal/assuan" - "github.com/smlx/piv-agent/internal/pivservice" + "github.com/smlx/piv-agent/internal/keyservice/gpg" + "github.com/smlx/piv-agent/internal/keyservice/piv" "go.uber.org/zap" ) -// GPG represents an ssh-agent server. +// GPG represents a gpg-agent server. type GPG struct { - pivService *pivservice.PIVService - log *zap.Logger + log *zap.Logger + pivKeyService *piv.KeyService + gpgKeyService *gpg.KeyService // fallback keyfile keys } // NewGPG initialises a new gpg-agent server. -func NewGPG(p *pivservice.PIVService, l *zap.Logger) *GPG { +func NewGPG(piv *piv.KeyService, pinentry gpg.PINEntryService, + log *zap.Logger, path string) *GPG { + kfs, err := gpg.New(log, pinentry, path) + if err != nil { + log.Info("couldn't load keyfiles", zap.String("path", path), zap.Error(err)) + } return &GPG{ - pivService: p, - log: l, + log: log, + pivKeyService: piv, + gpgKeyService: kfs, } } @@ -31,6 +39,7 @@ func (g *GPG) Serve(ctx context.Context, l net.Listener, exit *time.Ticker, timeout time.Duration) error { // start serving connections conns := accept(g.log, l) + g.log.Debug("accepted gpg-agent connection") for { select { case conn, ok := <-conns: @@ -39,15 +48,15 @@ func (g *GPG) Serve(ctx context.Context, l net.Listener, exit *time.Ticker, } // reset the exit timer exit.Reset(timeout) - // if the client stops responding for 16 seconds, give up. - if err := conn.SetDeadline(time.Now().Add(16 * time.Second)); err != nil { + // if the client stops responding for 300 seconds, give up. + if err := conn.SetDeadline(time.Now().Add(300 * time.Second)); err != nil { return fmt.Errorf("couldn't set deadline: %v", err) } // init protocol state machine - a := assuan.New(conn, g.pivService) + a := assuan.New(conn, g.log, g.pivKeyService, g.gpgKeyService) // run the protocol state machine to completion // (client severs connection) - if err := a.Run(conn); err != nil { + if err := a.Run(); err != nil { return err } case <-ctx.Done(): diff --git a/internal/ssh/agent.go b/internal/ssh/agent.go index f0cd370..37818d4 100644 --- a/internal/ssh/agent.go +++ b/internal/ssh/agent.go @@ -10,9 +10,9 @@ import ( "path/filepath" "sync" + "github.com/smlx/piv-agent/internal/keyservice/piv" "github.com/smlx/piv-agent/internal/notify" pinentry "github.com/smlx/piv-agent/internal/pinentry" - "github.com/smlx/piv-agent/internal/pivservice" "go.uber.org/zap" gossh "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -22,7 +22,7 @@ import ( // https://pkg.go.dev/golang.org/x/crypto/ssh/agent#Agent type Agent struct { mu sync.Mutex - pivService *pivservice.PIVService + piv *piv.KeyService log *zap.Logger loadKeyfile bool } @@ -37,8 +37,8 @@ var ErrUnknownKey = errors.New("requested signature of unknown key") var passphrases = map[string][]byte{} // NewAgent returns a new Agent. -func NewAgent(p *pivservice.PIVService, log *zap.Logger, loadKeyfile bool) *Agent { - return &Agent{pivService: p, log: log, loadKeyfile: loadKeyfile} +func NewAgent(p *piv.KeyService, log *zap.Logger, loadKeyfile bool) *Agent { + return &Agent{piv: p, log: log, loadKeyfile: loadKeyfile} } // List returns the identities known to the agent. @@ -64,20 +64,16 @@ func (a *Agent) List() ([]*agent.Key, error) { // returns the identities from hardware tokens func (a *Agent) securityKeyIDs() ([]*agent.Key, error) { var keys []*agent.Key - securityKeys, err := a.pivService.SecurityKeys() + securityKeys, err := a.piv.SecurityKeys() if err != nil { return nil, fmt.Errorf("couldn't get security keys: %v", err) } for _, k := range securityKeys { for _, s := range k.SigningKeys() { keys = append(keys, &agent.Key{ - Format: s.PubSSH.Type(), - Blob: s.PubSSH.Marshal(), - Comment: fmt.Sprintf( - `Security Key "%s" #%d PIV Slot %x`, - s.PubSSH, - k.Serial(), - s.SlotSpec.Slot.Key), + Format: s.PubSSH.Type(), + Blob: s.PubSSH.Marshal(), + Comment: k.Comment(s.SlotSpec), }) } } @@ -201,7 +197,7 @@ func (a *Agent) Signers() ([]gossh.Signer, error) { // get signers for all keys stored in hardware tokens func (a *Agent) tokenSigners() ([]gossh.Signer, error) { var signers []gossh.Signer - securityKeys, err := a.pivService.SecurityKeys() + securityKeys, err := a.piv.SecurityKeys() if err != nil { return nil, fmt.Errorf("couldn't get security keys: %v", err) }