mirror of
https://github.com/romanz/amodem.git
synced 2026-05-03 08:27:26 +08:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
513b1259c4 | ||
|
|
5984a58f65 | ||
|
|
e437591dd5 | ||
|
|
94ad9648f8 | ||
|
|
ed64f94bd3 | ||
|
|
bf9f2593b5 | ||
|
|
995fba3e93 | ||
|
|
34b269be1e | ||
|
|
5cfdc7734b | ||
|
|
2cb64991c3 | ||
|
|
a30cab1156 | ||
|
|
b30e6a8408 | ||
|
|
8041ed883f | ||
|
|
a71fa8de9e | ||
|
|
ddd823d976 | ||
|
|
fec84288be | ||
|
|
71f357c1bf | ||
|
|
8f1d008eb2 | ||
|
|
7a351acf15 | ||
|
|
7f9aa2b147 | ||
|
|
eed168341c | ||
|
|
8b85090fba | ||
|
|
8708b1e16d | ||
|
|
03e7fc48e9 | ||
|
|
4968ca7ff3 | ||
|
|
6b6d9f5d20 | ||
|
|
c22109df24 | ||
|
|
47ce035e79 | ||
|
|
36cbba6c57 | ||
|
|
6afe20350b | ||
|
|
fa171e8923 | ||
|
|
f0bda9a3e6 | ||
|
|
71b56e15d7 | ||
|
|
3b9c00e02a | ||
|
|
dcee59a19e | ||
|
|
a274de30b8 | ||
|
|
4fe9e437ad | ||
|
|
d04527a8ed | ||
|
|
3329c29cb4 | ||
|
|
df2cb52f8d | ||
|
|
f36ef4ffe0 | ||
|
|
f74de828fc | ||
|
|
912b1cde7a | ||
|
|
b7a8c42893 | ||
|
|
1e6c4e6930 | ||
|
|
a8f19e4150 | ||
|
|
6a9fdf75e2 | ||
|
|
6bc5b6af5e | ||
|
|
8672a6901a | ||
|
|
672af98ad7 | ||
|
|
ed531cfff8 | ||
|
|
bd1ae0f091 | ||
|
|
0c762e8998 | ||
|
|
bd0df4f801 | ||
|
|
3d1639d271 | ||
|
|
bea899d1ef | ||
|
|
ccc2174775 | ||
|
|
afa3fdb89c | ||
|
|
2ca3941cfa | ||
|
|
b1bd6cb690 | ||
|
|
766536d2c4 | ||
|
|
91f70e7a96 | ||
|
|
cf5bfd960a | ||
|
|
4bd769f138 | ||
|
|
91b850f184 | ||
|
|
c6bb090dfc | ||
|
|
fef4fd06c9 | ||
|
|
bc691ae795 | ||
|
|
61e516e200 | ||
|
|
543ff7021d | ||
|
|
2e0cfc8088 | ||
|
|
18f33f8a08 | ||
|
|
2973413995 | ||
|
|
2360693dc5 | ||
|
|
7443fc6512 | ||
|
|
5efb752979 | ||
|
|
4546cd674b | ||
|
|
5dba12f144 | ||
|
|
887561de9f | ||
|
|
6d730e0a5b | ||
|
|
d0732d16e8 | ||
|
|
dafb80ad7a | ||
|
|
df6249b071 | ||
|
|
942f01418b | ||
|
|
93b548b737 | ||
|
|
329f07249a | ||
|
|
a1f7088d33 | ||
|
|
25f066e113 | ||
|
|
0699273d49 | ||
|
|
92c352e860 | ||
|
|
34c03a462c | ||
|
|
2e688ccac9 |
@@ -1,7 +1,7 @@
|
||||
[bumpversion]
|
||||
commit = True
|
||||
tag = True
|
||||
current_version = 0.11.0
|
||||
current_version = 0.13.1
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return
|
||||
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return,fixme,duplicate-code,cyclic-import
|
||||
|
||||
[SIMILARITIES]
|
||||
min-similarity-lines=5
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
sudo: false
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ This project allows you to use various hardware security devices to operate GPG
|
||||
|
||||
You can do things like sign your emails, git commits, and software packages, manage your passwords (with [pass](https://www.passwordstore.org/) and [gopass](https://www.justwatch.com/gopass/), among others), authenticate web tunnels and file transfers, and more.
|
||||
|
||||
See SatoshiLabs' blog posts about this feature:
|
||||
See the following blog posts about this tool:
|
||||
|
||||
- [TREZOR Firmware 1.3.4 enables SSH login](https://medium.com/@satoshilabs/trezor-firmware-1-3-4-enables-ssh-login-86a622d7e609)
|
||||
- [TREZOR Firmware 1.3.6 — GPG Signing, SSH Login Updates and Advanced Transaction Features for Segwit](https://medium.com/@satoshilabs/trezor-firmware-1-3-6-20a7df6e692)
|
||||
- [TREZOR Firmware 1.4.0 — GPG decryption support](https://www.reddit.com/r/TREZOR/comments/50h8r9/new_trezor_firmware_fidou2f_and_initial_ethereum/d7420q7/)
|
||||
- [A Step by Step Guide to Securing your SSH Keys with the Ledger Nano S](https://thoughts.t37.net/a-step-by-step-guide-to-securing-your-ssh-keys-with-the-ledger-nano-s-92e58c64a005)
|
||||
|
||||
Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Keepkey](https://www.keepkey.com/), and [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s) are supported.
|
||||
|
||||
@@ -23,3 +24,4 @@ Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/)
|
||||
Note: If you're using Windows, see [trezor-ssh-agent](https://github.com/martin-lizner/trezor-ssh-agent) by Martin Lízner.
|
||||
|
||||
* **GPG** instructions and common use cases are [here](doc/README-GPG.md)
|
||||
* Instructions to configure a Trezor-style **PIN entry** program are [here](doc/README-PINENTRY.md)
|
||||
@@ -3,15 +3,15 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='trezor_agent',
|
||||
version='0.9.2',
|
||||
version='0.10.0',
|
||||
description='Using Trezor as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['trezor_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
'trezor>=0.9.0'
|
||||
'libagent>=0.13.0',
|
||||
'trezor[hidapi]>=0.11.0'
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
|
||||
15
contrib/neopg-trezor
Executable file
15
contrib/neopg-trezor
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
agent = 'trezor-gpg-agent'
|
||||
binary = 'neopg'
|
||||
|
||||
if sys.argv[1:2] == ['agent']:
|
||||
os.execvp(agent, [agent, '-vv'] + sys.argv[2:])
|
||||
else:
|
||||
# HACK: pass this script's path as argv[0], so it will be invoked again
|
||||
# when NeoPG tries to run its own agent:
|
||||
# https://github.com/das-labor/neopg/blob/1fe50460abe01febb118641e37aa50bc429a1786/src/neopg.cpp#L114
|
||||
# https://github.com/das-labor/neopg/blob/1fe50460abe01febb118641e37aa50bc429a1786/legacy/gnupg/common/asshelp.cpp#L217
|
||||
os.execvp(binary, [__file__, 'gpg2'] + sys.argv[1:])
|
||||
@@ -12,11 +12,11 @@ So when you `ssh` to a machine - rather than consult the normal ssh-agent (which
|
||||
|
||||
## Key Naming
|
||||
|
||||
`trezor-agent` goes to great length to avoid using the valuable parent key.
|
||||
`trezor-agent` goes to great length to avoid using the valuable parent key.
|
||||
|
||||
The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign).
|
||||
The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign).
|
||||
|
||||
And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else.
|
||||
And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else.
|
||||
|
||||
It therefore uses only derived child keys pairs instead (according to the [BIP-0032: Hierarchical Deterministic Wallets][1] system) - and ones on different leafs. So the parent key is only used within the device for creating the child keys - and not exposed in any way to `trezor-agent`.
|
||||
|
||||
@@ -26,7 +26,7 @@ It is common for SSH users to use one (or a few) private keys with SSH on all se
|
||||
|
||||
So taking a commmand such as:
|
||||
|
||||
$ trezor-agent -c user@fqdn.com
|
||||
$ trezor-agent -c user@fqdn.com
|
||||
|
||||
The `trezor-agent` will take the `user`@`fqdn.com`; canonicalise it (e.g. to add the ssh default port number if none was specified) and then apply some simple hashing (See [SLIP-0013 : Authentication using deterministic hierarchy][2]). The resulting 128bit hash is then used to construct a lead 'HD node' that contains an extened public private *child* key.
|
||||
|
||||
@@ -36,16 +36,16 @@ The `trezor-agent` then instructs SSH to connect to the server. It will then eng
|
||||
|
||||
### GPG
|
||||
|
||||
GPG uses much the same approach as SSH, expect in this it relies on [SLIP-0017 : ECDH using deterministic hierarchy][3] for the mapping to an ECDH key and it maps these to the normal GPG child key infrastructure.
|
||||
GPG uses much the same approach as SSH, except in this case it relies on [SLIP-0017 : ECDH using deterministic hierarchy][3] for the mapping to an ECDH key and it maps these to the normal GPG child key infrastructure.
|
||||
|
||||
Note: Keepkey does not support en-/de-cryption at this time.
|
||||
|
||||
### Index
|
||||
|
||||
The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address.
|
||||
The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address.
|
||||
|
||||
This feature is currently not used -- it is set to '0'. This may change in the future.
|
||||
|
||||
[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
|
||||
[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
|
||||
[2]: https://github.com/satoshilabs/slips/blob/master/slip-0013.md
|
||||
[3]: https://github.com/satoshilabs/slips/blob/master/slip-0017.md
|
||||
|
||||
@@ -14,11 +14,16 @@ You can install them on these distributions as follows:
|
||||
|
||||
$ apt-get install python3-pip python3-dev python3-tk libusb-1.0-0-dev libudev-dev
|
||||
|
||||
##### Fedora/RedHat
|
||||
##### RedHat
|
||||
|
||||
$ yum install python3-pip python3-devel python3-tk libusb-devel libudev-devel \
|
||||
gcc redhat-rpm-config
|
||||
|
||||
##### Fedora
|
||||
|
||||
$ dnf install python3-pip python3-devel python3-tkinter libusb-devel libudev-devel \
|
||||
gcc redhat-rpm-config
|
||||
|
||||
##### OpenSUSE
|
||||
|
||||
$ zypper install python-pip python-devel python-tk libusb-1_0-devel libudev-devel
|
||||
@@ -29,6 +34,12 @@ dependencies instead:
|
||||
|
||||
$ zypper install python3-pip python3-devel python3-tk libusb-1_0-devel libudev-devel
|
||||
|
||||
##### macOS
|
||||
|
||||
There are many different options to install python environment on macOS ([official](https://www.python.org/downloads/mac-osx/), [anaconda](https://conda.io/docs/user-guide/install/macos.html), ..). Most importantly you need `libusb`. Probably the easiest way is via [homebrew](https://brew.sh/)
|
||||
|
||||
$ brew install libusb
|
||||
|
||||
### GPG
|
||||
|
||||
If you intend to use GPG make sure you have GPG installed and up to date. This software requires a GPG version >= 2.1.11.
|
||||
@@ -55,6 +66,7 @@ gpg (GnuPG) 2.1.15
|
||||
3. Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
||||
|
||||
```
|
||||
$ pip3 install Cython hidapi
|
||||
$ pip3 install trezor_agent
|
||||
```
|
||||
|
||||
@@ -65,7 +77,11 @@ gpg (GnuPG) 2.1.15
|
||||
$ pip3 install --user -e trezor-agent/agents/trezor
|
||||
```
|
||||
|
||||
Read [these instructions](https://github.com/romanz/python-trezor#pin-entering) on how to enter your PIN with the PIN entry.
|
||||
Or, through Homebrew on macOS:
|
||||
|
||||
```
|
||||
$ brew install trezor-agent
|
||||
```
|
||||
|
||||
# 3. Install the KeepKey agent
|
||||
|
||||
@@ -80,6 +96,12 @@ Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_ag
|
||||
$ pip3 install keepkey_agent
|
||||
```
|
||||
|
||||
Or, on Mac using Homebrew:
|
||||
|
||||
```
|
||||
$ homebrew install keepkey-agent
|
||||
```
|
||||
|
||||
Or, directly from the latest source code:
|
||||
|
||||
```
|
||||
|
||||
@@ -23,6 +23,8 @@ Thanks!
|
||||
|
||||
Follow the instructions provided to complete the setup. Keep note of the timestamp value which you'll need if you want to regenerate the key later.
|
||||
|
||||
If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md).
|
||||
|
||||
2. Add `export GNUPGHOME=~/.gnupg/(trezor|keepkey|ledger)` to your `.bashrc` or other environment file.
|
||||
|
||||
This `GNUPGHOME` contains your hardware keyring and agent settings. This agent software assumes all keys are backed by hardware devices so you can't use standard GPG keys in `GNUPGHOME` (if you do mix keys you'll receive an error when you attempt to use them).
|
||||
@@ -68,6 +70,21 @@ $ git tag v1.2.3 --sign # create GPG-signed tag
|
||||
$ git tag v1.2.3 --verify # verify tag signature
|
||||
```
|
||||
|
||||
Note that your git email has to correlate to your gpg key email. If you use a different email for git, you'll need to either generate a new gpg key for that email or set your git email using the command:
|
||||
|
||||
````
|
||||
$ git config user.email foo@example.com
|
||||
````
|
||||
|
||||
If your git email is configured incorrectly, you will receive the error:
|
||||
|
||||
````
|
||||
error: gpg failed to sign the data
|
||||
fatal: failed to write commit object
|
||||
````
|
||||
|
||||
when committing to git.
|
||||
|
||||
### Manage passwords
|
||||
|
||||
Password managers such as [pass](https://www.passwordstore.org/) and [gopass](https://www.justwatch.com/gopass/) rely on GPG for encryption so you can use your device with them too.
|
||||
@@ -181,3 +198,54 @@ Press <enter> to keep the current choice[*], or type selection number: 0
|
||||
### Sign and decrypt email
|
||||
|
||||
Follow [these instructions](enigmail.md) to set up Enigmail in Thunderbird.
|
||||
|
||||
### Start the agent as a systemd unit
|
||||
|
||||
##### 1. Create these files in `~/.config/systemd/user`
|
||||
|
||||
Replace `trezor` with `keepkey` or `ledger` as required.
|
||||
|
||||
###### `trezor-gpg-agent.service`
|
||||
|
||||
````
|
||||
[Unit]
|
||||
Description=trezor-gpg-agent
|
||||
Requires=trezor-gpg-agent.socket
|
||||
|
||||
[Service]
|
||||
Type=Simple
|
||||
Environment="GNUPGHOME=%h/.gnupg/trezor"
|
||||
Environment="PATH=/bin:/usr/bin:/usr/local/bin:%h/.local/bin"
|
||||
ExecStart=/usr/bin/trezor-gpg-agent -vv
|
||||
````
|
||||
|
||||
If you've installed `trezor-agent` locally you may have to change the path in `ExecStart=`.
|
||||
|
||||
###### `trezor-gpg-agent.socket`
|
||||
|
||||
````
|
||||
[Unit]
|
||||
Description=trezor-gpg-agent socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=%t/gnupg/S.gpg-agent
|
||||
FileDescriptorName=std
|
||||
SocketMode=0600
|
||||
DirectoryMode=0700
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
````
|
||||
|
||||
##### 2. Stop trezor-gpg-agent if it's already running
|
||||
|
||||
```
|
||||
killall trezor-gpg-agent
|
||||
```
|
||||
|
||||
##### 3. Run
|
||||
|
||||
```
|
||||
systemctl --user start trezor-gpg-agent.service trezor-gpg-agent.socket
|
||||
systemctl --user enable trezor-gpg-agent.socket
|
||||
```
|
||||
|
||||
31
doc/README-NeoPG.md
Normal file
31
doc/README-NeoPG.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# NeoPG experimental support
|
||||
|
||||
1. Download build and install NeoPG from [source code](https://github.com/das-labor/neopg#installation).
|
||||
|
||||
2. Generate Ed25519-based identity (using a [special wrapper](https://github.com/romanz/trezor-agent/blob/c22109df24c6eb8263aa40183a016be3437b1a0c/contrib/neopg-trezor) to invoke TREZOR-based agent):
|
||||
|
||||
```bash
|
||||
$ export NEOPG_BINARY=$PWD/contrib/neopg-trezor
|
||||
$ $NEOPG_BINARY --help
|
||||
|
||||
$ export GNUPGHOME=/tmp/homedir
|
||||
$ trezor-gpg init "FooBar" -e ed25519
|
||||
sec ed25519 2018-07-01 [SC]
|
||||
802AF7E2DCF4491FFBB2F032341E95EF57CD7D5E
|
||||
uid [ultimate] FooBar
|
||||
ssb cv25519 2018-07-01 [E]
|
||||
```
|
||||
|
||||
3. Sign and verify signatures:
|
||||
```
|
||||
$ $NEOPG_BINARY -v --detach-sign FILE
|
||||
neopg: starting agent '/home/roman/Code/trezor/trezor-agent/contrib/neopg-trezor'
|
||||
neopg: using pgp trust model
|
||||
neopg: writing to 'FILE.sig'
|
||||
neopg: EDDSA/SHA256 signature from: "341E95EF57CD7D5E FooBar"
|
||||
|
||||
$ $NEOPG_BINARY --verify FILE.sig FILE
|
||||
neopg: Signature made Sun Jul 1 11:52:51 2018 IDT
|
||||
neopg: using EDDSA key 802AF7E2DCF4491FFBB2F032341E95EF57CD7D5E
|
||||
neopg: Good signature from "FooBar" [ultimate]
|
||||
```
|
||||
69
doc/README-PINENTRY.md
Normal file
69
doc/README-PINENTRY.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Custom PIN entry
|
||||
|
||||
In order to use the default GPG pinentry program, install one of the following Linux packages:
|
||||
|
||||
```
|
||||
$ apt install pinentry-{curses,gnome3,qt}
|
||||
```
|
||||
|
||||
or (on macOS):
|
||||
|
||||
```
|
||||
$ brew install pinentry
|
||||
```
|
||||
|
||||
By default a standard GPG PIN entry program is used when entering your Trezor PIN, but it's difficult to use if you don't have a numeric keypad or want to use your mouse.
|
||||
|
||||
You can specify a custom PIN entry program such as [trezor-gpg-pinentry-tk](https://github.com/rendaw/trezor-gpg-pinentry-tk) (and separately, a passphrase entry program) to match your workflow.
|
||||
|
||||
The below examples use `trezor-gpg-pinentry-tk` but any GPG compatible PIN entry can be used.
|
||||
|
||||
##### 1. Install the PIN entry
|
||||
|
||||
Run
|
||||
|
||||
```
|
||||
pip install trezor-gpg-pinentry-tk
|
||||
```
|
||||
|
||||
##### 2. SSH
|
||||
|
||||
Add the flag `--pin-entry-binary trezor-gpg-pinentry-tk` to all calls to `trezor-agent`.
|
||||
|
||||
To automatically use this flag, add the line `pinentry=trezor-gpg-pinentry-tk` to `~/.ssh/agent.config`. **Note** this is currently broken due to [this dependency issue](https://github.com/bw2/ConfigArgParse/issues/114).
|
||||
|
||||
If you run the SSH agent with Systemd you'll need to add `--pin-entry-binary` to the `ExecStart` command. You may also need to add this line:
|
||||
|
||||
```
|
||||
Environment="DISPLAY=:0"
|
||||
```
|
||||
|
||||
to the `[Service]` section to tell the PIN entry program how to connect to the X11 server.
|
||||
|
||||
##### 3. GPG
|
||||
|
||||
If you haven't completed initialization yet, run:
|
||||
|
||||
```
|
||||
$ (trezor|keepkey|ledger)-gpg init --pin-entry-binary trezor-gpg-pinentry-tk "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||
```
|
||||
|
||||
to configure the PIN entry at the same time.
|
||||
|
||||
Otherwise, open `$GNUPGHOME/trezor/run-agent.sh` and change the `--pin-entry-binary` option to `trezor-gpg-pinentry-tk` and run:
|
||||
|
||||
```
|
||||
killall trezor-gpg-agent
|
||||
```
|
||||
|
||||
##### 4. Troubleshooting
|
||||
|
||||
Any problems running the PIN entry program with GPG should appear in `$HOME/.gnupg/trezor/gpg-agent.log`.
|
||||
|
||||
You can get similar logs for SSH by specifying `--log-file` in the SSH command line.
|
||||
|
||||
The passphrase is cached by the agent (after its first entry), which needs to be restarted in order to reset the passphrase:
|
||||
```
|
||||
$ killall trezor-agent # (for SSH)
|
||||
$ killall trezor-gpg-agent # (for GPG)
|
||||
```
|
||||
@@ -6,6 +6,8 @@ SSH requires no configuration, but you may put common command line options in `~
|
||||
|
||||
See `(trezor|keepkey|ledger)-agent -h` for details on supported options and the configuration file format.
|
||||
|
||||
If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md).
|
||||
|
||||
## 2. Usage
|
||||
|
||||
Use the `(trezor|keepkey|ledger)-agent` program to work with SSH. It has three main modes of operation:
|
||||
@@ -30,6 +32,7 @@ $ (trezor|keepkey|ledger)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS
|
||||
```
|
||||
|
||||
to start the agent in the background and execute the command with environment variables set up to use the SSH agent. The specified identity is used for all SSH connections. The agent will exit after the command completes.
|
||||
Note the `--` separator, which is used to separate `trezor-agent`'s arguments from the SSH command arguments.
|
||||
|
||||
As a shortcut you can run
|
||||
|
||||
@@ -39,7 +42,7 @@ $ (trezor|keepkey|ledger)-agent identity@myhost -s
|
||||
|
||||
to start a shell with the proper environment.
|
||||
|
||||
##### 2. Connect to a server directly via `(trezor|keepkey|ledger)-agent`
|
||||
##### 3. Connect to a server directly via `(trezor|keepkey|ledger)-agent`
|
||||
|
||||
If you just want to connect to a server this is the simplest way to do it:
|
||||
|
||||
@@ -82,21 +85,29 @@ would allow you to login using the corresponding private key signature.
|
||||
|
||||
### Access remote Git/Mercurial repositories
|
||||
|
||||
Copy your public key and register it in your repository web interface (e.g. [GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/)):
|
||||
Export your public key and register it in your repository web interface
|
||||
(e.g. [GitHub](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/)):
|
||||
|
||||
$ trezor-agent -v -e ed25519 git@github.com | xclip
|
||||
$ trezor-agent -v -e ed25519 git@github.com > ~/.ssh/github.pub
|
||||
|
||||
Add the following configuration to your `~/.ssh/config` file:
|
||||
|
||||
Host github.com
|
||||
IdentityFile ~/.ssh/github.pub
|
||||
|
||||
Use the following Bash alias for convenient Git operations:
|
||||
|
||||
$ alias git_hub='trezor-agent -v -e ed25519 git@github.com -- git'
|
||||
$ alias ssh-shell='trezor-agent ~/.ssh/github.pub -v --shell'
|
||||
|
||||
Replace `git` with `git_hub` for remote operations:
|
||||
Now, you can use regular Git commands under the "SSH-enabled" sub-shell:
|
||||
|
||||
$ git_hub push origin master
|
||||
$ ssh-shell
|
||||
$ git push origin master
|
||||
|
||||
The same works for Mercurial (e.g. on [BitBucket](https://confluence.atlassian.com/bitbucket/set-up-ssh-for-mercurial-728138122.html)):
|
||||
|
||||
$ trezor-agent -v -e ed25519 git@bitbucket.org -- hg push
|
||||
$ ssh-shell
|
||||
$ hg push
|
||||
|
||||
### Start the agent as a systemd unit
|
||||
|
||||
@@ -112,12 +123,24 @@ Description=trezor-agent SSH agent
|
||||
Requires=trezor-ssh-agent.socket
|
||||
|
||||
[Service]
|
||||
Type=Simple
|
||||
Type=simple
|
||||
Environment="DISPLAY=:0"
|
||||
Environment="PATH=/bin:/usr/bin:/usr/local/bin:%h/.local/bin"
|
||||
ExecStart=/usr/bin/trezor-agent --foreground --sock-path %t/trezor-agent/S.ssh IDENTITY
|
||||
````
|
||||
|
||||
If you've installed `trezor-agent` locally you may have to change the path in `ExecStart=`.
|
||||
|
||||
Replace `IDENTITY` with the identity you used when exporting the public key.
|
||||
|
||||
If you have multiple Trezors connected, you can select which one to use via a `TREZOR_PATH`
|
||||
environment variable. Use `trezorctl list` to find the correct path. Then add it
|
||||
to the agent with the following line:
|
||||
````
|
||||
Environment="TREZOR_PATH=<your path here>"
|
||||
````
|
||||
Note that USB paths depend on the _USB port_ which you use.
|
||||
|
||||
###### `trezor-ssh-agent.socket`
|
||||
|
||||
````
|
||||
@@ -155,7 +178,7 @@ export SSH_AUTH_SOCK=$(systemctl show --user --property=Listen trezor-ssh-agent.
|
||||
If SSH connection fails to work, please open an [issue](https://github.com/romanz/trezor-agent/issues)
|
||||
with a verbose log attached (by running `trezor-agent -vv`) .
|
||||
|
||||
##### Incompatible SSH options
|
||||
##### `IdentitiesOnly` SSH option
|
||||
|
||||
Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`.
|
||||
|
||||
@@ -166,6 +189,21 @@ Note that your local SSH configuration may ignore `trezor-agent`, if it has `Ide
|
||||
This option is intended for situations where ssh-agent offers many different identities.
|
||||
The default is “no”.
|
||||
|
||||
If you are failing to connect, try running:
|
||||
If you are failing to connect, save your public key using:
|
||||
|
||||
$ trezor-agent -vv user@host -- ssh -vv -oIdentitiesOnly=no user@host
|
||||
$ trezor-agent -vv foobar@hostname.com > ~/.ssh/hostname.pub
|
||||
|
||||
And add the following lines to `~/.ssh/config` (providing the public key explicitly to SSH):
|
||||
|
||||
Host hostname.com
|
||||
User foobar
|
||||
IdentityFile ~/.ssh/hostname.pub
|
||||
|
||||
Then, the following commands should successfully command to the remote host:
|
||||
|
||||
$ trezor-agent -v foobar@hostname.com -s
|
||||
$ ssh foobar@hostname.com
|
||||
|
||||
or,
|
||||
|
||||
$ trezor-agent -v foobar@hostname.com -c
|
||||
|
||||
@@ -39,10 +39,6 @@ class FakeDevice(interface.Device):
|
||||
self.vk = self.sk.get_verifying_key()
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn = None
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
_verify_support(identity)
|
||||
|
||||
@@ -59,7 +59,7 @@ class DeviceError(Error):
|
||||
"""Error during device operation."""
|
||||
|
||||
|
||||
class Identity(object):
|
||||
class Identity:
|
||||
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
||||
|
||||
def __init__(self, identity_str, curve_name):
|
||||
@@ -102,7 +102,7 @@ class Identity(object):
|
||||
return self.curve_name
|
||||
|
||||
|
||||
class Device(object):
|
||||
class Device:
|
||||
"""Abstract cryptographic hardware device interface."""
|
||||
|
||||
def __init__(self):
|
||||
@@ -113,6 +113,14 @@ class Device(object):
|
||||
"""Connect to device, otherwise raise NotFoundError."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def close(self):
|
||||
"""Close connection to device.
|
||||
|
||||
By default, close the underlying connection. Overriding classes
|
||||
can perform their own cleanup.
|
||||
"""
|
||||
self.conn.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow usage as context manager."""
|
||||
self.conn = self.connect()
|
||||
@@ -121,7 +129,7 @@ class Device(object):
|
||||
def __exit__(self, *args):
|
||||
"""Close and mark as disconnected."""
|
||||
try:
|
||||
self.conn.close()
|
||||
self.close()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('close failed: %s', e)
|
||||
self.conn = None
|
||||
|
||||
@@ -32,6 +32,9 @@ class KeepKey(trezor.Trezor):
|
||||
|
||||
required_version = '>=1.0.4'
|
||||
|
||||
def _override_state_handler(self, _):
|
||||
"""No support for `state` handling on Keepkey."""
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
_verify_support(identity, ecdh)
|
||||
|
||||
@@ -9,6 +9,6 @@ from keepkeylib.transport_hid import HidTransport
|
||||
from keepkeylib.types_pb2 import IdentityType
|
||||
|
||||
|
||||
def enumerate_transports():
|
||||
"""Returns USB HID transports."""
|
||||
return [HidTransport(p) for p in HidTransport.enumerate()]
|
||||
def find_device():
|
||||
"""Returns first USB HID transport."""
|
||||
return next(HidTransport(p) for p in HidTransport.enumerate())
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
import mnemonic
|
||||
import semver
|
||||
|
||||
from . import interface
|
||||
@@ -27,55 +26,7 @@ class Trezor(interface.Device):
|
||||
required_version = '>=1.4.0'
|
||||
|
||||
ui = None # can be overridden by device's users
|
||||
|
||||
def _override_pin_handler(self, conn):
|
||||
if self.ui is None:
|
||||
return
|
||||
|
||||
def new_handler(_):
|
||||
try:
|
||||
scrambled_pin = self.ui.get_pin()
|
||||
result = self._defs.PinMatrixAck(pin=scrambled_pin)
|
||||
if not set(scrambled_pin).issubset('123456789'):
|
||||
raise self._defs.PinException(
|
||||
None, 'Invalid scrambled PIN: {!r}'.format(result.pin))
|
||||
return result
|
||||
except: # noqa
|
||||
conn.init_device()
|
||||
raise
|
||||
|
||||
conn.callback_PinMatrixRequest = new_handler
|
||||
|
||||
cached_passphrase_ack = None
|
||||
|
||||
def _override_passphrase_handler(self, conn):
|
||||
if self.ui is None:
|
||||
return
|
||||
|
||||
def new_handler(msg):
|
||||
try:
|
||||
if msg.on_device is True:
|
||||
return self._defs.PassphraseAck()
|
||||
if self.__class__.cached_passphrase_ack:
|
||||
log.debug('re-using cached %s passphrase', self)
|
||||
return self.__class__.cached_passphrase_ack
|
||||
|
||||
passphrase = self.ui.get_passphrase()
|
||||
passphrase = mnemonic.Mnemonic.normalize_string(passphrase)
|
||||
ack = self._defs.PassphraseAck(passphrase=passphrase)
|
||||
|
||||
length = len(ack.passphrase)
|
||||
if length > 50:
|
||||
msg = 'Too long passphrase ({} chars)'.format(length)
|
||||
raise ValueError(msg)
|
||||
|
||||
self.__class__.cached_passphrase_ack = ack
|
||||
return ack
|
||||
except: # noqa
|
||||
conn.init_device()
|
||||
raise
|
||||
|
||||
conn.callback_PassphraseRequest = new_handler
|
||||
cached_state = None
|
||||
|
||||
def _verify_version(self, connection):
|
||||
f = connection.features
|
||||
@@ -95,15 +46,15 @@ class Trezor(interface.Device):
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first available interface."""
|
||||
transports = self._defs.enumerate_transports()
|
||||
if not transports:
|
||||
transport = self._defs.find_device()
|
||||
if not transport:
|
||||
raise interface.NotFoundError('{} not connected'.format(self))
|
||||
|
||||
log.debug('transports: %s', transports)
|
||||
for _ in range(5):
|
||||
connection = self._defs.Client(transports[0])
|
||||
self._override_pin_handler(connection)
|
||||
self._override_passphrase_handler(connection)
|
||||
log.debug('using transport: %s', transport)
|
||||
for _ in range(5): # Retry a few times in case of PIN failures
|
||||
connection = self._defs.Client(transport=transport,
|
||||
ui=self.ui,
|
||||
state=self.__class__.cached_state)
|
||||
self._verify_version(connection)
|
||||
|
||||
try:
|
||||
@@ -119,7 +70,8 @@ class Trezor(interface.Device):
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn.close()
|
||||
self.__class__.cached_state = self.conn.state
|
||||
super().close()
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
@@ -127,8 +79,10 @@ class Trezor(interface.Device):
|
||||
log.debug('"%s" getting public key (%s) from %s',
|
||||
identity.to_string(), curve_name, self)
|
||||
addr = identity.get_bip32_address(ecdh=ecdh)
|
||||
result = self.conn.get_public_node(
|
||||
n=addr, ecdsa_curve_name=curve_name)
|
||||
result = self._defs.get_public_node(
|
||||
self.conn,
|
||||
n=addr,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
return bytes(result.node.public_key)
|
||||
|
||||
@@ -144,7 +98,8 @@ class Trezor(interface.Device):
|
||||
log.debug('"%s" signing %r (%s) on %s',
|
||||
identity.to_string(), blob, curve_name, self)
|
||||
try:
|
||||
result = self.conn.sign_identity(
|
||||
result = self._defs.sign_identity(
|
||||
self.conn,
|
||||
identity=self._identity_proto(identity),
|
||||
challenge_hidden=blob,
|
||||
challenge_visual='',
|
||||
@@ -153,7 +108,7 @@ class Trezor(interface.Device):
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == b'\x00'
|
||||
return bytes(result.signature[1:])
|
||||
except self._defs.CallException as e:
|
||||
except self._defs.TrezorFailure as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
@@ -164,7 +119,8 @@ class Trezor(interface.Device):
|
||||
log.debug('"%s" shared session key (%s) for %r from %s',
|
||||
identity.to_string(), curve_name, pubkey, self)
|
||||
try:
|
||||
result = self.conn.get_ecdh_session_key(
|
||||
result = self._defs.get_ecdh_session_key(
|
||||
self.conn,
|
||||
identity=self._identity_proto(identity),
|
||||
peer_public_key=pubkey,
|
||||
ecdsa_curve_name=curve_name)
|
||||
@@ -172,7 +128,7 @@ class Trezor(interface.Device):
|
||||
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
||||
assert result.session_key[:1] == b'\x04'
|
||||
return bytes(result.session_key)
|
||||
except self._defs.CallException as e:
|
||||
except self._defs.TrezorFailure as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
"""TREZOR-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
# pylint: disable=unused-import,import-error,no-name-in-module,no-member
|
||||
import os
|
||||
import logging
|
||||
|
||||
import mnemonic
|
||||
import semver
|
||||
import trezorlib
|
||||
|
||||
from trezorlib.client import CallException, PinException
|
||||
from trezorlib.client import TrezorClient as Client
|
||||
from trezorlib.messages import IdentityType, PassphraseAck, PinMatrixAck
|
||||
from trezorlib.device import TrezorDevice
|
||||
from trezorlib.exceptions import TrezorFailure, PinException
|
||||
from trezorlib.transport import get_transport
|
||||
from trezorlib.messages import IdentityType
|
||||
|
||||
from trezorlib.btc import get_public_node
|
||||
from trezorlib.misc import sign_identity, get_ecdh_session_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def enumerate_transports():
|
||||
"""Returns all available transports."""
|
||||
return TrezorDevice.enumerate()
|
||||
def find_device():
|
||||
"""Selects a transport based on `TREZOR_PATH` environment variable.
|
||||
|
||||
If unset, picks first connected device.
|
||||
"""
|
||||
try:
|
||||
return get_transport(os.environ.get("TREZOR_PATH"))
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.debug("Failed to find a Trezor device: %s", e)
|
||||
|
||||
141
libagent/device/ui.py
Normal file
141
libagent/device/ui.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""UIs for PIN/passphrase entry."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UI:
|
||||
"""UI for PIN/passphrase entry (for TREZOR devices)."""
|
||||
|
||||
def __init__(self, device_type, config=None):
|
||||
"""C-tor."""
|
||||
default_pinentry = 'pinentry' # by default, use GnuPG pinentry tool
|
||||
if config is None:
|
||||
config = {}
|
||||
self.pin_entry_binary = config.get('pin_entry_binary',
|
||||
default_pinentry)
|
||||
self.passphrase_entry_binary = config.get('passphrase_entry_binary',
|
||||
default_pinentry)
|
||||
self.options_getter = create_default_options_getter()
|
||||
self.device_name = device_type.__name__
|
||||
self.cached_passphrase_ack = None
|
||||
|
||||
def get_pin(self, _code=None):
|
||||
"""Ask the user for (scrambled) PIN."""
|
||||
description = (
|
||||
'Use the numeric keypad to describe number positions.\n'
|
||||
'The layout is:\n'
|
||||
' 7 8 9\n'
|
||||
' 4 5 6\n'
|
||||
' 1 2 3')
|
||||
return interact(
|
||||
title='{} PIN'.format(self.device_name),
|
||||
prompt='PIN:',
|
||||
description=description,
|
||||
binary=self.pin_entry_binary,
|
||||
options=self.options_getter())
|
||||
|
||||
def get_passphrase(self, prompt='Passphrase:'):
|
||||
"""Ask the user for passphrase."""
|
||||
passphrase = None
|
||||
if self.cached_passphrase_ack:
|
||||
passphrase = self.cached_passphrase_ack.get()
|
||||
if passphrase is None:
|
||||
passphrase = interact(
|
||||
title='{} passphrase'.format(self.device_name),
|
||||
prompt=prompt,
|
||||
description=None,
|
||||
binary=self.passphrase_entry_binary,
|
||||
options=self.options_getter())
|
||||
if self.cached_passphrase_ack:
|
||||
self.cached_passphrase_ack.set(passphrase)
|
||||
return passphrase
|
||||
|
||||
def button_request(self, _code=None):
|
||||
"""Called by TrezorClient when device interaction is required."""
|
||||
# XXX: show notification to the user?
|
||||
|
||||
|
||||
def create_default_options_getter():
|
||||
"""Return current TTY and DISPLAY settings for GnuPG pinentry."""
|
||||
options = []
|
||||
try:
|
||||
ttyname = subprocess.check_output(args=['tty']).strip()
|
||||
options.append(b'ttyname=' + ttyname)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.warning('no TTY found: %s', e)
|
||||
|
||||
display = os.environ.get('DISPLAY')
|
||||
if display is not None:
|
||||
options.append('display={}'.format(display).encode('ascii'))
|
||||
else:
|
||||
log.warning('DISPLAY not defined')
|
||||
|
||||
log.info('using %s for pinentry options', options)
|
||||
return lambda: options
|
||||
|
||||
|
||||
def write(p, line):
|
||||
"""Send and flush a single line to the subprocess' stdin."""
|
||||
log.debug('%s <- %r', p.args, line)
|
||||
p.stdin.write(line)
|
||||
p.stdin.flush()
|
||||
|
||||
|
||||
class UnexpectedError(Exception):
|
||||
"""Unexpected response."""
|
||||
|
||||
|
||||
def expect(p, prefixes, confidential=False):
|
||||
"""Read a line and return it without required prefix."""
|
||||
resp = p.stdout.readline()
|
||||
log.debug('%s -> %r', p.args, resp if not confidential else '********')
|
||||
for prefix in prefixes:
|
||||
if resp.startswith(prefix):
|
||||
return resp[len(prefix):]
|
||||
raise UnexpectedError(resp)
|
||||
|
||||
|
||||
def interact(title, description, prompt, binary, options):
|
||||
"""Use GPG pinentry program to interact with the user."""
|
||||
args = [binary]
|
||||
p = subprocess.Popen(args=args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
env=os.environ)
|
||||
p.args = args # TODO: remove after Python 2 deprecation.
|
||||
expect(p, [b'OK'])
|
||||
|
||||
title = util.assuan_serialize(title.encode('ascii'))
|
||||
write(p, b'SETTITLE ' + title + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
if description:
|
||||
description = util.assuan_serialize(description.encode('ascii'))
|
||||
write(p, b'SETDESC ' + description + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
if prompt:
|
||||
prompt = util.assuan_serialize(prompt.encode('ascii'))
|
||||
write(p, b'SETPROMPT ' + prompt + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
log.debug('setting %d options', len(options))
|
||||
for opt in options:
|
||||
write(p, b'OPTION ' + opt + b'\n')
|
||||
expect(p, [b'OK', b'ERR'])
|
||||
|
||||
write(p, b'GETPIN\n')
|
||||
pin = expect(p, [b'OK', b'D '], confidential=True)
|
||||
|
||||
p.communicate() # close stdin and wait for the process to exit
|
||||
exit_code = p.wait()
|
||||
if exit_code:
|
||||
raise subprocess.CalledProcessError(exit_code, binary)
|
||||
|
||||
return pin.decode('ascii').strip()
|
||||
@@ -1,67 +0,0 @@
|
||||
"""UIs for PIN/passphrase entry."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from . import pinentry
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_default_options_getter():
|
||||
options = []
|
||||
try:
|
||||
ttyname = subprocess.check_output(args=['tty']).strip()
|
||||
options.append(b'ttyname=' + ttyname)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.warning('no TTY found: %s', e)
|
||||
|
||||
display = os.environ.get('DISPLAY')
|
||||
if display is not None:
|
||||
options.append('display={}'.format(display).encode('ascii'))
|
||||
else:
|
||||
log.warning('DISPLAY not defined')
|
||||
|
||||
log.info('using %s for pinentry options', options)
|
||||
return lambda: options
|
||||
|
||||
|
||||
class UI(object):
|
||||
"""UI for PIN/passphrase entry (for TREZOR devices)."""
|
||||
|
||||
def __init__(self, device_type, config=None):
|
||||
"""C-tor."""
|
||||
default_pinentry = 'pinentry' # by default, use GnuPG pinentry tool
|
||||
if config is None:
|
||||
config = {}
|
||||
self.pin_entry_binary = config.get('pin_entry_binary',
|
||||
default_pinentry)
|
||||
self.passphrase_entry_binary = config.get('passphrase_entry_binary',
|
||||
default_pinentry)
|
||||
self.options_getter = _create_default_options_getter()
|
||||
self.device_name = device_type.__name__
|
||||
|
||||
def get_pin(self):
|
||||
"""Ask the user for (scrambled) PIN."""
|
||||
description = (
|
||||
'Use the numeric keypad to describe number positions.\n'
|
||||
'The layout is:\n'
|
||||
' 7 8 9\n'
|
||||
' 4 5 6\n'
|
||||
' 1 2 3')
|
||||
return pinentry.interact(
|
||||
title='{} PIN'.format(self.device_name),
|
||||
prompt='PIN:',
|
||||
description=description,
|
||||
binary=self.pin_entry_binary,
|
||||
options=self.options_getter())
|
||||
|
||||
def get_passphrase(self):
|
||||
"""Ask the user for passphrase."""
|
||||
return pinentry.interact(
|
||||
title='{} passphrase'.format(self.device_name),
|
||||
prompt='Passphrase:',
|
||||
description=None,
|
||||
binary=self.passphrase_entry_binary,
|
||||
options=self.options_getter())
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Python wrapper for GnuPG's pinentry."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import libagent.gpg.agent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write(p, line):
|
||||
"""Send and flush a single line to the subprocess' stdin."""
|
||||
log.debug('%s <- %r', p.args, line)
|
||||
p.stdin.write(line)
|
||||
p.stdin.flush()
|
||||
|
||||
|
||||
def expect(p, prefixes):
|
||||
"""Read a line and return it without required prefix."""
|
||||
resp = p.stdout.readline()
|
||||
log.debug('%s -> %r', p.args, resp)
|
||||
for prefix in prefixes:
|
||||
if resp.startswith(prefix):
|
||||
return resp[len(prefix):]
|
||||
raise ValueError('Unexpected response: {}'.format(resp))
|
||||
|
||||
|
||||
def interact(title, description, prompt, binary, options):
|
||||
"""Use GPG pinentry program to interact with the user."""
|
||||
p = subprocess.Popen(args=[binary],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
env=os.environ)
|
||||
expect(p, [b'OK'])
|
||||
|
||||
title = libagent.gpg.agent.serialize(title.encode('ascii'))
|
||||
write(p, b'SETTITLE ' + title + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
if description:
|
||||
description = libagent.gpg.agent.serialize(description.encode('ascii'))
|
||||
write(p, b'SETDESC ' + description + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
if prompt:
|
||||
prompt = libagent.gpg.agent.serialize(prompt.encode('ascii'))
|
||||
write(p, b'SETPROMPT ' + prompt + b'\n')
|
||||
expect(p, [b'OK'])
|
||||
|
||||
log.debug('setting %d options', len(options))
|
||||
for opt in options:
|
||||
write(p, b'OPTION ' + opt + b'\n')
|
||||
expect(p, [b'OK', b'ERR'])
|
||||
|
||||
write(p, b'GETPIN\n')
|
||||
pin = expect(p, [b'OK', b'D '])
|
||||
|
||||
p.communicate() # close stdin and wait for the process to exit
|
||||
exit_code = p.wait()
|
||||
if exit_code:
|
||||
raise subprocess.CalledProcessError(exit_code, binary)
|
||||
|
||||
return pin.decode('ascii').strip()
|
||||
@@ -86,7 +86,8 @@ def verify_gpg_version():
|
||||
required_gpg = '>=2.1.11'
|
||||
msg = 'Existing GnuPG has version "{}" ({} required)'.format(existing_gpg,
|
||||
required_gpg)
|
||||
assert semver.match(existing_gpg, required_gpg), msg
|
||||
if not semver.match(existing_gpg, required_gpg):
|
||||
log.error(msg)
|
||||
|
||||
|
||||
def check_output(args):
|
||||
@@ -147,6 +148,7 @@ export PATH={0}
|
||||
-vv \
|
||||
--pin-entry-binary={pin_entry_binary} \
|
||||
--passphrase-entry-binary={passphrase_entry_binary} \
|
||||
--cache-expiry-seconds={cache_expiry_seconds} \
|
||||
$*
|
||||
""".format(os.environ['PATH'], agent_path, **vars(args)))
|
||||
check_call(['chmod', '700', f.name])
|
||||
@@ -178,20 +180,23 @@ fi
|
||||
# Generate new GPG identity and import into GPG keyring
|
||||
pubkey = write_file(os.path.join(homedir, 'pubkey.asc'),
|
||||
export_public_key(device_type, args))
|
||||
gpg_binary = keyring.get_gnupg_binary()
|
||||
check_call([gpg_binary, '--homedir', homedir, '--quiet',
|
||||
'--import', pubkey.name])
|
||||
verbosity = ('-' + ('v' * args.verbose)) if args.verbose else '--quiet'
|
||||
check_call(keyring.gpg_command(['--homedir', homedir, verbosity,
|
||||
'--import', pubkey.name]))
|
||||
|
||||
# Make new GPG identity with "ultimate" trust (via its fingerprint)
|
||||
out = check_output([gpg_binary, '--homedir', homedir, '--list-public-keys',
|
||||
'--with-fingerprint', '--with-colons'])
|
||||
out = check_output(keyring.gpg_command(['--homedir', homedir,
|
||||
'--list-public-keys',
|
||||
'--with-fingerprint',
|
||||
'--with-colons']))
|
||||
fpr = re.findall('fpr:::::::::([0-9A-F]+):', out)[0]
|
||||
f = write_file(os.path.join(homedir, 'ownertrust.txt'), fpr + ':6\n')
|
||||
check_call([gpg_binary, '--homedir', homedir,
|
||||
'--import-ownertrust', f.name])
|
||||
check_call(keyring.gpg_command(['--homedir', homedir,
|
||||
'--import-ownertrust', f.name]))
|
||||
|
||||
# Load agent and make sure it responds with the new identity
|
||||
check_call([gpg_binary, '--list-secret-keys'], env={'GNUPGHOME': homedir})
|
||||
check_call(keyring.gpg_command(['--homedir', homedir,
|
||||
'--list-secret-keys', args.user_id]))
|
||||
|
||||
|
||||
def run_unlock(device_type, args):
|
||||
@@ -201,16 +206,33 @@ def run_unlock(device_type, args):
|
||||
log.info('unlocked %s device', d)
|
||||
|
||||
|
||||
def _server_from_assuan_fd(env):
|
||||
fd = env.get('_assuan_connection_fd')
|
||||
if fd is None:
|
||||
return None
|
||||
log.info('using fd=%r for UNIX socket server', fd)
|
||||
return server.unix_domain_socket_server_from_fd(int(fd))
|
||||
|
||||
|
||||
def _server_from_sock_path(env):
|
||||
sock_path = keyring.get_agent_sock_path(env=env)
|
||||
return server.unix_domain_socket_server(sock_path)
|
||||
|
||||
|
||||
def run_agent(device_type):
|
||||
"""Run a simple GPG-agent server."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.add_argument('--server', default=False, action='store_true',
|
||||
help='Use stdin/stdout for communication with GPG.')
|
||||
|
||||
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
|
||||
help='Path to PIN entry UI helper.')
|
||||
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
|
||||
help='Path to passphrase entry UI helper.')
|
||||
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
|
||||
help='Expire passphrase from cache after this duration.')
|
||||
|
||||
args, _ = p.parse_known_args()
|
||||
|
||||
@@ -223,13 +245,20 @@ def run_agent(device_type):
|
||||
log.debug('os.environ: %s', os.environ)
|
||||
log.debug('pid: %d, parent pid: %d', os.getpid(), os.getppid())
|
||||
try:
|
||||
env = {'GNUPGHOME': args.homedir}
|
||||
sock_path = keyring.get_agent_sock_path(env=env)
|
||||
env = {'GNUPGHOME': args.homedir, 'PATH': os.environ['PATH']}
|
||||
pubkey_bytes = keyring.export_public_keys(env=env)
|
||||
device_type.ui = device.ui.UI(device_type=device_type,
|
||||
config=vars(args))
|
||||
handler = agent.Handler(device=device_type(), pubkey_bytes=pubkey_bytes)
|
||||
with server.unix_domain_socket_server(sock_path) as sock:
|
||||
device_type.ui.cached_passphrase_ack = util.ExpiringCache(
|
||||
seconds=float(args.cache_expiry_seconds))
|
||||
handler = agent.Handler(device=device_type(),
|
||||
pubkey_bytes=pubkey_bytes)
|
||||
|
||||
sock_server = _server_from_assuan_fd(os.environ)
|
||||
if sock_server is None:
|
||||
sock_server = _server_from_sock_path(env)
|
||||
|
||||
with sock_server as sock:
|
||||
for conn in agent.yield_connections(sock):
|
||||
with contextlib.closing(conn):
|
||||
try:
|
||||
@@ -237,15 +266,21 @@ def run_agent(device_type):
|
||||
except agent.AgentStop:
|
||||
log.info('stopping gpg-agent')
|
||||
return
|
||||
except IOError as e:
|
||||
log.info('connection closed: %s', e)
|
||||
return
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('handler failed: %s', e)
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('gpg-agent failed: %s', e)
|
||||
|
||||
|
||||
def main(device_type):
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser()
|
||||
epilog = ('See https://github.com/romanz/trezor-agent/blob/master/'
|
||||
'doc/README-GPG.md for usage examples.')
|
||||
parser = argparse.ArgumentParser(epilog=epilog)
|
||||
|
||||
agent_package = device_type.package_name()
|
||||
resources_map = {r.key: r for r in pkg_resources.require(agent_package)}
|
||||
@@ -265,13 +300,15 @@ def main(device_type):
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.add_argument('-s', '--subkey', default=False, action='store_true')
|
||||
|
||||
p.add_argument('--homedir', type=str,
|
||||
p.add_argument('--homedir', type=str, default=os.environ.get('GNUPGHOME'),
|
||||
help='Customize GnuPG home directory for the new identity.')
|
||||
|
||||
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
|
||||
help='Path to PIN entry UI helper.')
|
||||
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
|
||||
help='Path to passphrase entry UI helper.')
|
||||
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
|
||||
help='Expire passphrase from cache after this duration.')
|
||||
|
||||
p.set_defaults(func=run_init)
|
||||
|
||||
@@ -281,5 +318,7 @@ def main(device_type):
|
||||
|
||||
args = parser.parse_args()
|
||||
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
|
||||
device_type.ui.cached_passphrase_ack = util.ExpiringCache(
|
||||
seconds=float(args.cache_expiry_seconds))
|
||||
|
||||
return args.func(device_type=device_type, args=args)
|
||||
|
||||
@@ -21,25 +21,17 @@ def yield_connections(sock):
|
||||
yield conn
|
||||
|
||||
|
||||
def serialize(data):
|
||||
"""Serialize data according to ASSUAN protocol."""
|
||||
for c in [b'%', b'\n', b'\r']:
|
||||
escaped = '%{:02X}'.format(ord(c)).encode('ascii')
|
||||
data = data.replace(c, escaped)
|
||||
return data
|
||||
|
||||
|
||||
def sig_encode(r, s):
|
||||
"""Serialize ECDSA signature data into GPG S-expression."""
|
||||
r = serialize(util.num2bytes(r, 32))
|
||||
s = serialize(util.num2bytes(s, 32))
|
||||
r = util.assuan_serialize(util.num2bytes(r, 32))
|
||||
s = util.assuan_serialize(util.num2bytes(s, 32))
|
||||
return b'(7:sig-val(5:ecdsa(1:r32:' + r + b')(1:s32:' + s + b')))'
|
||||
|
||||
|
||||
def _serialize_point(data):
|
||||
prefix = '{}:'.format(len(data)).encode('ascii')
|
||||
# https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html
|
||||
return b'(5:value' + serialize(prefix + data) + b')'
|
||||
return b'(5:value' + util.assuan_serialize(prefix + data) + b')'
|
||||
|
||||
|
||||
def parse_ecdh(line):
|
||||
@@ -78,7 +70,7 @@ class AgentStop(Exception):
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class Handler(object):
|
||||
class Handler:
|
||||
"""GPG agent requests' handler."""
|
||||
|
||||
def _get_options(self):
|
||||
@@ -87,6 +79,7 @@ class Handler(object):
|
||||
def __init__(self, device, pubkey_bytes):
|
||||
"""C-tor."""
|
||||
self.reset()
|
||||
self.options = []
|
||||
device.ui.options_getter = self._get_options
|
||||
self.client = client.Client(device=device)
|
||||
# Cache public keys from GnuPG
|
||||
@@ -99,7 +92,7 @@ class Handler(object):
|
||||
b'OPTION': lambda _, args: self.handle_option(*args),
|
||||
b'SETKEYDESC': None,
|
||||
b'NOP': None,
|
||||
b'GETINFO': lambda conn, _: keyring.sendline(conn, b'D ' + self.version),
|
||||
b'GETINFO': self.handle_getinfo,
|
||||
b'AGENT_ID': lambda conn, _: keyring.sendline(conn, b'D TREZOR'), # "Fake" agent ID
|
||||
b'SIGKEY': lambda _, args: self.set_key(*args),
|
||||
b'SETKEY': lambda _, args: self.set_key(*args),
|
||||
@@ -109,6 +102,7 @@ class Handler(object):
|
||||
b'HAVEKEY': lambda _, args: self.have_key(*args),
|
||||
b'KEYINFO': _key_info,
|
||||
b'SCD': self.handle_scd,
|
||||
b'GET_PASSPHRASE': self.handle_get_passphrase,
|
||||
}
|
||||
|
||||
def reset(self):
|
||||
@@ -116,13 +110,38 @@ class Handler(object):
|
||||
self.keygrip = None
|
||||
self.digest = None
|
||||
self.algo = None
|
||||
self.options = []
|
||||
|
||||
def handle_option(self, opt):
|
||||
"""Store GPG agent-related options (e.g. for pinentry)."""
|
||||
self.options.append(opt)
|
||||
log.debug('options: %s', self.options)
|
||||
|
||||
def handle_get_passphrase(self, conn, _):
|
||||
"""Allow simple GPG symmetric encryption (using a passphrase)."""
|
||||
p1 = self.client.device.ui.get_passphrase('Symmetric encryption:')
|
||||
p2 = self.client.device.ui.get_passphrase('Re-enter encryption:')
|
||||
if p1 == p2:
|
||||
result = b'D ' + util.assuan_serialize(p1.encode('ascii'))
|
||||
keyring.sendline(conn, result, confidential=True)
|
||||
else:
|
||||
log.warning('Passphrase does not match!')
|
||||
|
||||
def handle_getinfo(self, conn, args):
|
||||
"""Handle some of the GETINFO messages."""
|
||||
result = None
|
||||
if args[0] == b'version':
|
||||
result = self.version
|
||||
elif args[0] == b's2k_count':
|
||||
# Use highest number of S2K iterations.
|
||||
# https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Options.html
|
||||
# https://tools.ietf.org/html/rfc4880#section-3.7.1.3
|
||||
result = '{}'.format(64 << 20).encode('ascii')
|
||||
else:
|
||||
log.warning('Unknown GETINFO command: %s', args)
|
||||
|
||||
if result:
|
||||
keyring.sendline(conn, b'D ' + result)
|
||||
|
||||
def handle_scd(self, conn, args):
|
||||
"""No support for smart-card device protocol."""
|
||||
reply = {
|
||||
@@ -132,7 +151,7 @@ class Handler(object):
|
||||
raise AgentError(b'ERR 100696144 No such device <SCD>')
|
||||
keyring.sendline(conn, b'D ' + reply)
|
||||
|
||||
@util.memoize
|
||||
@util.memoize_method # global cache for key grips
|
||||
def get_identity(self, keygrip):
|
||||
"""
|
||||
Returns device.interface.Identity that matches specified keygrip.
|
||||
@@ -179,7 +198,6 @@ class Handler(object):
|
||||
ec_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey)
|
||||
keyring.sendline(conn, b'D ' + _serialize_point(ec_point))
|
||||
|
||||
@util.memoize
|
||||
def have_key(self, *keygrips):
|
||||
"""Check if any keygrip corresponds to a TREZOR-based key."""
|
||||
for keygrip in keygrips:
|
||||
|
||||
@@ -15,7 +15,7 @@ def create_identity(user_id, curve_name):
|
||||
return result
|
||||
|
||||
|
||||
class Client(object):
|
||||
class Client:
|
||||
"""Sign messages and get public keys from a hardware device."""
|
||||
|
||||
def __init__(self, device):
|
||||
|
||||
@@ -48,9 +48,9 @@ def communicate(sock, msg):
|
||||
return recvline(sock)
|
||||
|
||||
|
||||
def sendline(sock, msg):
|
||||
def sendline(sock, msg, confidential=False):
|
||||
"""Send a binary message, followed by EOL."""
|
||||
log.debug('<- %r', msg)
|
||||
log.debug('<- %r', ('<snip>' if confidential else msg))
|
||||
sock.sendall(msg + b'\n')
|
||||
|
||||
|
||||
@@ -198,8 +198,10 @@ def get_gnupg_components(sp=subprocess):
|
||||
|
||||
|
||||
@util.memoize
|
||||
def get_gnupg_binary(sp=subprocess):
|
||||
def get_gnupg_binary(sp=subprocess, neopg_binary=None):
|
||||
"""Starting GnuPG 2.2.x, the default installation uses `gpg`."""
|
||||
if neopg_binary:
|
||||
return neopg_binary
|
||||
return get_gnupg_components(sp=sp)['gpg']
|
||||
|
||||
|
||||
@@ -207,11 +209,8 @@ def gpg_command(args, env=None):
|
||||
"""Prepare common GPG command line arguments."""
|
||||
if env is None:
|
||||
env = os.environ
|
||||
cmd = [get_gnupg_binary()]
|
||||
homedir = env.get('GNUPGHOME')
|
||||
if homedir:
|
||||
cmd.extend(['--homedir', homedir])
|
||||
return cmd + args
|
||||
cmd = get_gnupg_binary(neopg_binary=env.get('NEOPG_BINARY'))
|
||||
return [cmd] + args
|
||||
|
||||
|
||||
def get_keygrip(user_id, sp=subprocess):
|
||||
@@ -226,7 +225,9 @@ def gpg_version(sp=subprocess):
|
||||
args = gpg_command(['--version'])
|
||||
output = check_output(args=args, sp=sp)
|
||||
line = output.split(b'\n')[0] # b'gpg (GnuPG) 2.1.11'
|
||||
return line.split(b' ')[-1] # b'2.1.11'
|
||||
line = line.split(b' ')[-1] # b'2.1.11'
|
||||
line = line.split(b'-')[0] # remove trailing version parts
|
||||
return line.split(b'v')[-1] # remove 'v' prefix
|
||||
|
||||
|
||||
def export_public_key(user_id, env=None, sp=subprocess):
|
||||
|
||||
@@ -185,7 +185,7 @@ def get_curve_name_by_oid(oid):
|
||||
raise KeyError('Unknown OID: {!r}'.format(oid))
|
||||
|
||||
|
||||
class PublicKey(object):
|
||||
class PublicKey:
|
||||
"""GPG representation for public key packets."""
|
||||
|
||||
def __init__(self, curve_name, created, verifying_key, ecdh=False):
|
||||
|
||||
11
libagent/gpg/tests/test_agent.py
Normal file
11
libagent/gpg/tests/test_agent.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .. import agent
|
||||
|
||||
|
||||
def test_sig_encode():
|
||||
SIG = (
|
||||
b'(7:sig-val(5:ecdsa(1:r32:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\x00\x00\x00\x0c)(1:s32:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\x00\x00\x00")))')
|
||||
assert agent.sig_encode(12, 34) == SIG
|
||||
@@ -41,7 +41,7 @@ def test_parse_rsa():
|
||||
assert keyring.parse_sig(sig) == (0x1020304,)
|
||||
|
||||
|
||||
class FakeSocket(object):
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.rx = io.BytesIO()
|
||||
self.tx = io.BytesIO()
|
||||
|
||||
@@ -39,6 +39,43 @@ def unix_domain_socket_server(sock_path):
|
||||
remove_file(sock_path)
|
||||
|
||||
|
||||
class FDServer:
|
||||
"""File-descriptor based server (for NeoPG)."""
|
||||
|
||||
def __init__(self, fd):
|
||||
"""C-tor."""
|
||||
self.fd = fd
|
||||
self.sock = socket.fromfd(fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
|
||||
def accept(self):
|
||||
"""Use the same socket for I/O."""
|
||||
return self, None
|
||||
|
||||
def recv(self, n):
|
||||
"""Forward to underlying socket."""
|
||||
return self.sock.recv(n)
|
||||
|
||||
def sendall(self, data):
|
||||
"""Forward to underlying socket."""
|
||||
return self.sock.sendall(data)
|
||||
|
||||
def close(self):
|
||||
"""Not needed."""
|
||||
|
||||
def settimeout(self, _):
|
||||
"""Not needed."""
|
||||
|
||||
def getsockname(self):
|
||||
"""Simple representation."""
|
||||
return '<fd: {}>'.format(self.fd)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def unix_domain_socket_server_from_fd(fd):
|
||||
"""Build UDS-based socket server from a file descriptor."""
|
||||
yield FDServer(fd)
|
||||
|
||||
|
||||
def handle_connection(conn, handler, mutex):
|
||||
"""
|
||||
Handle a single connection using the specified protocol handler in a loop.
|
||||
|
||||
@@ -23,9 +23,11 @@ log = logging.getLogger(__name__)
|
||||
UNIX_SOCKET_TIMEOUT = 0.1
|
||||
|
||||
|
||||
def ssh_args(label):
|
||||
def ssh_args(conn):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = device.interface.string_to_identity(label)
|
||||
I, = conn.identities
|
||||
identity = I.identity_dict
|
||||
pubkey_tempfile, = conn.public_keys_as_files()
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
@@ -33,12 +35,15 @@ def ssh_args(label):
|
||||
if 'user' in identity:
|
||||
args += ['-l', identity['user']]
|
||||
|
||||
args += ['-o', 'IdentityFile={}'.format(pubkey_tempfile.name)]
|
||||
args += ['-o', 'IdentitiesOnly=true']
|
||||
return args + [identity['host']]
|
||||
|
||||
|
||||
def mosh_args(label):
|
||||
def mosh_args(conn):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = device.interface.string_to_identity(label)
|
||||
I, = conn.identities
|
||||
identity = I.identity_dict
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
@@ -60,7 +65,10 @@ def _to_unicode(s):
|
||||
|
||||
def create_agent_parser(device_type):
|
||||
"""Create an ArgumentParser for this tool."""
|
||||
p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'])
|
||||
epilog = ('See https://github.com/romanz/trezor-agent/blob/master/'
|
||||
'doc/README-SSH.md for usage examples.')
|
||||
p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'],
|
||||
epilog=epilog)
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
agent_package = device_type.package_name()
|
||||
@@ -89,6 +97,8 @@ def create_agent_parser(device_type):
|
||||
help='Path to PIN entry UI helper.')
|
||||
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
|
||||
help='Path to passphrase entry UI helper.')
|
||||
p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'),
|
||||
help='Expire passphrase from cache after this duration.')
|
||||
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument('-d', '--daemonize', default=False, action='store_true',
|
||||
@@ -183,7 +193,7 @@ def import_public_keys(contents):
|
||||
yield line
|
||||
|
||||
|
||||
class JustInTimeConnection(object):
|
||||
class JustInTimeConnection:
|
||||
"""Connect to the device just before the needed operation."""
|
||||
|
||||
def __init__(self, conn_factory, identities, public_keys=None):
|
||||
@@ -191,6 +201,7 @@ class JustInTimeConnection(object):
|
||||
self.conn_factory = conn_factory
|
||||
self.identities = identities
|
||||
self.public_keys_cache = public_keys
|
||||
self.public_keys_tempfiles = []
|
||||
|
||||
def public_keys(self):
|
||||
"""Return a list of SSH public keys (in textual format)."""
|
||||
@@ -207,6 +218,17 @@ class JustInTimeConnection(object):
|
||||
pk['identity'] = identity
|
||||
return public_keys
|
||||
|
||||
def public_keys_as_files(self):
|
||||
"""Store public keys as temporary SSH identity files."""
|
||||
if not self.public_keys_tempfiles:
|
||||
for pk in self.public_keys():
|
||||
f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w')
|
||||
f.write(pk)
|
||||
f.flush()
|
||||
self.public_keys_tempfiles.append(f)
|
||||
|
||||
return self.public_keys_tempfiles
|
||||
|
||||
def sign(self, blob, identity):
|
||||
"""Sign a given blob using the specified identity on the device."""
|
||||
conn = self.conn_factory()
|
||||
@@ -236,6 +258,7 @@ def main(device_type):
|
||||
util.setup_logging(verbosity=args.verbose, filename=args.log_file)
|
||||
|
||||
public_keys = None
|
||||
filename = None
|
||||
if args.identity.startswith('/'):
|
||||
filename = args.identity
|
||||
contents = open(filename, 'rb').read().decode('utf-8')
|
||||
@@ -250,14 +273,22 @@ def main(device_type):
|
||||
identity.identity_dict['proto'] = u'ssh'
|
||||
log.info('identity #%d: %s', index, identity.to_string())
|
||||
|
||||
sock_path = _get_sock_path(args)
|
||||
# override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey):
|
||||
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
|
||||
device_type.ui.cached_passphrase_ack = util.ExpiringCache(
|
||||
args.cache_expiry_seconds)
|
||||
|
||||
conn = JustInTimeConnection(
|
||||
conn_factory=lambda: client.Client(device_type()),
|
||||
identities=identities, public_keys=public_keys)
|
||||
|
||||
sock_path = _get_sock_path(args)
|
||||
command = args.command
|
||||
context = _dummy_context()
|
||||
if args.connect:
|
||||
command = ['ssh'] + ssh_args(args.identity) + args.command
|
||||
command = ['ssh'] + ssh_args(conn) + args.command
|
||||
elif args.mosh:
|
||||
command = ['mosh'] + mosh_args(args.identity) + args.command
|
||||
command = ['mosh'] + mosh_args(conn) + args.command
|
||||
elif args.daemonize:
|
||||
out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path)
|
||||
sys.stdout.write(out)
|
||||
@@ -272,13 +303,6 @@ def main(device_type):
|
||||
command = os.environ['SHELL']
|
||||
sys.stdin.close()
|
||||
|
||||
# override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey):
|
||||
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
|
||||
|
||||
conn = JustInTimeConnection(
|
||||
conn_factory=lambda: client.Client(device_type()),
|
||||
identities=identities, public_keys=public_keys)
|
||||
|
||||
if command or args.daemonize or args.foreground:
|
||||
with context:
|
||||
return run_server(conn=conn, command=command, sock_path=sock_path,
|
||||
|
||||
@@ -11,7 +11,7 @@ from . import formats, util
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client(object):
|
||||
class Client:
|
||||
"""Client wrapper for SSH authentication device."""
|
||||
|
||||
def __init__(self, device):
|
||||
|
||||
@@ -70,7 +70,7 @@ def _legacy_pubs(buf):
|
||||
return util.frame(code, num)
|
||||
|
||||
|
||||
class Handler(object):
|
||||
class Handler:
|
||||
"""ssh-agent protocol handler."""
|
||||
|
||||
def __init__(self, conn, debug=False):
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_socket():
|
||||
assert not os.path.isfile(path)
|
||||
|
||||
|
||||
class FakeSocket(object):
|
||||
class FakeSocket:
|
||||
|
||||
def __init__(self, data=b''):
|
||||
self.rx = io.BytesIO(data)
|
||||
@@ -77,7 +77,7 @@ def test_server_thread():
|
||||
connections = [sock]
|
||||
quit_event = threading.Event()
|
||||
|
||||
class FakeServer(object):
|
||||
class FakeServer:
|
||||
def accept(self): # pylint: disable=no-self-use
|
||||
if not connections:
|
||||
raise socket.timeout()
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_frames():
|
||||
assert util.read_frame(io.BytesIO(f)) == b''.join(msgs)
|
||||
|
||||
|
||||
class FakeSocket(object):
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.buf = io.BytesIO()
|
||||
|
||||
@@ -115,3 +115,32 @@ def test_memoize():
|
||||
assert g(1) == g(1)
|
||||
assert g(1) != g(2)
|
||||
assert f.mock_calls == [mock.call(1), mock.call(2)]
|
||||
|
||||
|
||||
def test_assuan_serialize():
|
||||
assert util.assuan_serialize(b'') == b''
|
||||
assert util.assuan_serialize(b'123\n456') == b'123%0A456'
|
||||
assert util.assuan_serialize(b'\r\n') == b'%0D%0A'
|
||||
|
||||
|
||||
def test_cache():
|
||||
timer = mock.Mock(side_effect=range(7))
|
||||
c = util.ExpiringCache(seconds=2, timer=timer) # t=0
|
||||
assert c.get() is None # t=1
|
||||
obj = 'foo'
|
||||
c.set(obj) # t=2
|
||||
assert c.get() is obj # t=3
|
||||
assert c.get() is obj # t=4
|
||||
assert c.get() is None # t=5
|
||||
assert c.get() is None # t=6
|
||||
|
||||
|
||||
def test_cache_inf():
|
||||
timer = mock.Mock(side_effect=range(6))
|
||||
c = util.ExpiringCache(seconds=float('inf'), timer=timer)
|
||||
obj = 'foo'
|
||||
c.set(obj)
|
||||
assert c.get() is obj
|
||||
assert c.get() is obj
|
||||
assert c.get() is obj
|
||||
assert c.get() is obj
|
||||
|
||||
@@ -5,6 +5,7 @@ import functools
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -145,7 +146,7 @@ def hexlify(blob):
|
||||
return binascii.hexlify(blob).decode('ascii').upper()
|
||||
|
||||
|
||||
class Reader(object):
|
||||
class Reader:
|
||||
"""Read basic type objects out of given stream."""
|
||||
|
||||
def __init__(self, stream):
|
||||
@@ -215,6 +216,24 @@ def memoize(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def memoize_method(method):
|
||||
"""Simple caching decorator."""
|
||||
cache = {}
|
||||
|
||||
@functools.wraps(method)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
"""Caching wrapper."""
|
||||
key = (args, tuple(sorted(kwargs.items())))
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
else:
|
||||
result = method(self, *args, **kwargs)
|
||||
cache[key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@memoize
|
||||
def which(cmd):
|
||||
"""Return full path to specified command, or raise OSError if missing."""
|
||||
@@ -229,3 +248,33 @@ def which(cmd):
|
||||
raise OSError('Cannot find {!r} in $PATH'.format(cmd))
|
||||
log.debug('which %r => %r', cmd, full_path)
|
||||
return full_path
|
||||
|
||||
|
||||
def assuan_serialize(data):
|
||||
"""Serialize data according to ASSUAN protocol (for GPG daemon communication)."""
|
||||
for c in [b'%', b'\n', b'\r']:
|
||||
escaped = '%{:02X}'.format(ord(c)).encode('ascii')
|
||||
data = data.replace(c, escaped)
|
||||
return data
|
||||
|
||||
|
||||
class ExpiringCache:
|
||||
"""Simple cache with a deadline."""
|
||||
|
||||
def __init__(self, seconds, timer=time.time):
|
||||
"""C-tor."""
|
||||
self.duration = seconds
|
||||
self.timer = timer
|
||||
self.value = None
|
||||
self.set(None)
|
||||
|
||||
def get(self):
|
||||
"""Returns existing value, or None if deadline has expired."""
|
||||
if self.timer() > self.deadline:
|
||||
self.value = None
|
||||
return self.value
|
||||
|
||||
def set(self, value):
|
||||
"""Set new value and reset the deadline for expiration."""
|
||||
self.deadline = self.timer() + self.duration
|
||||
self.value = value
|
||||
|
||||
11
setup.py
11
setup.py
@@ -3,7 +3,7 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='libagent',
|
||||
version='0.11.0',
|
||||
version='0.13.1',
|
||||
description='Using hardware wallets as SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
@@ -15,8 +15,10 @@ setup(
|
||||
'libagent.ssh'
|
||||
],
|
||||
install_requires=[
|
||||
'docutils>=0.14',
|
||||
'wheel>=0.32.3',
|
||||
'backports.shutil_which>=3.5.1',
|
||||
'ConfigArgParse>=0.12.0',
|
||||
'ConfigArgParse>=0.12.1',
|
||||
'python-daemon>=2.1.2',
|
||||
'ecdsa>=0.13',
|
||||
'ed25519>=1.4',
|
||||
@@ -34,10 +36,7 @@ setup(
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: System :: Networking',
|
||||
'Topic :: Communications',
|
||||
|
||||
Reference in New Issue
Block a user