mirror of
https://github.com/romanz/amodem.git
synced 2026-05-03 08:27:26 +08:00
Compare commits
303 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b99c0794 | ||
|
|
f66da28cc3 | ||
|
|
c3853e97c7 | ||
|
|
32eff19bb6 | ||
|
|
fd182e744f | ||
|
|
a12202d809 | ||
|
|
d0e7fa7cca | ||
|
|
e1bbdb4bcc | ||
|
|
4d9d6c0741 | ||
|
|
4c3c5a7c53 | ||
|
|
362ddcc707 | ||
|
|
88ff57187f | ||
|
|
52d840cbbb | ||
|
|
8c22e5030b | ||
|
|
18c80b4cca | ||
|
|
7eab4933ed | ||
|
|
d103ebee6f | ||
|
|
d8bcca3ccb | ||
|
|
67ef11419a | ||
|
|
d4d168c746 | ||
|
|
61cfcef35c | ||
|
|
0f627e8322 | ||
|
|
7bdfa7609d | ||
|
|
53b08f4968 | ||
|
|
15b0218bf2 | ||
|
|
f52e959639 | ||
|
|
d98f49445e | ||
|
|
ab6892f42f | ||
|
|
f03312d61f | ||
|
|
b75cf74976 | ||
|
|
363b4d633f | ||
|
|
b7d0ef0f94 | ||
|
|
8c3744c30c | ||
|
|
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 | ||
|
|
51dbecd4c2 | ||
|
|
ceae65aa5a | ||
|
|
d0497b0137 | ||
|
|
870152a7af | ||
|
|
cbdc52c0a4 | ||
|
|
0c9fc33757 | ||
|
|
17ea941add | ||
|
|
64064b5ecc | ||
|
|
601a2b1336 | ||
|
|
2e688ccac9 | ||
|
|
b6181bb5b5 | ||
|
|
b6da299cb0 | ||
|
|
04627f0899 | ||
|
|
54ce6f2cec | ||
|
|
a1047ba7b1 | ||
|
|
e90bd0cd81 | ||
|
|
66e3e60370 | ||
|
|
3f1604d609 | ||
|
|
d0f4cccfd2 | ||
|
|
08d81c992c | ||
|
|
55a899f929 | ||
|
|
e7604dff68 | ||
|
|
8849545700 | ||
|
|
d109cd73b5 | ||
|
|
95e98d6eda | ||
|
|
9e78d52721 | ||
|
|
2a76ef6819 | ||
|
|
654a3c465a | ||
|
|
2168115b06 | ||
|
|
4a9140c42d | ||
|
|
b20d98bf57 | ||
|
|
199fb299c3 | ||
|
|
06e169f141 | ||
|
|
131111bc0e | ||
|
|
f4208009e0 | ||
|
|
73d60dbec0 | ||
|
|
34ea224290 | ||
|
|
7803026f61 | ||
|
|
34ce1005fd | ||
|
|
8677c8ebaa | ||
|
|
6363eb0d4a | ||
|
|
a32bfc749b | ||
|
|
75d117ad0d | ||
|
|
cefc5f180a | ||
|
|
0f5c71b748 | ||
|
|
d5f97b7efa | ||
|
|
4a12bfa0b7 | ||
|
|
cac889ff7d | ||
|
|
92c6e680ed | ||
|
|
bf294beb56 | ||
|
|
713345918e | ||
|
|
eb60c2f475 | ||
|
|
6d8d43db9b | ||
|
|
3e67bc9f0e | ||
|
|
38b50485de | ||
|
|
9cba27b31a | ||
|
|
00a65a9820 | ||
|
|
52ad601e66 | ||
|
|
d96a2820ff | ||
|
|
29aaf777ad | ||
|
|
385fc9457b | ||
|
|
9cf73f677a | ||
|
|
ec97cd0c44 | ||
|
|
4cd7dc02eb | ||
|
|
8fe9460ed6 | ||
|
|
db16aa3d1c | ||
|
|
41ccd2f332 | ||
|
|
cb14d1e00b | ||
|
|
cc6ee31deb | ||
|
|
b1f302151b | ||
|
|
fde50f04ab | ||
|
|
7e42e455a1 | ||
|
|
13cd6be2d1 | ||
|
|
40469c4100 | ||
|
|
0d059587a7 | ||
|
|
283cb3d7e8 | ||
|
|
51cc716e3f | ||
|
|
8b4850b0ce | ||
|
|
f22c07e970 | ||
|
|
29c7234ef4 | ||
|
|
1942e3999b | ||
|
|
f2e52a88be | ||
|
|
b26a4cc7b0 | ||
|
|
c4dfca04f2 | ||
|
|
a1ecbf447e | ||
|
|
1f9d457e92 | ||
|
|
cb3477fc69 | ||
|
|
9bbc66cc16 | ||
|
|
06afc971db | ||
|
|
2b51a85c26 | ||
|
|
1906e6d9b0 | ||
|
|
b3f6e39b48 | ||
|
|
8b03b649d5 | ||
|
|
90cbc41b17 | ||
|
|
4926d4f4d3 | ||
|
|
d52f295326 | ||
|
|
47a8a53247 | ||
|
|
9530c4d7db | ||
|
|
a2d0c1067d | ||
|
|
3d5717dca1 | ||
|
|
08fef24e39 | ||
|
|
bab46dae5c | ||
|
|
e2625cc521 | ||
|
|
7ed76fe472 | ||
|
|
a5929eed62 | ||
|
|
5f722f8ae1 | ||
|
|
7212b2fa37 | ||
|
|
55e1c614a7 | ||
|
|
8cf1f0463a | ||
|
|
f177b0b55a | ||
|
|
b2450d448c | ||
|
|
93e5f0cd8b | ||
|
|
9998456fe0 | ||
|
|
0f85ae6e2c | ||
|
|
44cdeed024 | ||
|
|
867e2cfd1b | ||
|
|
df6ddab2cf | ||
|
|
5b9f03d198 | ||
|
|
06ea890095 | ||
|
|
0999a85529 | ||
|
|
835f283ccf | ||
|
|
f57dbb553f | ||
|
|
a890dcc085 | ||
|
|
c8ed4a223a | ||
|
|
1ef96bed03 | ||
|
|
e4fdca08e5 | ||
|
|
51b297e93b | ||
|
|
c22c959cf9 | ||
|
|
3199cb964a | ||
|
|
c5f245957d | ||
|
|
fbb3059a0b | ||
|
|
e922f45871 | ||
|
|
377af1466c | ||
|
|
b7743e12a5 | ||
|
|
48d5630561 | ||
|
|
b88dff8430 | ||
|
|
2af1086ed8 | ||
|
|
7e95179128 | ||
|
|
ac8898a434 | ||
|
|
0b829636e1 | ||
|
|
7598f6cdbf | ||
|
|
c8bf57cbcc | ||
|
|
62af49236c | ||
|
|
af3f669780 | ||
|
|
1520dbd8b9 | ||
|
|
4f05d51e9b | ||
|
|
9d38c26a0f | ||
|
|
3a9330b995 | ||
|
|
f904aac92e | ||
|
|
ca67923fe8 | ||
|
|
ce90e61eb2 | ||
|
|
90dc124e8d | ||
|
|
442bf725ef | ||
|
|
5820480052 | ||
|
|
ae2a84e168 | ||
|
|
f6911a0016 | ||
|
|
69c54eb425 | ||
|
|
931573f32b | ||
|
|
1e8363d4fc | ||
|
|
b7113083b4 | ||
|
|
b5c4eca0d2 | ||
|
|
8aa08d0862 | ||
|
|
b452b49f4c | ||
|
|
639c4efb6d | ||
|
|
6f1686c614 | ||
|
|
300d9a7140 | ||
|
|
b143bafc70 | ||
|
|
f2c6b6b9c1 | ||
|
|
e0507b1508 | ||
|
|
85274d8374 | ||
|
|
f358ca29d4 | ||
|
|
53d43cba29 | ||
|
|
214b556f83 | ||
|
|
051a3fd4ab | ||
|
|
91050ee64a | ||
|
|
257992d04c | ||
|
|
6c2273387d | ||
|
|
b6ad8207ba | ||
|
|
3a93fc859d |
7
.bumpversion.cfg
Normal file
7
.bumpversion.cfg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[bumpversion]
|
||||||
|
commit = True
|
||||||
|
tag = True
|
||||||
|
current_version = 0.14.2
|
||||||
|
|
||||||
|
[bumpversion:file:setup.py]
|
||||||
|
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
[MESSAGES CONTROL]
|
[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,import-outside-toplevel
|
||||||
|
|
||||||
|
[SIMILARITIES]
|
||||||
|
min-similarity-lines=5
|
||||||
|
|||||||
14
.travis.yml
14
.travis.yml
@@ -1,23 +1,25 @@
|
|||||||
sudo: false
|
sudo: false
|
||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- "2.7"
|
|
||||||
- "3.4"
|
|
||||||
- "3.5"
|
|
||||||
- "3.6"
|
- "3.6"
|
||||||
|
- "3.7"
|
||||||
|
- "3.8"
|
||||||
|
- "3.9"
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
directories:
|
directories:
|
||||||
- $HOME/.cache/pip
|
- $HOME/.cache/pip
|
||||||
|
|
||||||
before_install:
|
before_install:
|
||||||
- pip install -U setuptools pylint coverage pep8 pydocstyle "pip>=7.0" wheel
|
- pip install -U pip wheel
|
||||||
|
- pip install -U setuptools
|
||||||
|
- pip install -U pylint coverage pycodestyle pydocstyle
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- pip install -e .
|
- pip install -U -e .
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- pep8 libagent
|
- pycodestyle libagent
|
||||||
- pylint --reports=no --rcfile .pylintrc libagent
|
- pylint --reports=no --rcfile .pylintrc libagent
|
||||||
- pydocstyle libagent
|
- pydocstyle libagent
|
||||||
- coverage run --source libagent/ -m py.test -v
|
- coverage run --source libagent/ -m py.test -v
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
Note: the GPG-related code is still under development, so please try the current implementation
|
|
||||||
and feel free to [report any issue](https://github.com/romanz/trezor-agent/issues) you have encountered.
|
|
||||||
Thanks!
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
First, verify that you have GPG 2.1.11+ installed
|
|
||||||
([Debian](https://gist.github.com/vt0r/a2f8c0bcb1400131ff51),
|
|
||||||
[macOS](https://sourceforge.net/p/gpgosx/docu/Download/)):
|
|
||||||
|
|
||||||
```
|
|
||||||
$ gpg2 --version | head -n1
|
|
||||||
gpg (GnuPG) 2.1.15
|
|
||||||
```
|
|
||||||
|
|
||||||
This GPG version is included in [Ubuntu 16.04](https://launchpad.net/ubuntu/+source/gnupg2)
|
|
||||||
and [Linux Mint 18](https://community.linuxmint.com/software/view/gnupg2).
|
|
||||||
|
|
||||||
Update you TREZOR firmware to the latest version (at least v1.4.0).
|
|
||||||
|
|
||||||
Install latest `trezor-agent` package from GitHub:
|
|
||||||
```
|
|
||||||
$ pip install --user git+https://github.com/romanz/trezor-agent.git
|
|
||||||
```
|
|
||||||
|
|
||||||
# Quickstart
|
|
||||||
|
|
||||||
## Identity creation
|
|
||||||
[](https://asciinema.org/a/c2yodst21h9obttkn9wgf3783)
|
|
||||||
|
|
||||||
## Sample usage (signature and decryption)
|
|
||||||
[](https://asciinema.org/a/7x0h9tyoyu5ar6jc8y9oih0ba)
|
|
||||||
|
|
||||||
You can use GNU Privacy Assistant (GPA) in order to inspect the created keys
|
|
||||||
and perform signature and decryption operations using:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ sudo apt install gpa
|
|
||||||
$ ./scripts/gpg-shell gpa
|
|
||||||
```
|
|
||||||
[](https://www.gnupg.org/related_software/swlist.html#gpa)
|
|
||||||
|
|
||||||
## Git commit & tag signatures:
|
|
||||||
Git can use GPG to sign and verify commits and tags (see [here](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)):
|
|
||||||
```
|
|
||||||
$ git config --local gpg.program $(which gpg2)
|
|
||||||
$ git commit --gpg-sign # create GPG-signed commit
|
|
||||||
$ git log --show-signature -1 # verify commit signature
|
|
||||||
$ git tag v1.2.3 --sign # create GPG-signed tag
|
|
||||||
$ git tag v1.2.3 --verify # verify tag signature
|
|
||||||
```
|
|
||||||
|
|
||||||
## Password manager
|
|
||||||
|
|
||||||
First install `pass` from [passwordstore.org](https://www.passwordstore.org/) and initialize it to use your TREZOR-based GPG identity:
|
|
||||||
```
|
|
||||||
$ ./scripts/gpg-shell
|
|
||||||
$ pass init "Roman Zeyde <roman.zeyde@gmail.com>"
|
|
||||||
Password store initialized for Roman Zeyde <roman.zeyde@gmail.com>
|
|
||||||
```
|
|
||||||
Then, you can generate truly random passwords and save them encrypted using your public key (as separate `.gpg` files under `~/.password-store/`):
|
|
||||||
```
|
|
||||||
$ pass generate Dev/github 32
|
|
||||||
$ pass generate Social/hackernews 32
|
|
||||||
$ pass generate Social/twitter 32
|
|
||||||
$ pass generate VPS/linode 32
|
|
||||||
$ pass
|
|
||||||
Password Store
|
|
||||||
├── Dev
|
|
||||||
│ └── github
|
|
||||||
├── Social
|
|
||||||
│ ├── hackernews
|
|
||||||
│ └── twitter
|
|
||||||
└── VPS
|
|
||||||
└── linode
|
|
||||||
```
|
|
||||||
In order to paste them into the browser, you'd need to decrypt the password using your hardware device:
|
|
||||||
```
|
|
||||||
$ pass --clip VPS/linode
|
|
||||||
Copied VPS/linode to clipboard. Will clear in 45 seconds.
|
|
||||||
```
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Screencast demo usage
|
|
||||||
|
|
||||||
## Simple usage (single SSH session)
|
|
||||||
[](https://asciinema.org/a/22959)
|
|
||||||
|
|
||||||
## Advanced usage (multiple SSH sessions from a sub-shell)
|
|
||||||
[](https://asciinema.org/a/33240)
|
|
||||||
|
|
||||||
## Using for GitHub SSH authentication (via `trezor-git` utility)
|
|
||||||
[](https://asciinema.org/a/38337)
|
|
||||||
|
|
||||||
## Loading multiple SSH identities from configuration file
|
|
||||||
[](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny)
|
|
||||||
|
|
||||||
# Public key generation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
/tmp $ trezor-agent user@ssh.hostname.com -v > hostname.pub
|
|
||||||
2015-09-02 15:03:18,929 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
|
|
||||||
2015-09-02 15:03:23,342 INFO disconnected from Trezor
|
|
||||||
/tmp $ cat hostname.pub
|
|
||||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGSevcDwmT+QaZPUEWUUjTeZRBICChxMKuJ7dRpBSF8+qt+8S1GBK5Zj8Xicc8SHG/SE/EXKUL2UU3kcUzE7ADQ= ssh://user@ssh.hostname.com
|
|
||||||
|
|
||||||
Append `hostname.pub` contents to `/home/user/.ssh/authorized_keys`
|
|
||||||
configuration file at `ssh.hostname.com`, so the remote server
|
|
||||||
would allow you to login using the corresponding private key signature.
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
/tmp $ trezor-agent user@ssh.hostname.com -v -c
|
|
||||||
2015-09-02 15:09:39,782 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
|
|
||||||
2015-09-02 15:09:44,430 INFO please confirm user "roman" login to "ssh://user@ssh.hostname.com" using Trezor...
|
|
||||||
2015-09-02 15:09:46,152 INFO signature status: OK
|
|
||||||
Linux lmde 3.16.0-4-amd64 #1 SMP Debian 3.16.7-ckt11-1+deb8u3 (2015-08-04) x86_64
|
|
||||||
|
|
||||||
The programs included with the Debian GNU/Linux system are free software;
|
|
||||||
the exact distribution terms for each program are described in the
|
|
||||||
individual files in /usr/share/doc/*/copyright.
|
|
||||||
|
|
||||||
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
|
||||||
permitted by applicable law.
|
|
||||||
Last login: Tue Sep 1 15:57:05 2015 from localhost
|
|
||||||
~ $
|
|
||||||
|
|
||||||
Make sure to confirm SSH signature on the Trezor device when requested.
|
|
||||||
|
|
||||||
## Accessing remote Git/Mercurial repositories
|
|
||||||
|
|
||||||
Use your SSH public key to access your remote repository (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
|
|
||||||
|
|
||||||
Use the following Bash alias for convinient Git operations:
|
|
||||||
|
|
||||||
$ alias git_hub='trezor-agent -v -e ed25519 git@github.com -- git'
|
|
||||||
|
|
||||||
Replace `git` with `git_hub` for remote operations:
|
|
||||||
|
|
||||||
$ git_hub 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
|
|
||||||
|
|
||||||
|
|
||||||
# Troubleshooting
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`.
|
|
||||||
|
|
||||||
IdentitiesOnly
|
|
||||||
Specifies that ssh(1) should only use the authentication identity files configured in
|
|
||||||
the ssh_config files, even if ssh-agent(1) or a PKCS11Provider offers more identities.
|
|
||||||
The argument to this keyword must be “yes” or “no”.
|
|
||||||
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:
|
|
||||||
|
|
||||||
$ trezor-agent -vv user@host -- ssh -vv -oIdentitiesOnly=no user@host
|
|
||||||
91
README.md
91
README.md
@@ -1,83 +1,42 @@
|
|||||||
# Using TREZOR as a hardware SSH/GPG agent
|
# Hardware-based SSH/GPG agent
|
||||||
|
|
||||||
[](https://travis-ci.org/romanz/trezor-agent)
|
[](https://travis-ci.org/romanz/trezor-agent)
|
||||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
[](https://gitter.im/romanz/trezor-agent)
|
||||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
|
||||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
|
||||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
|
||||||
|
|
||||||
See SatoshiLabs' blog posts about this feature:
|
This project allows you to use various hardware security devices to operate GPG and SSH. Instead of keeping your key on your computer and decrypting it with a passphrase when you want to use it, the key is generated and stored on the device and never reaches your computer. Read more about the design [here](doc/DESIGN.md).
|
||||||
|
|
||||||
|
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 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.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.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/)
|
- [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)
|
||||||
|
|
||||||
## Installation
|
Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Keepkey](https://www.keepkey.com/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported.
|
||||||
|
|
||||||
Install the following packages:
|
## Components
|
||||||
|
|
||||||
$ apt-get install python-dev libusb-1.0-0-dev libudev-dev
|
This repository contains source code for one library as well as
|
||||||
$ pip install -U setuptools pip
|
agents to interact with several different hardware devices:
|
||||||
|
|
||||||
Make sure you are running the latest firmware version on your hardware device.
|
* [`libagent`](https://pypi.org/project/libagent/): shared library
|
||||||
Currently the following firmware versions are supported:
|
* [`trezor-agent`](https://pypi.org/project/trezor-agent/): Using Trezor as hardware-based SSH/PGP agent
|
||||||
|
* [`ledger_agent`](https://pypi.org/project/ledger_agent/): Using Ledger as hardware-based SSH/PGP agent
|
||||||
|
* [`keepkey_agent`](https://pypi.org/project/keepkey_agent/): Using KeepKey as hardware-based SSH/PGP agent
|
||||||
|
* [`onlykey-agent`](https://pypi.org/project/onlykey-agent/): Using OnlyKey as hardware-based SSH/PGP agent
|
||||||
|
|
||||||
* [TREZOR](https://wallet.trezor.io/data/firmware/releases.json): `1.4.2+`
|
|
||||||
* [KeepKey](https://github.com/keepkey/keepkey-firmware/releases): `3.0.17+`
|
|
||||||
* [Ledger Nano S](https://github.com/LedgerHQ/blue-app-ssh-agent): `0.0.3+`
|
|
||||||
|
|
||||||
### TREZOR
|
The [/releases](/releases) page on Github contains the `libagent`
|
||||||
|
releases.
|
||||||
|
|
||||||
Make sure that your `udev` rules are configured [correctly](https://doc.satoshilabs.com/trezor-user/settingupchromeonlinux.html#manual-configuration-of-udev-rules).
|
## Documentation
|
||||||
Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
|
||||||
|
|
||||||
$ pip install trezor_agent
|
* **Installation** instructions are [here](doc/INSTALL.md)
|
||||||
|
* **SSH** instructions and common use cases are [here](doc/README-SSH.md)
|
||||||
|
|
||||||
Or, directly from the latest source code:
|
Note: If you're using Windows, see [trezor-ssh-agent](https://github.com/martin-lizner/trezor-ssh-agent) by Martin Lízner.
|
||||||
|
|
||||||
$ git clone https://github.com/romanz/trezor-agent
|
* **GPG** instructions and common use cases are [here](doc/README-GPG.md)
|
||||||
$ pip install --user -e trezor-agent/agents/trezor
|
* Instructions to configure a Trezor-style **PIN entry** program are [here](doc/README-PINENTRY.md)
|
||||||
|
|
||||||
If you have an error regarding `protobuf` imports (after installing it), please see [this issue](https://github.com/romanz/trezor-agent/issues/28).
|
|
||||||
|
|
||||||
### KeepKey
|
|
||||||
|
|
||||||
Make sure that your `udev` rules are configured [correctly](https://support.keepkey.com/support/solutions/articles/6000037796-keepkey-wallet-is-not-being-recognized-by-linux).
|
|
||||||
Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_agent) package:
|
|
||||||
|
|
||||||
$ pip install keepkey_agent
|
|
||||||
|
|
||||||
Or, directly from the latest source code:
|
|
||||||
|
|
||||||
$ git clone https://github.com/romanz/trezor-agent
|
|
||||||
$ pip install --user -e trezor-agent/agents/keepkey
|
|
||||||
|
|
||||||
### Ledger Nano S
|
|
||||||
|
|
||||||
Make sure that your `udev` rules are configured [correctly](http://support.ledgerwallet.com/knowledge_base/topics/ledger-wallet-is-not-recognized-on-linux).
|
|
||||||
Then, install the latest [ledger_agent](https://pypi.python.org/pypi/ledger_agent) package:
|
|
||||||
|
|
||||||
$ pip install ledger_agent
|
|
||||||
|
|
||||||
Or, directly from the latest source code:
|
|
||||||
|
|
||||||
$ git clone https://github.com/romanz/trezor-agent
|
|
||||||
$ pip install --user -e trezor-agent/agents/ledger
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
For SSH, see the [following instructions](README-SSH.md) (for Windows support,
|
|
||||||
see [trezor-ssh-agent](https://github.com/martin-lizner/trezor-ssh-agent) project (by Martin Lízner)).
|
|
||||||
|
|
||||||
For GPG, see the [following instructions](README-GPG.md).
|
|
||||||
|
|
||||||
See [here](https://github.com/romanz/python-trezor#pin-entering) for PIN entering instructions.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
If there is an import problem with the installed `protobuf` package,
|
|
||||||
see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it.
|
|
||||||
|
|
||||||
### Gitter
|
|
||||||
|
|
||||||
Questions, suggestions and discussions are welcome: [](https://gitter.im/romanz/trezor-agent)
|
|
||||||
|
|||||||
7
agents/fake/fake_device_agent.py
Normal file
7
agents/fake/fake_device_agent.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import libagent.gpg
|
||||||
|
import libagent.ssh
|
||||||
|
from libagent.device.fake_device import FakeDevice as DeviceType
|
||||||
|
|
||||||
|
ssh_agent = lambda: libagent.ssh.main(DeviceType)
|
||||||
|
gpg_tool = lambda: libagent.gpg.main(DeviceType)
|
||||||
|
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)
|
||||||
42
agents/fake/setup.py
Normal file
42
agents/fake/setup.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
print('NEVER USE THIS CODE FOR REAL-LIFE USE-CASES!!!')
|
||||||
|
print('ONLY FOR DEBUGGING AND TESTING!!!')
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='fake_device_agent',
|
||||||
|
version='0.9.0',
|
||||||
|
description='Testing trezor_agent with a fake device - NOT SAFE!!!',
|
||||||
|
author='Roman Zeyde',
|
||||||
|
author_email='roman.zeyde@gmail.com',
|
||||||
|
url='http://github.com/romanz/trezor-agent',
|
||||||
|
scripts=['fake_device_agent.py'],
|
||||||
|
install_requires=[
|
||||||
|
'libagent>=0.9.0',
|
||||||
|
],
|
||||||
|
platforms=['POSIX'],
|
||||||
|
classifiers=[
|
||||||
|
'Environment :: Console',
|
||||||
|
'Development Status :: 4 - Beta',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Intended Audience :: Information Technology',
|
||||||
|
'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',
|
||||||
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
|
'Topic :: System :: Networking',
|
||||||
|
'Topic :: Communications',
|
||||||
|
'Topic :: Security',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
],
|
||||||
|
entry_points={'console_scripts': [
|
||||||
|
'fake-device-agent = fake_device_agent:ssh_agent',
|
||||||
|
'fake-device-gpg = fake_device_agent:gpg_tool',
|
||||||
|
'fake-device-gpg-agent = fake_device_agent:gpg_agent',
|
||||||
|
]},
|
||||||
|
)
|
||||||
@@ -9,7 +9,10 @@ setup(
|
|||||||
author_email='roman.zeyde@gmail.com',
|
author_email='roman.zeyde@gmail.com',
|
||||||
url='http://github.com/romanz/trezor-agent',
|
url='http://github.com/romanz/trezor-agent',
|
||||||
scripts=['keepkey_agent.py'],
|
scripts=['keepkey_agent.py'],
|
||||||
install_requires=['libagent>=0.9.0', 'keepkey>=0.7.3'],
|
install_requires=[
|
||||||
|
'libagent>=0.9.0',
|
||||||
|
'keepkey>=0.7.3'
|
||||||
|
],
|
||||||
platforms=['POSIX'],
|
platforms=['POSIX'],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
@@ -22,6 +25,7 @@ setup(
|
|||||||
'Programming Language :: Python :: 2.7',
|
'Programming Language :: Python :: 2.7',
|
||||||
'Programming Language :: Python :: 3.4',
|
'Programming Language :: Python :: 3.4',
|
||||||
'Programming Language :: Python :: 3.5',
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
'Topic :: System :: Networking',
|
'Topic :: System :: Networking',
|
||||||
'Topic :: Communications',
|
'Topic :: Communications',
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ setup(
|
|||||||
author_email='roman.zeyde@gmail.com',
|
author_email='roman.zeyde@gmail.com',
|
||||||
url='http://github.com/romanz/trezor-agent',
|
url='http://github.com/romanz/trezor-agent',
|
||||||
scripts=['ledger_agent.py'],
|
scripts=['ledger_agent.py'],
|
||||||
install_requires=['libagent>=0.9.0', 'ledgerblue>=0.1.8'],
|
install_requires=[
|
||||||
|
'libagent>=0.9.0',
|
||||||
|
'ledgerblue>=0.1.8'
|
||||||
|
],
|
||||||
platforms=['POSIX'],
|
platforms=['POSIX'],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
@@ -22,6 +25,7 @@ setup(
|
|||||||
'Programming Language :: Python :: 2.7',
|
'Programming Language :: Python :: 2.7',
|
||||||
'Programming Language :: Python :: 3.4',
|
'Programming Language :: Python :: 3.4',
|
||||||
'Programming Language :: Python :: 3.5',
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
'Topic :: System :: Networking',
|
'Topic :: System :: Networking',
|
||||||
'Topic :: Communications',
|
'Topic :: Communications',
|
||||||
|
|||||||
7
agents/onlykey/onlykey_agent.py
Normal file
7
agents/onlykey/onlykey_agent.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import libagent.gpg
|
||||||
|
import libagent.ssh
|
||||||
|
from libagent.device.onlykey import OnlyKey as DeviceType
|
||||||
|
|
||||||
|
ssh_agent = lambda: libagent.ssh.main(DeviceType)
|
||||||
|
gpg_tool = lambda: libagent.gpg.main(DeviceType)
|
||||||
|
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)
|
||||||
40
agents/onlykey/setup.py
Normal file
40
agents/onlykey/setup.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='onlykey-agent',
|
||||||
|
version='1.2.0',
|
||||||
|
description='Using onlykey as hardware SSH/GPG agent',
|
||||||
|
author='CryptoTrust',
|
||||||
|
author_email='t@crp.to',
|
||||||
|
url='http://github.com/trustcrypto/onlykey-agent',
|
||||||
|
scripts=['onlykey_agent.py'],
|
||||||
|
install_requires=[
|
||||||
|
'libagent>=0.14.2',
|
||||||
|
'onlykey>=1.2.0'
|
||||||
|
],
|
||||||
|
platforms=['POSIX'],
|
||||||
|
classifiers=[
|
||||||
|
'Environment :: Console',
|
||||||
|
'Development Status :: 4 - Beta',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Intended Audience :: Information Technology',
|
||||||
|
'Intended Audience :: System Administrators',
|
||||||
|
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||||
|
'Operating System :: POSIX',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
|
'Topic :: System :: Networking',
|
||||||
|
'Topic :: Communications',
|
||||||
|
'Topic :: Security',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
],
|
||||||
|
entry_points={'console_scripts': [
|
||||||
|
'onlykey-agent = onlykey_agent:ssh_agent',
|
||||||
|
'onlykey-gpg = onlykey_agent:gpg_tool',
|
||||||
|
'onlykey-gpg-agent = onlykey_agent:gpg_agent',
|
||||||
|
]},
|
||||||
|
)
|
||||||
@@ -3,13 +3,16 @@ from setuptools import setup
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='trezor_agent',
|
name='trezor_agent',
|
||||||
version='0.9.0',
|
version='0.11.0',
|
||||||
description='Using Trezor as hardware SSH/GPG agent',
|
description='Using Trezor as hardware SSH/GPG agent',
|
||||||
author='Roman Zeyde',
|
author='Roman Zeyde',
|
||||||
author_email='roman.zeyde@gmail.com',
|
author_email='roman.zeyde@gmail.com',
|
||||||
url='http://github.com/romanz/trezor-agent',
|
url='http://github.com/romanz/trezor-agent',
|
||||||
scripts=['trezor_agent.py'],
|
scripts=['trezor_agent.py'],
|
||||||
install_requires=['libagent>=0.9.0', 'trezor>=0.7.6'],
|
install_requires=[
|
||||||
|
'libagent>=0.14.0',
|
||||||
|
'trezor[hidapi]>=0.12.0,<0.13'
|
||||||
|
],
|
||||||
platforms=['POSIX'],
|
platforms=['POSIX'],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
@@ -19,9 +22,10 @@ setup(
|
|||||||
'Intended Audience :: System Administrators',
|
'Intended Audience :: System Administrators',
|
||||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||||
'Operating System :: POSIX',
|
'Operating System :: POSIX',
|
||||||
'Programming Language :: Python :: 2.7',
|
|
||||||
'Programming Language :: Python :: 3.4',
|
|
||||||
'Programming Language :: Python :: 3.5',
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
'Topic :: System :: Networking',
|
'Topic :: System :: Networking',
|
||||||
'Topic :: Communications',
|
'Topic :: Communications',
|
||||||
|
|||||||
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:])
|
||||||
51
doc/DESIGN.md
Normal file
51
doc/DESIGN.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
Most cryptographic tools (such as gpg, ssh and openssl) allow the offloading of some key cryptographic steps to *engines* or *agents*. This is to allow sensitive operations, such as asking for a password or doing the actual encryption step, to be kept separate from the larger body of code. This makes it easier to secure those steps, move them onto hardware or easier to audit.
|
||||||
|
|
||||||
|
SSH and GPG do this by means of a simple interprocess communication protocol (usually a unix domain socket) and an agent (`ssh-agent`) or GPG key daemon (`gpg-agent`). The `trezor-agent` mimics these two protocols.
|
||||||
|
|
||||||
|
These two agents make the connection between the front end (e.g. a `gpg --sign` command, or an `ssh user@fqdn`). And then they wait for a request from the 'front end', and then do the actual asking for a password and subsequent using the private key to sign or decrypt something.
|
||||||
|
|
||||||
|
The various hardware wallets (Trezor, KeepKey and Ledger) each have the ability (as of Firmware 1.3.4) to use the NIST P-256 elliptic curve to sign, encrypt or decrypt. This curve can be used with S/MIME, GPG and SSH.
|
||||||
|
|
||||||
|
So when you `ssh` to a machine - rather than consult the normal ssh-agent (which in turn will use your private SSH key in files such as `~/.ssh/id_rsa`) -- the trezor-agent will aks your hardware wallet to use its private key to sign the challenge.
|
||||||
|
|
||||||
|
## Key Naming
|
||||||
|
|
||||||
|
`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).
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
|
||||||
|
It is common for SSH users to use one (or a few) private keys with SSH on all servers they log into. The `trezor-agent` is slightly more cautious and derives a child key that is *unique* to the server and username you are logging into from your master private key on the device.
|
||||||
|
|
||||||
|
So taking a commmand such as:
|
||||||
|
|
||||||
|
$ 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.
|
||||||
|
|
||||||
|
This way they keypair is specific to the server/hostname/port and protocol combination used. And it is this private key that is used to sign the nonce passed by the SSH server (as opposed to the master key).
|
||||||
|
|
||||||
|
The `trezor-agent` then instructs SSH to connect to the server. It will then engage in the normal challenge response process, ask the hardware wallet to blindly sign any nonce flashed by the server with the derived child private key and return this to the server. It then hands over to normal SSH for the rest of the logged in session.
|
||||||
|
|
||||||
|
### GPG
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
[2]: https://github.com/satoshilabs/slips/blob/master/slip-0013.md
|
||||||
|
[3]: https://github.com/satoshilabs/slips/blob/master/slip-0017.md
|
||||||
164
doc/INSTALL.md
Normal file
164
doc/INSTALL.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
Install the following packages (depending on your distribution):
|
||||||
|
|
||||||
|
### OS dependencies
|
||||||
|
|
||||||
|
This software needs Python, libusb, and libudev along with development files.
|
||||||
|
|
||||||
|
You can install them on these distributions as follows:
|
||||||
|
|
||||||
|
##### Debian
|
||||||
|
|
||||||
|
$ apt-get install python3-pip python3-dev python3-tk libusb-1.0-0-dev libudev-dev
|
||||||
|
|
||||||
|
##### 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
|
||||||
|
|
||||||
|
If you are using python3 or your system `pip` command points to `pip3.x`
|
||||||
|
(`/etc/alternatives/pip -> /usr/bin/pip3.6`) you will need to install these
|
||||||
|
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.
|
||||||
|
|
||||||
|
You can verify your installed version by running:
|
||||||
|
```
|
||||||
|
$ gpg2 --version | head -n1
|
||||||
|
gpg (GnuPG) 2.1.15
|
||||||
|
```
|
||||||
|
|
||||||
|
* Follow this installation guide for [Debian](https://gist.github.com/vt0r/a2f8c0bcb1400131ff51)
|
||||||
|
* Install GPG for [macOS](https://sourceforge.net/p/gpgosx/docu/Download/)
|
||||||
|
* Install packages for Ubuntu 16.04 [here](https://launchpad.net/ubuntu/+source/gnupg2)
|
||||||
|
* Install packages for Linux Mint 18 [here](https://community.linuxmint.com/software/view/gnupg2)
|
||||||
|
|
||||||
|
# 2. Install the TREZOR agent
|
||||||
|
|
||||||
|
1. Make sure you are running the latest firmware version on your Trezor:
|
||||||
|
|
||||||
|
* [TREZOR firmware releases](https://wallet.trezor.io/data/firmware/releases.json): `1.4.2+`
|
||||||
|
|
||||||
|
2. Make sure that your `udev` rules are configured [correctly](https://doc.satoshilabs.com/trezor-user/settingupchromeonlinux.html#manual-configuration-of-udev-rules).
|
||||||
|
|
||||||
|
3. Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pip3 install Cython hidapi
|
||||||
|
$ pip3 install trezor_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, directly from the latest source code:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/romanz/trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent/agents/trezor
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, through Homebrew on macOS:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ brew install trezor-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
# 3. Install the KeepKey agent
|
||||||
|
|
||||||
|
1. Make sure you are running the latest firmware version on your KeepKey:
|
||||||
|
|
||||||
|
* [KeepKey firmware releases](https://github.com/keepkey/keepkey-firmware/releases): `3.0.17+`
|
||||||
|
|
||||||
|
2. Make sure that your `udev` rules are configured [correctly](https://support.keepkey.com/support/solutions/articles/6000037796-keepkey-wallet-is-not-being-recognized-by-linux).
|
||||||
|
Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_agent) package:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pip3 install keepkey_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, on Mac using Homebrew:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ homebrew install keepkey-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, directly from the latest source code:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/romanz/trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent/agents/keepkey
|
||||||
|
```
|
||||||
|
|
||||||
|
# 4. Install the Ledger Nano S agent
|
||||||
|
|
||||||
|
1. Make sure you are running the latest firmware version on your Ledger Nano S:
|
||||||
|
|
||||||
|
* [Ledger Nano S firmware releases](https://github.com/LedgerHQ/blue-app-ssh-agent): `0.0.3+` (install [SSH/PGP Agent](https://www.ledgerwallet.com/images/apps/chrome-mngr-apps.png) app)
|
||||||
|
|
||||||
|
2. Make sure that your `udev` rules are configured [correctly](https://ledger.zendesk.com/hc/en-us/articles/115005165269-What-if-Ledger-Wallet-is-not-recognized-on-Linux-).
|
||||||
|
3. Then, install the latest [ledger_agent](https://pypi.python.org/pypi/ledger_agent) package:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pip3 install ledger_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, directly from the latest source code:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/romanz/trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent/agents/ledger
|
||||||
|
```
|
||||||
|
|
||||||
|
# 5. Install the OnlyKey agent
|
||||||
|
|
||||||
|
1. Make sure you are running the latest firmware version on your OnlyKey:
|
||||||
|
|
||||||
|
* [OnlyKey Firmware Upgrade Guide](https://docs.crp.to/upgradeguide.html)
|
||||||
|
|
||||||
|
2. Make sure that your `udev` rules are configured [correctly](https://docs.crp.to/linux.html#udev-rule).
|
||||||
|
3. Then, install the latest [onlykey-agent](https://pypi.python.org/pypi/onlykey-agent) package:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pip3 install onlykey-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, directly from the latest source code:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/romanz/trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent
|
||||||
|
$ pip3 install --user -e trezor-agent/agents/onlykey
|
||||||
|
```
|
||||||
|
|
||||||
|
# 6. Installation Troubleshooting
|
||||||
|
|
||||||
|
If there is an import problem with the installed `protobuf` package,
|
||||||
|
see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it.
|
||||||
|
|
||||||
|
If you can't find the command-line utilities (after running `pip install --user`),
|
||||||
|
please make sure that `~/.local/bin` is on your `PATH` variable
|
||||||
|
(see a [relevant](https://github.com/pypa/pip/issues/3813) issue).
|
||||||
|
|
||||||
|
If you can't find command-line utilities and are on macOS/OSX check `~/Library/Python/2.7/bin` and add to `PATH` if necessary (see a [relevant](https://github.com/romanz/trezor-agent/issues/155) issue).
|
||||||
251
doc/README-GPG.md
Normal file
251
doc/README-GPG.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# GPG Agent
|
||||||
|
|
||||||
|
Note: the GPG-related code is still under development, so please try the current implementation
|
||||||
|
and please let me [know](https://github.com/romanz/trezor-agent/issues/new) if something doesn't
|
||||||
|
work well for you. If possible:
|
||||||
|
|
||||||
|
* record the session (e.g. using [asciinema](https://asciinema.org))
|
||||||
|
* attach the GPG agent log from `~/.gnupg/{trezor,ledger}/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz))
|
||||||
|
|
||||||
|
Thanks!
|
||||||
|
|
||||||
|
## 1. Configuration
|
||||||
|
|
||||||
|
1. Initialize the agent GPG directory.
|
||||||
|
|
||||||
|
[](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8)
|
||||||
|
|
||||||
|
Run
|
||||||
|
|
||||||
|
```
|
||||||
|
$ (trezor|keepkey|ledger|onlykey)-gpg init "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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|onlykey)` 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).
|
||||||
|
|
||||||
|
If you wish to switch back to your software keys unset `GNUPGHOME`.
|
||||||
|
|
||||||
|
3. Log out and back into your session to ensure your environment is updated everywhere.
|
||||||
|
|
||||||
|
## 2. Usage
|
||||||
|
|
||||||
|
You can use any GPG commands or software that uses GPG as usual and will be prompted to interact with your hardware device as necessary. The agent is automatically started if it isn't running when you run any `gpg` command.
|
||||||
|
|
||||||
|
##### Restarting the agent
|
||||||
|
|
||||||
|
If you change settings or need to restart the agent for some other reason, simply kill it. It will restart the next time GPG is invoked.
|
||||||
|
|
||||||
|
## 3. Common Use Cases
|
||||||
|
|
||||||
|
### Sign and decrypt files
|
||||||
|
|
||||||
|
[](https://asciinema.org/a/120441)
|
||||||
|
|
||||||
|
### Inspect GPG keys
|
||||||
|
You can use GNU Privacy Assistant (GPA) in order to inspect the created keys and perform signature and decryption operations as usual:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sudo apt install gpa
|
||||||
|
$ gpa
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://www.gnupg.org/related_software/swlist.html#gpa)
|
||||||
|
|
||||||
|
### Sign Git commits and tags
|
||||||
|
|
||||||
|
Git can use GPG to sign and verify commits and tags (see [here](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git config --local commit.gpgsign 1
|
||||||
|
$ git config --local gpg.program $(which gpg2)
|
||||||
|
$ git commit --gpg-sign # create GPG-signed commit
|
||||||
|
$ git log --show-signature -1 # verify commit signature
|
||||||
|
$ 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.
|
||||||
|
|
||||||
|
##### With `pass`:
|
||||||
|
|
||||||
|
First install `pass` from [passwordstore.org] and initialize it to use your TREZOR-based GPG identity:
|
||||||
|
```
|
||||||
|
$ pass init "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||||
|
Password store initialized for Roman Zeyde <roman.zeyde@gmail.com>
|
||||||
|
```
|
||||||
|
Then, you can generate truly random passwords and save them encrypted using your public key (as separate `.gpg` files under `~/.password-store/`):
|
||||||
|
```
|
||||||
|
$ pass generate Dev/github 32
|
||||||
|
$ pass generate Social/hackernews 32
|
||||||
|
$ pass generate Social/twitter 32
|
||||||
|
$ pass generate VPS/linode 32
|
||||||
|
$ pass
|
||||||
|
Password Store
|
||||||
|
├── Dev
|
||||||
|
│ └── github
|
||||||
|
├── Social
|
||||||
|
│ ├── hackernews
|
||||||
|
│ └── twitter
|
||||||
|
└── VPS
|
||||||
|
└── linode
|
||||||
|
```
|
||||||
|
In order to paste them into the browser, you'd need to decrypt the password using your hardware device:
|
||||||
|
```
|
||||||
|
$ pass --clip VPS/linode
|
||||||
|
Copied VPS/linode to clipboard. Will clear in 45 seconds.
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use the following [Qt-based UI](https://qtpass.org/) for `pass`:
|
||||||
|
```
|
||||||
|
$ sudo apt install qtpass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Re-generate a GPG identity
|
||||||
|
[](https://asciinema.org/a/5tIQa5qt5bV134oeOqFyKEU29)
|
||||||
|
|
||||||
|
If you've forgotten the timestamp value, but still have access to the public key, then you can
|
||||||
|
retrieve the timestamp with the following command (substitute "john@doe.bit" for the key's address or id):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ gpg2 --export 'john@doe.bit' | gpg2 --list-packets | grep created | head -n1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add new UIDs to your identity
|
||||||
|
|
||||||
|
After your main identity is created, you can add new user IDs using the regular GnuPG commands:
|
||||||
|
```
|
||||||
|
$ trezor-gpg init "Foobar" -vv
|
||||||
|
$ export GNUPGHOME=${HOME}/.gnupg/trezor
|
||||||
|
$ gpg2 -K
|
||||||
|
------------------------------------------
|
||||||
|
sec nistp256/6275E7DA 2017-12-05 [SC]
|
||||||
|
uid [ultimate] Foobar
|
||||||
|
ssb nistp256/35F58F26 2017-12-05 [E]
|
||||||
|
|
||||||
|
$ gpg2 --edit Foobar
|
||||||
|
gpg> adduid
|
||||||
|
Real name: Xyzzy
|
||||||
|
Email address:
|
||||||
|
Comment:
|
||||||
|
You selected this USER-ID:
|
||||||
|
"Xyzzy"
|
||||||
|
|
||||||
|
Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? o
|
||||||
|
|
||||||
|
gpg> save
|
||||||
|
|
||||||
|
$ gpg2 -K
|
||||||
|
------------------------------------------
|
||||||
|
sec nistp256/6275E7DA 2017-12-05 [SC]
|
||||||
|
uid [ultimate] Xyzzy
|
||||||
|
uid [ultimate] Foobar
|
||||||
|
ssb nistp256/35F58F26 2017-12-05 [E]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate GnuPG subkeys
|
||||||
|
In order to add TREZOR-based subkey to an existing GnuPG identity, use the `--subkey` flag:
|
||||||
|
```
|
||||||
|
$ gpg2 -k foobar
|
||||||
|
pub rsa2048/90C4064B 2017-10-10 [SC]
|
||||||
|
uid [ultimate] foobar
|
||||||
|
sub rsa2048/4DD05FF0 2017-10-10 [E]
|
||||||
|
|
||||||
|
$ trezor-gpg init "foobar" --subkey
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://asciinema.org/a/Ick5G724zrZRFsGY7ZUdFSnV1)
|
||||||
|
|
||||||
|
In order to enter existing GPG passphrase, I recommend installing and using a graphical Pinentry:
|
||||||
|
```
|
||||||
|
$ sudo apt install pinentry-gnome3
|
||||||
|
$ sudo update-alternatives --config pinentry
|
||||||
|
There are 4 choices for the alternative pinentry (providing /usr/bin/pinentry).
|
||||||
|
|
||||||
|
Selection Path Priority Status
|
||||||
|
------------------------------------------------------------
|
||||||
|
* 0 /usr/bin/pinentry-gnome3 90 auto mode
|
||||||
|
1 /usr/bin/pinentry-curses 50 manual mode
|
||||||
|
2 /usr/bin/pinentry-gnome3 90 manual mode
|
||||||
|
3 /usr/bin/pinentry-qt 80 manual mode
|
||||||
|
4 /usr/bin/pinentry-tty 30 manual mode
|
||||||
|
|
||||||
|
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` or `onlykey` 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)
|
||||||
|
```
|
||||||
226
doc/README-SSH.md
Normal file
226
doc/README-SSH.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# SSH Agent
|
||||||
|
|
||||||
|
## 1. Configuration
|
||||||
|
|
||||||
|
SSH requires no configuration, but you may put common command line options in `~/.ssh/agent.conf` to avoid repeating them in every invocation.
|
||||||
|
|
||||||
|
See `(trezor|keepkey|ledger|onlykey)-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|onlykey)-agent` program to work with SSH. It has three main modes of operation:
|
||||||
|
|
||||||
|
##### 1. Export public keys
|
||||||
|
|
||||||
|
To get your public key so you can add it to `authorized_hosts` or allow
|
||||||
|
ssh access to a service that supports it, run:
|
||||||
|
|
||||||
|
```
|
||||||
|
(trezor|keepkey|ledger|onlykey)-agent identity@myhost
|
||||||
|
```
|
||||||
|
|
||||||
|
The identity (ex: `identity@myhost`) is used to derive the public key and is added as a comment to the exported key string.
|
||||||
|
|
||||||
|
##### 2. Run a command with the agent's environment
|
||||||
|
|
||||||
|
Run
|
||||||
|
|
||||||
|
```
|
||||||
|
$ (trezor|keepkey|ledger|onlykey)-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.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
(trezor|keepkey|ledger|onlykey)-agent -e ed25519 bob@example.com -- rsync up/ bob@example.com:/home/bob
|
||||||
|
```
|
||||||
|
|
||||||
|
As a shortcut you can run
|
||||||
|
|
||||||
|
```
|
||||||
|
$ (trezor|keepkey|ledger|onlykey)-agent identity@myhost -s
|
||||||
|
```
|
||||||
|
|
||||||
|
to start a shell with the proper environment.
|
||||||
|
|
||||||
|
##### 3. Connect to a server directly via `(trezor|keepkey|ledger|onlykey)-agent`
|
||||||
|
|
||||||
|
If you just want to connect to a server this is the simplest way to do it:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ (trezor|keepkey|ledger|onlykey)-agent user@remotehost -c
|
||||||
|
```
|
||||||
|
|
||||||
|
The identity `user@remotehost` is used as both the destination user and host as well as for key derivation, so you must generate a separate key for each host you connect to.
|
||||||
|
|
||||||
|
## 3. Common Use Cases
|
||||||
|
|
||||||
|
### Start a single SSH session
|
||||||
|
[](https://asciinema.org/a/22959)
|
||||||
|
|
||||||
|
### Start multiple SSH sessions from a sub-shell
|
||||||
|
|
||||||
|
This feature allows using regular SSH-related commands within a subprocess running user's shell.
|
||||||
|
`SSH_AUTH_SOCK` environment variable is defined for the subprocess (pointing to the SSH agent, running as a parent process).
|
||||||
|
This way the user can use SSH-related commands (e.g. `ssh`, `ssh-add`, `sshfs`, `git`, `hg`), while authenticating via the hardware device.
|
||||||
|
|
||||||
|
[](https://asciinema.org/a/33240)
|
||||||
|
|
||||||
|
### Load different SSH identities from configuration file
|
||||||
|
|
||||||
|
[](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny)
|
||||||
|
|
||||||
|
### Implement passwordless login
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
/tmp $ trezor-agent user@ssh.hostname.com -v > hostname.pub
|
||||||
|
2015-09-02 15:03:18,929 INFO getting "ssh://user@ssh.hostname.com" public key from Trezor...
|
||||||
|
2015-09-02 15:03:23,342 INFO disconnected from Trezor
|
||||||
|
/tmp $ cat hostname.pub
|
||||||
|
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGSevcDwmT+QaZPUEWUUjTeZRBICChxMKuJ7dRpBSF8+qt+8S1GBK5Zj8Xicc8SHG/SE/EXKUL2UU3kcUzE7ADQ= ssh://user@ssh.hostname.com
|
||||||
|
|
||||||
|
Append `hostname.pub` contents to `/home/user/.ssh/authorized_keys`
|
||||||
|
configuration file at `ssh.hostname.com`, so the remote server
|
||||||
|
would allow you to login using the corresponding private key signature.
|
||||||
|
|
||||||
|
### Access remote Git/Mercurial repositories
|
||||||
|
|
||||||
|
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 > ~/.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 ssh-shell='trezor-agent ~/.ssh/github.pub -v --shell'
|
||||||
|
|
||||||
|
Now, you can use regular Git commands under the "SSH-enabled" sub-shell:
|
||||||
|
|
||||||
|
$ 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)):
|
||||||
|
|
||||||
|
$ ssh-shell
|
||||||
|
$ hg push
|
||||||
|
|
||||||
|
### Start the agent as a systemd unit
|
||||||
|
|
||||||
|
##### 1. Create these files in `~/.config/systemd/user`
|
||||||
|
|
||||||
|
Replace `trezor` with `keepkey` or `ledger` or `onlykey` as required.
|
||||||
|
|
||||||
|
###### `trezor-ssh-agent.service`
|
||||||
|
|
||||||
|
````
|
||||||
|
[Unit]
|
||||||
|
Description=trezor-agent SSH agent
|
||||||
|
Requires=trezor-ssh-agent.socket
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Restart=always
|
||||||
|
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.
|
||||||
|
|
||||||
|
`IDENTITY` can be a path (starting with `/`) to a file containing a list of public keys
|
||||||
|
generated by Trezor. I.e. `/home/myUser/.ssh/trezor.conf` with one public key per line.
|
||||||
|
This is a more convenient way to have a systemd setup that has to handle multiple
|
||||||
|
keys/hosts.
|
||||||
|
|
||||||
|
When updating the file, make sure to restart trezor-agent.
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
````
|
||||||
|
[Unit]
|
||||||
|
Description=trezor-agent SSH agent socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=%t/trezor-agent/S.ssh
|
||||||
|
FileDescriptorName=ssh
|
||||||
|
Service=trezor-ssh-agent.service
|
||||||
|
SocketMode=0600
|
||||||
|
DirectoryMode=0700
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
|
````
|
||||||
|
|
||||||
|
##### 2. Run
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl --user start trezor-ssh-agent.service trezor-ssh-agent.socket
|
||||||
|
systemctl --user enable trezor-ssh-agent.socket
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 3. Add this line to your `.bashrc` or equivalent file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SSH_AUTH_SOCK=$(systemctl show --user --property=Listen trezor-ssh-agent.socket | grep -o "/run.*" | cut -d " " -f 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure the SSH_AUTH_SOCK variable matches the location of the socket that trezor-agent
|
||||||
|
is listening on: `ps -x | grep trezor-agent`. In this setup trezor-agent should start
|
||||||
|
automatically when the socket is opened.
|
||||||
|
|
||||||
|
##### 4. SSH will now automatically use your device key in all terminals.
|
||||||
|
|
||||||
|
## 4. Troubleshooting
|
||||||
|
|
||||||
|
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`) .
|
||||||
|
|
||||||
|
##### `IdentitiesOnly` SSH option
|
||||||
|
|
||||||
|
Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`.
|
||||||
|
|
||||||
|
IdentitiesOnly
|
||||||
|
Specifies that ssh(1) should only use the authentication identity files configured in
|
||||||
|
the ssh_config files, even if ssh-agent(1) or a PKCS11Provider offers more identities.
|
||||||
|
The argument to this keyword must be “yes” or “no”.
|
||||||
|
This option is intended for situations where ssh-agent offers many different identities.
|
||||||
|
The default is “no”.
|
||||||
|
|
||||||
|
If you are failing to connect, save your public key using:
|
||||||
|
|
||||||
|
$ 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
|
||||||
26
doc/enigmail.md
Normal file
26
doc/enigmail.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Tutorial
|
||||||
|
|
||||||
|
First, install [Thunderbird](https://www.mozilla.org/en-US/thunderbird/) and
|
||||||
|
the [Enigmail](https://www.enigmail.net/index.php/en/) add-on.
|
||||||
|
|
||||||
|
Make sure to use the correct GNUPGHOME path before starting Thunderbird:
|
||||||
|
```bash
|
||||||
|
$ export GNUPGHOME=${HOME}/.gnupg/trezor
|
||||||
|
$ thunderbird
|
||||||
|
```
|
||||||
|
Run the Enigmail's setup wizard and choose your GPG identity:
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Then, you can compose encrypted (and signed) messages using the regular UI:
|
||||||
|
|
||||||
|
NOTES:
|
||||||
|
- The email's title is **public** - only the body is encrypted.
|
||||||
|
- You will be asked to confirm the signature using the hardware device before sending the email.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
After receiving the email, you will be asked to confirm the decryption the hardware device:
|
||||||
|

|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Cryptographic hardware device management."""
|
"""Cryptographic hardware device management."""
|
||||||
|
|
||||||
from . import interface
|
from . import interface, ui
|
||||||
|
|||||||
70
libagent/device/fake_device.py
Normal file
70
libagent/device/fake_device.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Fake device - ONLY FOR TESTS!!! (NEVER USE WITH REAL DATA)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import ecdsa
|
||||||
|
|
||||||
|
from . import interface
|
||||||
|
from .. import formats
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_support(identity):
|
||||||
|
"""Make sure the device supports given configuration."""
|
||||||
|
if identity.curve_name not in {formats.CURVE_NIST256}:
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Unsupported elliptic curve: {}'.format(identity.curve_name))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDevice(interface.Device):
|
||||||
|
"""Connection to TREZOR device."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def package_name(cls):
|
||||||
|
"""Python package name."""
|
||||||
|
return 'fake-device-agent'
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Return "dummy" connection."""
|
||||||
|
log.critical('NEVER USE THIS CODE FOR REAL-LIFE USE-CASES!!!')
|
||||||
|
log.critical('ONLY FOR DEBUGGING AND TESTING!!!')
|
||||||
|
# The code below uses HARD-CODED secret key - and should be used ONLY
|
||||||
|
# for GnuPG integration tests (e.g. when no real device is available).
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.secexp = 1
|
||||||
|
self.sk = ecdsa.SigningKey.from_secret_exponent(
|
||||||
|
secexp=self.secexp, curve=ecdsa.curves.NIST256p, hashfunc=hashlib.sha256)
|
||||||
|
self.vk = self.sk.get_verifying_key()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def pubkey(self, identity, ecdh=False):
|
||||||
|
"""Return public key."""
|
||||||
|
_verify_support(identity)
|
||||||
|
data = self.vk.to_string()
|
||||||
|
x, y = data[:32], data[32:]
|
||||||
|
prefix = bytearray([2 + (bytearray(y)[0] & 1)])
|
||||||
|
return bytes(prefix) + x
|
||||||
|
|
||||||
|
def sign(self, identity, blob):
|
||||||
|
"""Sign given blob and return the signature (as bytes)."""
|
||||||
|
if identity.identity_dict['proto'] in {'ssh'}:
|
||||||
|
digest = hashlib.sha256(blob).digest()
|
||||||
|
else:
|
||||||
|
digest = blob
|
||||||
|
return self.sk.sign_digest_deterministic(digest=digest,
|
||||||
|
hashfunc=hashlib.sha256)
|
||||||
|
|
||||||
|
def ecdh(self, identity, pubkey):
|
||||||
|
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||||
|
assert pubkey[:1] == b'\x04'
|
||||||
|
peer = ecdsa.VerifyingKey.from_string(
|
||||||
|
pubkey[1:],
|
||||||
|
curve=ecdsa.curves.NIST256p,
|
||||||
|
hashfunc=hashlib.sha256)
|
||||||
|
shared = ecdsa.VerifyingKey.from_public_point(
|
||||||
|
point=(peer.pubkey.point * self.secexp),
|
||||||
|
curve=ecdsa.curves.NIST256p,
|
||||||
|
hashfunc=hashlib.sha256)
|
||||||
|
return shared.to_string()
|
||||||
@@ -6,6 +6,8 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
import unidecode
|
||||||
|
|
||||||
from .. import formats, util
|
from .. import formats, util
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -57,7 +59,7 @@ class DeviceError(Error):
|
|||||||
"""Error during device operation."""
|
"""Error during device operation."""
|
||||||
|
|
||||||
|
|
||||||
class Identity(object):
|
class Identity:
|
||||||
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
||||||
|
|
||||||
def __init__(self, identity_str, curve_name):
|
def __init__(self, identity_str, curve_name):
|
||||||
@@ -67,16 +69,22 @@ class Identity(object):
|
|||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
"""Return a copy of identity_dict items."""
|
"""Return a copy of identity_dict items."""
|
||||||
return self.identity_dict.items()
|
return [(k, unidecode.unidecode(v))
|
||||||
|
for k, v in self.identity_dict.items()]
|
||||||
|
|
||||||
def __str__(self):
|
def to_bytes(self):
|
||||||
|
"""Transliterate Unicode into ASCII."""
|
||||||
|
s = identity_to_string(self.identity_dict)
|
||||||
|
return unidecode.unidecode(s).encode('ascii')
|
||||||
|
|
||||||
|
def to_string(self):
|
||||||
"""Return identity serialized to string."""
|
"""Return identity serialized to string."""
|
||||||
return '<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
|
return u'<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
|
||||||
|
|
||||||
def get_bip32_address(self, ecdh=False):
|
def get_bip32_address(self, ecdh=False):
|
||||||
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
||||||
index = struct.pack('<L', self.identity_dict.get('index', 0))
|
index = struct.pack('<L', self.identity_dict.get('index', 0))
|
||||||
addr = index + identity_to_string(self.identity_dict).encode('ascii')
|
addr = index + self.to_bytes()
|
||||||
log.debug('bip32 address string: %r', addr)
|
log.debug('bip32 address string: %r', addr)
|
||||||
digest = hashlib.sha256(addr).digest()
|
digest = hashlib.sha256(addr).digest()
|
||||||
s = io.BytesIO(bytearray(digest))
|
s = io.BytesIO(bytearray(digest))
|
||||||
@@ -94,7 +102,7 @@ class Identity(object):
|
|||||||
return self.curve_name
|
return self.curve_name
|
||||||
|
|
||||||
|
|
||||||
class Device(object):
|
class Device:
|
||||||
"""Abstract cryptographic hardware device interface."""
|
"""Abstract cryptographic hardware device interface."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -105,6 +113,14 @@ class Device(object):
|
|||||||
"""Connect to device, otherwise raise NotFoundError."""
|
"""Connect to device, otherwise raise NotFoundError."""
|
||||||
raise NotImplementedError()
|
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):
|
def __enter__(self):
|
||||||
"""Allow usage as context manager."""
|
"""Allow usage as context manager."""
|
||||||
self.conn = self.connect()
|
self.conn = self.connect()
|
||||||
@@ -113,7 +129,7 @@ class Device(object):
|
|||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
"""Close and mark as disconnected."""
|
"""Close and mark as disconnected."""
|
||||||
try:
|
try:
|
||||||
self.conn.close()
|
self.close()
|
||||||
except Exception as e: # pylint: disable=broad-except
|
except Exception as e: # pylint: disable=broad-except
|
||||||
log.exception('close failed: %s', e)
|
log.exception('close failed: %s', e)
|
||||||
self.conn = None
|
self.conn = None
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ def _verify_support(identity, ecdh):
|
|||||||
class KeepKey(trezor.Trezor):
|
class KeepKey(trezor.Trezor):
|
||||||
"""Connection to KeepKey device."""
|
"""Connection to KeepKey device."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def package_name(cls):
|
||||||
|
"""Python package name (at PyPI)."""
|
||||||
|
return 'keepkey-agent'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _defs(self):
|
def _defs(self):
|
||||||
from . import keepkey_defs
|
from . import keepkey_defs
|
||||||
@@ -27,6 +32,9 @@ class KeepKey(trezor.Trezor):
|
|||||||
|
|
||||||
required_version = '>=1.0.4'
|
required_version = '>=1.0.4'
|
||||||
|
|
||||||
|
def _override_state_handler(self, _):
|
||||||
|
"""No support for `state` handling on Keepkey."""
|
||||||
|
|
||||||
def pubkey(self, identity, ecdh=False):
|
def pubkey(self, identity, ecdh=False):
|
||||||
"""Return public key."""
|
"""Return public key."""
|
||||||
_verify_support(identity, ecdh)
|
_verify_support(identity, ecdh)
|
||||||
|
|||||||
@@ -2,8 +2,22 @@
|
|||||||
|
|
||||||
# pylint: disable=unused-import,import-error
|
# pylint: disable=unused-import,import-error
|
||||||
|
|
||||||
from keepkeylib.client import CallException as Error
|
from keepkeylib.client import CallException, PinException
|
||||||
from keepkeylib.client import KeepKeyClient as Client
|
from keepkeylib.client import KeepKeyClient as Client
|
||||||
from keepkeylib.messages_pb2 import PassphraseAck
|
from keepkeylib.messages_pb2 import PassphraseAck, PinMatrixAck
|
||||||
from keepkeylib.transport_hid import HidTransport as Transport
|
from keepkeylib.transport_hid import HidTransport
|
||||||
|
from keepkeylib.transport_webusb import WebUsbTransport
|
||||||
from keepkeylib.types_pb2 import IdentityType
|
from keepkeylib.types_pb2 import IdentityType
|
||||||
|
|
||||||
|
get_public_node = Client.get_public_node
|
||||||
|
sign_identity = Client.sign_identity
|
||||||
|
Client.state = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_device():
|
||||||
|
"""Returns first WebUSB or HID transport."""
|
||||||
|
for d in WebUsbTransport.enumerate():
|
||||||
|
return WebUsbTransport(d)
|
||||||
|
|
||||||
|
for d in HidTransport.enumerate():
|
||||||
|
return HidTransport(d)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import struct
|
|||||||
from ledgerblue import comm # pylint: disable=import-error
|
from ledgerblue import comm # pylint: disable=import-error
|
||||||
|
|
||||||
from . import interface
|
from . import interface
|
||||||
|
from .. import formats
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ def _convert_public_key(ecdsa_curve_name, result):
|
|||||||
class LedgerNanoS(interface.Device):
|
class LedgerNanoS(interface.Device):
|
||||||
"""Connection to Ledger Nano S device."""
|
"""Connection to Ledger Nano S device."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def package_name(cls):
|
||||||
|
"""Python package name (at PyPI)."""
|
||||||
|
return 'ledger-agent'
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
"""Enumerate and connect to the first USB HID interface."""
|
"""Enumerate and connect to the first USB HID interface."""
|
||||||
try:
|
try:
|
||||||
@@ -56,8 +62,12 @@ class LedgerNanoS(interface.Device):
|
|||||||
apdu = binascii.unhexlify(apdu)
|
apdu = binascii.unhexlify(apdu)
|
||||||
apdu += bytearray([len(path) + 1, len(path) // 4])
|
apdu += bytearray([len(path) + 1, len(path) // 4])
|
||||||
apdu += path
|
apdu += path
|
||||||
result = bytearray(self.conn.exchange(bytes(apdu)))[1:]
|
log.debug('apdu: %r', apdu)
|
||||||
return _convert_public_key(curve_name, result)
|
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||||
|
log.debug('result: %r', result)
|
||||||
|
return formats.decompress_pubkey(
|
||||||
|
pubkey=_convert_public_key(curve_name, result[1:]),
|
||||||
|
curve_name=identity.curve_name)
|
||||||
|
|
||||||
def sign(self, identity, blob):
|
def sign(self, identity, blob):
|
||||||
"""Sign given blob and return the signature (as bytes)."""
|
"""Sign given blob and return the signature (as bytes)."""
|
||||||
@@ -77,7 +87,9 @@ class LedgerNanoS(interface.Device):
|
|||||||
apdu += bytearray([len(blob) + len(path) + 1])
|
apdu += bytearray([len(blob) + len(path) + 1])
|
||||||
apdu += bytearray([len(path) // 4]) + path
|
apdu += bytearray([len(path) // 4]) + path
|
||||||
apdu += blob
|
apdu += blob
|
||||||
|
log.debug('apdu: %r', apdu)
|
||||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||||
|
log.debug('result: %r', result)
|
||||||
if identity.curve_name == 'nist256p1':
|
if identity.curve_name == 'nist256p1':
|
||||||
offset = 3
|
offset = 3
|
||||||
length = result[offset]
|
length = result[offset]
|
||||||
@@ -106,6 +118,8 @@ class LedgerNanoS(interface.Device):
|
|||||||
apdu += bytearray([len(pubkey) + len(path) + 1])
|
apdu += bytearray([len(pubkey) + len(path) + 1])
|
||||||
apdu += bytearray([len(path) // 4]) + path
|
apdu += bytearray([len(path) // 4]) + path
|
||||||
apdu += pubkey
|
apdu += pubkey
|
||||||
|
log.debug('apdu: %r', apdu)
|
||||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||||
|
log.debug('result: %r', result)
|
||||||
assert result[0] == 0x04
|
assert result[0] == 0x04
|
||||||
return bytes(result)
|
return bytes(result)
|
||||||
|
|||||||
376
libagent/device/onlykey.py
Normal file
376
libagent/device/onlykey.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
# pylint: disable=too-many-locals,too-many-statements,too-many-branches
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
"""OnlyKey-related code (see https://www.onlykey.io/)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import codecs
|
||||||
|
import time
|
||||||
|
import ecdsa
|
||||||
|
import nacl.signing
|
||||||
|
import unidecode
|
||||||
|
|
||||||
|
from . import interface
|
||||||
|
|
||||||
|
# import pgpy
|
||||||
|
# from pgpy import PGPKey
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyKey(interface.Device):
|
||||||
|
"""Connection to OnlyKey device."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def package_name(cls):
|
||||||
|
"""Python package name (at PyPI)."""
|
||||||
|
return 'onlykey-agent'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _defs(self):
|
||||||
|
from . import onlykey_defs
|
||||||
|
return onlykey_defs
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Enumerate and connect to the first USB HID interface."""
|
||||||
|
try:
|
||||||
|
self.device_name = 'OnlyKey'
|
||||||
|
self.ok = self._defs.OnlyKey()
|
||||||
|
self.ok.set_time(time.time())
|
||||||
|
self.okversion = self.ok.read_string(timeout_ms=500)
|
||||||
|
self.okversion = self.okversion[8:]
|
||||||
|
self.skeyslot = 132
|
||||||
|
self.dkeyslot = 132
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.NotFoundError('{} not connected: "{}"') from e
|
||||||
|
|
||||||
|
def set_skey(self, skey):
|
||||||
|
"""Set signing key to use."""
|
||||||
|
self.skeyslot = skey
|
||||||
|
log.debug('Setting skey slot = %s', skey)
|
||||||
|
|
||||||
|
def set_dkey(self, dkey):
|
||||||
|
"""Set decryption key to use."""
|
||||||
|
self.dkeyslot = dkey
|
||||||
|
log.debug('Setting dkey slot = %s', dkey)
|
||||||
|
|
||||||
|
def import_pub(self, pubkey):
|
||||||
|
"""Import PGP public key."""
|
||||||
|
self.import_pubkey = pubkey
|
||||||
|
log.debug('Public key to import = %s', pubkey)
|
||||||
|
# self.import_pubkey_obj, _ = pgpy.PGPKey.from_blob(pubkey)
|
||||||
|
# self.import_pubkey_bytes = bytes(self.import_pubkey_obj)
|
||||||
|
|
||||||
|
def get_sk_dk(self):
|
||||||
|
"""Get default signing key and decryption key slots."""
|
||||||
|
self.set_skey(132)
|
||||||
|
self.set_dkey(132)
|
||||||
|
|
||||||
|
def sig_hash(self, sighash):
|
||||||
|
"""Set signature hashing algorithm to use."""
|
||||||
|
if sighash in (b'rsa-sha2-512', b'rsa-sha2-256'):
|
||||||
|
self.sighash = sighash
|
||||||
|
log.info('Setting RSA signature Hash Type =%s', sighash)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close connection."""
|
||||||
|
log.info('disconnected from %s', self.device_name)
|
||||||
|
self.ok.close()
|
||||||
|
|
||||||
|
def pubkey(self, identity, ecdh=False):
|
||||||
|
"""Return public key."""
|
||||||
|
curve_name = identity.get_curve_name(ecdh=ecdh)
|
||||||
|
if identity.identity_dict['proto'] != 'ssh' and hasattr('self', 'skeyslot') is False:
|
||||||
|
self.get_sk_dk()
|
||||||
|
if identity.identity_dict['proto'] != 'ssh' and self.dkeyslot < 132 and ecdh is True:
|
||||||
|
this_slot_id = self.dkeyslot
|
||||||
|
log.info('Key Slot =%s', this_slot_id)
|
||||||
|
elif self.skeyslot < 132 and ecdh is False:
|
||||||
|
this_slot_id = self.skeyslot
|
||||||
|
log.info('Key Slot =%s', this_slot_id)
|
||||||
|
else:
|
||||||
|
this_slot_id = 132
|
||||||
|
|
||||||
|
log.info('Requesting public key from key slot =%s', this_slot_id)
|
||||||
|
|
||||||
|
log.debug('"%s" getting public key (%s) from %s',
|
||||||
|
identity.to_string(), curve_name, self)
|
||||||
|
|
||||||
|
# Calculate hash for key derivation input data
|
||||||
|
if identity.identity_dict['proto'] == 'ssh':
|
||||||
|
if identity.identity_dict.get('user'):
|
||||||
|
id_parts = unidecode.unidecode(identity.identity_dict['user'] + '@' +
|
||||||
|
identity.identity_dict['host']).encode('ascii')
|
||||||
|
else:
|
||||||
|
id_parts = unidecode.unidecode(identity.identity_dict['host']).encode('ascii')
|
||||||
|
else:
|
||||||
|
id_parts = identity.to_bytes()
|
||||||
|
log.info('Identity to hash =%s', id_parts)
|
||||||
|
h1 = hashlib.sha256()
|
||||||
|
h1.update(id_parts)
|
||||||
|
data = h1.hexdigest()
|
||||||
|
log.info('Identity hash =%s', data)
|
||||||
|
|
||||||
|
if this_slot_id > 100:
|
||||||
|
if curve_name == 'curve25519':
|
||||||
|
data = '04' + data
|
||||||
|
elif curve_name == 'secp256k1':
|
||||||
|
# Not currently supported by agent, for future use
|
||||||
|
data = '03' + data
|
||||||
|
elif curve_name == 'nist256p1':
|
||||||
|
data = '02' + data
|
||||||
|
elif curve_name == 'ed25519':
|
||||||
|
data = '01' + data
|
||||||
|
else:
|
||||||
|
data = '00' + data
|
||||||
|
|
||||||
|
self.ok.send_message(msg=self._defs.Message.OKGETPUBKEY, slot_id=this_slot_id, payload=data)
|
||||||
|
log.info('curve name= %s', repr(curve_name))
|
||||||
|
t_end = time.time() + 1.5
|
||||||
|
if curve_name != 'rsa':
|
||||||
|
while time.time() < t_end:
|
||||||
|
try:
|
||||||
|
ok_pubkey = self.ok.read_bytes(timeout_ms=100)
|
||||||
|
if len(ok_pubkey) == 64 and len(set(ok_pubkey[0:63])) != 1:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.DeviceError(e)
|
||||||
|
|
||||||
|
log.info('received= %s', repr(ok_pubkey))
|
||||||
|
if len(set(ok_pubkey[34:63])) == 1:
|
||||||
|
if curve_name in ('nist256p1', 'secp256k1'):
|
||||||
|
raise interface.DeviceError("Public key curve does not match requested type")
|
||||||
|
ok_pubkey = bytearray(ok_pubkey[0:32])
|
||||||
|
log.info('Received Public Key generated by OnlyKey= %s', repr(ok_pubkey.hex()))
|
||||||
|
vk = nacl.signing.VerifyKey(bytes(ok_pubkey),
|
||||||
|
encoder=nacl.encoding.RawEncoder)
|
||||||
|
log.info('vk= %s', repr(vk))
|
||||||
|
# time.sleep(3)
|
||||||
|
return vk
|
||||||
|
elif len(ok_pubkey) == 64:
|
||||||
|
ok_pubkey = bytearray(ok_pubkey[0:64])
|
||||||
|
if curve_name in ('ed25519', 'curve25519'):
|
||||||
|
raise interface.DeviceError("Public key curve does not match requested type")
|
||||||
|
log.info('Received Public Key generated by OnlyKey= %s', repr(ok_pubkey))
|
||||||
|
if identity.curve_name == 'nist256p1':
|
||||||
|
vk = ecdsa.VerifyingKey.from_string(ok_pubkey, curve=ecdsa.NIST256p)
|
||||||
|
else:
|
||||||
|
vk = ecdsa.VerifyingKey.from_string(ok_pubkey, curve=ecdsa.SECP256k1)
|
||||||
|
return vk
|
||||||
|
else:
|
||||||
|
ok_pubkey = []
|
||||||
|
while time.time() < t_end:
|
||||||
|
try:
|
||||||
|
ok_pub_part = self.ok.read_bytes(timeout_ms=100)
|
||||||
|
if len(ok_pub_part) == 64 and len(set(ok_pub_part[0:63])) != 1:
|
||||||
|
log.info('received part= %s', repr(ok_pub_part))
|
||||||
|
ok_pubkey += ok_pub_part
|
||||||
|
# Todo know RSA type to know how many packets
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.DeviceError(e)
|
||||||
|
|
||||||
|
log.info('received= %s', repr(ok_pubkey))
|
||||||
|
if len(ok_pubkey) == 256:
|
||||||
|
# https://security.stackexchange.com/questions/42268/how-do-i-get-the-rsa-bit-length-with-the-pubkey-and-openssl
|
||||||
|
ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \
|
||||||
|
b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \
|
||||||
|
b'\x00\x00\x01\x01' + b'\x00' + bytes(ok_pubkey)
|
||||||
|
# ok_pubkey = b'\x00\x00\x00\x07' + b'\x72\x73\x61\x2d\x73\x68\x61\x32\x2d\x32\x35\x
|
||||||
|
# 36' + b'\x00\x00\x00\x03' + b'\x01\x00\x01' + b'\x00\x00\x01\x01' + b'\x00' + byte
|
||||||
|
# s(ok_pubkey)
|
||||||
|
elif len(ok_pubkey) == 512:
|
||||||
|
ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \
|
||||||
|
b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \
|
||||||
|
b'\x00\x00\x02\x01' + b'\x00' + bytes(ok_pubkey)
|
||||||
|
else:
|
||||||
|
raise interface.DeviceError("Error response length is not a valid public key")
|
||||||
|
log.info('pubkey len = %s', len(ok_pubkey))
|
||||||
|
return ok_pubkey
|
||||||
|
|
||||||
|
def sign(self, identity, blob):
|
||||||
|
"""Sign given blob and return the signature (as bytes)."""
|
||||||
|
curve_name = identity.get_curve_name(ecdh=False)
|
||||||
|
log.debug('"%s" signing %r (%s) on %s',
|
||||||
|
identity.to_string(), blob, curve_name, self)
|
||||||
|
if identity.identity_dict['proto'] != 'ssh' and hasattr('self', 'skeyslot') is False:
|
||||||
|
self.get_sk_dk()
|
||||||
|
# Calculate hash for SSH signing
|
||||||
|
if curve_name == 'rsa':
|
||||||
|
if self.sighash == b'rsa-sha2-512':
|
||||||
|
log.info('rsa-sha2-512')
|
||||||
|
h1 = hashlib.sha512()
|
||||||
|
h1.update(blob)
|
||||||
|
data = h1.hexdigest()
|
||||||
|
data = codecs.decode(data, 'hex_codec')
|
||||||
|
elif self.sighash == b'rsa-sha2-256':
|
||||||
|
log.info('rsa-sha2-256')
|
||||||
|
h1 = hashlib.sha256()
|
||||||
|
h1.update(blob)
|
||||||
|
data = h1.hexdigest()
|
||||||
|
data = codecs.decode(data, 'hex_codec')
|
||||||
|
else:
|
||||||
|
# Calculate hash for key derivation input data
|
||||||
|
h1 = hashlib.sha256()
|
||||||
|
if identity.identity_dict['proto'] == 'ssh':
|
||||||
|
if identity.identity_dict.get('user'):
|
||||||
|
id_parts = unidecode.unidecode(identity.identity_dict['user'] + '@' +
|
||||||
|
identity.identity_dict['host']).encode('ascii')
|
||||||
|
else:
|
||||||
|
id_parts = unidecode.unidecode(identity.identity_dict['host']).encode('ascii')
|
||||||
|
else:
|
||||||
|
id_parts = identity.to_bytes()
|
||||||
|
h1.update(id_parts)
|
||||||
|
data = h1.hexdigest()
|
||||||
|
data = codecs.decode(data, 'hex_codec')
|
||||||
|
log.info('Identity to hash =%s', id_parts)
|
||||||
|
log.info('Identity hash =%s', data)
|
||||||
|
# Determine type of key to derive on OnlyKey for signature
|
||||||
|
# Slot 132 used for derived key, slots 101-116 used for stored ecc keys
|
||||||
|
# slots 1-4 used for stored RSA keys
|
||||||
|
if self.skeyslot == 132:
|
||||||
|
if curve_name == 'ed25519':
|
||||||
|
this_slot_id = 201
|
||||||
|
log.info('Key type ed25519')
|
||||||
|
elif curve_name == 'nist256p1':
|
||||||
|
this_slot_id = 202
|
||||||
|
log.info('Key type nistp256')
|
||||||
|
else:
|
||||||
|
this_slot_id = 203
|
||||||
|
log.info('Key type secp256k1')
|
||||||
|
# Send data and identity hash
|
||||||
|
raw_message = blob + data
|
||||||
|
else:
|
||||||
|
this_slot_id = self.skeyslot
|
||||||
|
# Send just data to sign
|
||||||
|
raw_message = blob
|
||||||
|
h2 = hashlib.sha256()
|
||||||
|
h2.update(raw_message)
|
||||||
|
d = h2.digest()
|
||||||
|
assert len(d) == 32
|
||||||
|
b1, b2, b3 = get_button(self, d[0]), get_button(self, d[15]), get_button(self, d[31])
|
||||||
|
log.info('Key Slot =%s', this_slot_id)
|
||||||
|
print('Enter the 3 digit challenge code on OnlyKey to authorize '+identity.to_string())
|
||||||
|
print('{} {} {}'.format(b1, b2, b3))
|
||||||
|
t_end = time.time() + 22
|
||||||
|
if curve_name != 'rsa':
|
||||||
|
self.ok.send_large_message2(msg=self._defs.Message.OKSIGN, payload=raw_message,
|
||||||
|
slot_id=this_slot_id)
|
||||||
|
while time.time() < t_end:
|
||||||
|
try:
|
||||||
|
result = self.ok.read_bytes(timeout_ms=100)
|
||||||
|
if len(result) == 64 and len(set(result[0:63])) != 1:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.DeviceError(e)
|
||||||
|
|
||||||
|
if len(result) >= 60:
|
||||||
|
log.info('received= %s', repr(result))
|
||||||
|
while len(result) < 64:
|
||||||
|
result.append(0)
|
||||||
|
log.info('disconnected from %s', self.device_name)
|
||||||
|
self.ok.close()
|
||||||
|
return bytes(result)
|
||||||
|
else:
|
||||||
|
self.ok.send_large_message2(msg=self._defs.Message.OKSIGN, payload=data,
|
||||||
|
slot_id=this_slot_id)
|
||||||
|
result = []
|
||||||
|
while time.time() < t_end:
|
||||||
|
try:
|
||||||
|
sig_part = self.ok.read_bytes(timeout_ms=100)
|
||||||
|
if len(sig_part) == 64 and len(set(sig_part[0:63])) != 1:
|
||||||
|
log.info('received part= %s', repr(sig_part))
|
||||||
|
result += sig_part
|
||||||
|
t_end = time.time() + 1
|
||||||
|
# Todo know RSA type to know how many packets
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.DeviceError(e)
|
||||||
|
|
||||||
|
log.info('received= %s', repr(result))
|
||||||
|
return bytes(result)
|
||||||
|
raise Exception('failed to sign challenge')
|
||||||
|
|
||||||
|
def ecdh(self, identity, pubkey):
|
||||||
|
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||||
|
curve_name = identity.get_curve_name(ecdh=True)
|
||||||
|
log.debug('"%s" shared session key (%s) for %r from %s',
|
||||||
|
identity.to_string(), curve_name, pubkey, self)
|
||||||
|
# Calculate hash for key derivation input data
|
||||||
|
h1 = hashlib.sha256()
|
||||||
|
if identity.identity_dict['proto'] == 'ssh':
|
||||||
|
if identity.identity_dict.get('user'):
|
||||||
|
id_parts = unidecode.unidecode(identity.identity_dict['user'] + '@' +
|
||||||
|
identity.identity_dict['host']).encode('ascii')
|
||||||
|
else:
|
||||||
|
id_parts = unidecode.unidecode(identity.identity_dict['host']).encode('ascii')
|
||||||
|
else:
|
||||||
|
id_parts = identity.to_bytes()
|
||||||
|
h1.update(id_parts)
|
||||||
|
log.info('Identity to hash =%s', id_parts)
|
||||||
|
data = h1.hexdigest()
|
||||||
|
log.info('Identity hash =%s', data)
|
||||||
|
data = codecs.decode(data, 'hex_codec')
|
||||||
|
# Determine type of key to derive on OnlyKey for ecdh
|
||||||
|
# Slot 132 used for derived key, slots 101-116 used for stored ecc keys,
|
||||||
|
# slots 1-4 used for stored RSA keys
|
||||||
|
if self.dkeyslot == 132:
|
||||||
|
if curve_name == 'curve25519':
|
||||||
|
this_slot_id = 204
|
||||||
|
log.info('Key type curve25519')
|
||||||
|
elif curve_name == 'nist256p1':
|
||||||
|
this_slot_id = 202
|
||||||
|
log.info('Key type nistp256')
|
||||||
|
else:
|
||||||
|
this_slot_id = 203
|
||||||
|
log.info('Key type secp256k1')
|
||||||
|
raw_message = pubkey + data
|
||||||
|
else:
|
||||||
|
this_slot_id = self.dkeyslot
|
||||||
|
raw_message = pubkey
|
||||||
|
log.info('Key Slot =%s', this_slot_id)
|
||||||
|
log.info('data hash =%s', data)
|
||||||
|
h2 = hashlib.sha256()
|
||||||
|
h2.update(raw_message)
|
||||||
|
d = h2.digest()
|
||||||
|
assert len(d) == 32
|
||||||
|
b1, b2, b3 = get_button(self, d[0]), get_button(self, d[15]), get_button(self, d[31])
|
||||||
|
self.ok.send_large_message2(msg=self._defs.Message.OKDECRYPT, payload=raw_message,
|
||||||
|
slot_id=this_slot_id)
|
||||||
|
print('Enter the 3 digit challenge code on OnlyKey to authorize ' + identity.to_string())
|
||||||
|
print('{} {} {}'.format(b1, b2, b3))
|
||||||
|
t_end = time.time() + 22
|
||||||
|
if curve_name != 'rsa':
|
||||||
|
while time.time() < t_end:
|
||||||
|
try:
|
||||||
|
result = self.ok.read_bytes(timeout_ms=100)
|
||||||
|
if len(result) == 64 and len(set(result[0:63])) != 1:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.DeviceError(e)
|
||||||
|
if len(set(result[34:63])) == 1:
|
||||||
|
result = b'\x04' + bytes(result[0:32])
|
||||||
|
else:
|
||||||
|
result = []
|
||||||
|
while time.time() < t_end:
|
||||||
|
try:
|
||||||
|
dec_part = self.ok.read_bytes(timeout_ms=100)
|
||||||
|
if len(dec_part) == 64 and len(set(dec_part[0:63])) != 1:
|
||||||
|
log.info('received part= %s', repr(dec_part))
|
||||||
|
result += dec_part
|
||||||
|
t_end = time.time() + 1
|
||||||
|
# Todo know RSA type to know how many packets
|
||||||
|
except Exception as e:
|
||||||
|
raise interface.DeviceError(e)
|
||||||
|
|
||||||
|
log.info('received= %s', repr(result))
|
||||||
|
log.info('disconnected from %s', self.device_name)
|
||||||
|
self.ok.close()
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def get_button(self, byte):
|
||||||
|
"""Return button number."""
|
||||||
|
if str(self.okversion) == 'v0.2-beta.8c':
|
||||||
|
return byte % 5 + 1
|
||||||
|
else:
|
||||||
|
return byte % 6 + 1
|
||||||
5
libagent/device/onlykey_defs.py
Normal file
5
libagent/device/onlykey_defs.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""OnlyKey-related definitions."""
|
||||||
|
|
||||||
|
# pylint: disable=unused-import,import-error,no-name-in-module
|
||||||
|
|
||||||
|
from onlykey import OnlyKey, Message
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
import semver
|
import semver
|
||||||
|
|
||||||
from . import interface
|
from . import interface
|
||||||
|
from .. import formats
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -14,62 +14,82 @@ log = logging.getLogger(__name__)
|
|||||||
class Trezor(interface.Device):
|
class Trezor(interface.Device):
|
||||||
"""Connection to TREZOR device."""
|
"""Connection to TREZOR device."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def package_name(cls):
|
||||||
|
"""Python package name (at PyPI)."""
|
||||||
|
return 'trezor-agent'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _defs(self):
|
def _defs(self):
|
||||||
from . import trezor_defs
|
from . import trezor_defs
|
||||||
# Allow using TREZOR bridge transport (instead of the HID default)
|
|
||||||
trezor_defs.Transport = {
|
|
||||||
'bridge': trezor_defs.BridgeTransport,
|
|
||||||
}.get(os.environ.get('TREZOR_TRANSPORT'), trezor_defs.HidTransport)
|
|
||||||
return trezor_defs
|
return trezor_defs
|
||||||
|
|
||||||
required_version = '>=1.4.0'
|
required_version = '>=1.4.0'
|
||||||
passphrase = os.environ.get('TREZOR_PASSPHRASE', '')
|
|
||||||
|
ui = None # can be overridden by device's users
|
||||||
|
cached_session_id = None
|
||||||
|
|
||||||
|
def _verify_version(self, connection):
|
||||||
|
f = connection.features
|
||||||
|
log.debug('connected to %s %s', self, f.device_id)
|
||||||
|
log.debug('label : %s', f.label)
|
||||||
|
log.debug('vendor : %s', f.vendor)
|
||||||
|
current_version = '{}.{}.{}'.format(f.major_version,
|
||||||
|
f.minor_version,
|
||||||
|
f.patch_version)
|
||||||
|
log.debug('version : %s', current_version)
|
||||||
|
log.debug('revision : %s', binascii.hexlify(f.revision))
|
||||||
|
if not semver.match(current_version, self.required_version):
|
||||||
|
fmt = ('Please upgrade your {} firmware to {} version'
|
||||||
|
' (current: {})')
|
||||||
|
raise ValueError(fmt.format(self, self.required_version,
|
||||||
|
current_version))
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
"""Enumerate and connect to the first USB HID interface."""
|
"""Enumerate and connect to the first available interface."""
|
||||||
def passphrase_handler(_):
|
transport = self._defs.find_device()
|
||||||
log.debug('using %s passphrase for %s',
|
if not transport:
|
||||||
'non-empty' if self.passphrase else 'empty', self)
|
raise interface.NotFoundError('{} not connected'.format(self))
|
||||||
return self._defs.PassphraseAck(passphrase=self.passphrase)
|
|
||||||
|
|
||||||
for d in self._defs.Transport.enumerate():
|
log.debug('using transport: %s', transport)
|
||||||
log.debug('endpoint: %s', d)
|
for _ in range(5): # Retry a few times in case of PIN failures
|
||||||
transport = self._defs.Transport(d)
|
connection = self._defs.Client(transport=transport,
|
||||||
connection = self._defs.Client(transport)
|
ui=self.ui,
|
||||||
connection.callback_PassphraseRequest = passphrase_handler
|
session_id=self.__class__.cached_session_id)
|
||||||
f = connection.features
|
self._verify_version(connection)
|
||||||
log.debug('connected to %s %s', self, f.device_id)
|
|
||||||
log.debug('label : %s', f.label)
|
try:
|
||||||
log.debug('vendor : %s', f.vendor)
|
# unlock PIN and passphrase
|
||||||
current_version = '{}.{}.{}'.format(f.major_version,
|
self._defs.get_address(connection,
|
||||||
f.minor_version,
|
"Testnet",
|
||||||
f.patch_version)
|
self._defs.PASSPHRASE_TEST_PATH)
|
||||||
log.debug('version : %s', current_version)
|
return connection
|
||||||
log.debug('revision : %s', binascii.hexlify(f.revision))
|
except (self._defs.PinException, ValueError) as e:
|
||||||
if not semver.match(current_version, self.required_version):
|
log.error('Invalid PIN: %s, retrying...', e)
|
||||||
fmt = ('Please upgrade your {} firmware to {} version'
|
continue
|
||||||
' (current: {})')
|
except Exception as e:
|
||||||
raise ValueError(fmt.format(self, self.required_version,
|
log.exception('ping failed: %s', e)
|
||||||
current_version))
|
connection.close() # so the next HID open() will succeed
|
||||||
connection.ping(msg='', pin_protection=True) # unlock PIN
|
raise
|
||||||
return connection
|
|
||||||
raise interface.NotFoundError('{} not connected'.format(self))
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close connection."""
|
"""Close connection."""
|
||||||
self.conn.close()
|
self.__class__.cached_session_id = self.conn.session_id
|
||||||
|
super().close()
|
||||||
|
|
||||||
def pubkey(self, identity, ecdh=False):
|
def pubkey(self, identity, ecdh=False):
|
||||||
"""Return public key."""
|
"""Return public key."""
|
||||||
curve_name = identity.get_curve_name(ecdh=ecdh)
|
curve_name = identity.get_curve_name(ecdh=ecdh)
|
||||||
log.debug('"%s" getting public key (%s) from %s',
|
log.debug('"%s" getting public key (%s) from %s',
|
||||||
identity, curve_name, self)
|
identity.to_string(), curve_name, self)
|
||||||
addr = identity.get_bip32_address(ecdh=ecdh)
|
addr = identity.get_bip32_address(ecdh=ecdh)
|
||||||
result = self.conn.get_public_node(n=addr,
|
result = self._defs.get_public_node(
|
||||||
ecdsa_curve_name=curve_name)
|
self.conn,
|
||||||
|
n=addr,
|
||||||
|
ecdsa_curve_name=curve_name)
|
||||||
log.debug('result: %s', result)
|
log.debug('result: %s', result)
|
||||||
return result.node.public_key
|
pubkey = bytes(result.node.public_key)
|
||||||
|
return formats.decompress_pubkey(pubkey=pubkey, curve_name=identity.curve_name)
|
||||||
|
|
||||||
def _identity_proto(self, identity):
|
def _identity_proto(self, identity):
|
||||||
result = self._defs.IdentityType()
|
result = self._defs.IdentityType()
|
||||||
@@ -81,9 +101,10 @@ class Trezor(interface.Device):
|
|||||||
"""Sign given blob and return the signature (as bytes)."""
|
"""Sign given blob and return the signature (as bytes)."""
|
||||||
curve_name = identity.get_curve_name(ecdh=False)
|
curve_name = identity.get_curve_name(ecdh=False)
|
||||||
log.debug('"%s" signing %r (%s) on %s',
|
log.debug('"%s" signing %r (%s) on %s',
|
||||||
identity, blob, curve_name, self)
|
identity.to_string(), blob, curve_name, self)
|
||||||
try:
|
try:
|
||||||
result = self.conn.sign_identity(
|
result = self._defs.sign_identity(
|
||||||
|
self.conn,
|
||||||
identity=self._identity_proto(identity),
|
identity=self._identity_proto(identity),
|
||||||
challenge_hidden=blob,
|
challenge_hidden=blob,
|
||||||
challenge_visual='',
|
challenge_visual='',
|
||||||
@@ -91,8 +112,8 @@ class Trezor(interface.Device):
|
|||||||
log.debug('result: %s', result)
|
log.debug('result: %s', result)
|
||||||
assert len(result.signature) == 65
|
assert len(result.signature) == 65
|
||||||
assert result.signature[:1] == b'\x00'
|
assert result.signature[:1] == b'\x00'
|
||||||
return result.signature[1:]
|
return bytes(result.signature[1:])
|
||||||
except self._defs.Error as e:
|
except self._defs.TrezorFailure as e:
|
||||||
msg = '{} error: {}'.format(self, e)
|
msg = '{} error: {}'.format(self, e)
|
||||||
log.debug(msg, exc_info=True)
|
log.debug(msg, exc_info=True)
|
||||||
raise interface.DeviceError(msg)
|
raise interface.DeviceError(msg)
|
||||||
@@ -101,17 +122,18 @@ class Trezor(interface.Device):
|
|||||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||||
curve_name = identity.get_curve_name(ecdh=True)
|
curve_name = identity.get_curve_name(ecdh=True)
|
||||||
log.debug('"%s" shared session key (%s) for %r from %s',
|
log.debug('"%s" shared session key (%s) for %r from %s',
|
||||||
identity, curve_name, pubkey, self)
|
identity.to_string(), curve_name, pubkey, self)
|
||||||
try:
|
try:
|
||||||
result = self.conn.get_ecdh_session_key(
|
result = self._defs.get_ecdh_session_key(
|
||||||
|
self.conn,
|
||||||
identity=self._identity_proto(identity),
|
identity=self._identity_proto(identity),
|
||||||
peer_public_key=pubkey,
|
peer_public_key=pubkey,
|
||||||
ecdsa_curve_name=curve_name)
|
ecdsa_curve_name=curve_name)
|
||||||
log.debug('result: %s', result)
|
log.debug('result: %s', result)
|
||||||
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
||||||
assert result.session_key[:1] == b'\x04'
|
assert result.session_key[:1] == b'\x04'
|
||||||
return result.session_key
|
return bytes(result.session_key)
|
||||||
except self._defs.Error as e:
|
except self._defs.TrezorFailure as e:
|
||||||
msg = '{} error: {}'.format(self, e)
|
msg = '{} error: {}'.format(self, e)
|
||||||
log.debug(msg, exc_info=True)
|
log.debug(msg, exc_info=True)
|
||||||
raise interface.DeviceError(msg)
|
raise interface.DeviceError(msg)
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
"""TREZOR-related definitions."""
|
"""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
|
||||||
|
|
||||||
from trezorlib.client import CallException as Error
|
import mnemonic
|
||||||
from trezorlib.client import TrezorClient as Client
|
import semver
|
||||||
from trezorlib.messages_pb2 import PassphraseAck
|
import trezorlib
|
||||||
from trezorlib.transport_bridge import BridgeTransport
|
|
||||||
from trezorlib.transport_hid import HidTransport
|
from trezorlib.client import TrezorClient as Client, PASSPHRASE_TEST_PATH
|
||||||
from trezorlib.types_pb2 import IdentityType
|
from trezorlib.exceptions import TrezorFailure, PinException
|
||||||
|
from trezorlib.transport import get_transport
|
||||||
|
from trezorlib.messages import IdentityType
|
||||||
|
|
||||||
|
from trezorlib.btc import get_address, get_public_node
|
||||||
|
from trezorlib.misc import sign_identity, get_ecdh_session_key
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
153
libagent/device/ui.py
Normal file
153
libagent/device/ui.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""UIs for PIN/passphrase entry."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
from trezorlib.client import PASSPHRASE_ON_DEVICE
|
||||||
|
except ImportError:
|
||||||
|
PASSPHRASE_ON_DEVICE = object()
|
||||||
|
|
||||||
|
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 = util.ExpiringCache(
|
||||||
|
seconds=float(config.get('cache_expiry_seconds', 'inf')))
|
||||||
|
|
||||||
|
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:', available_on_device=False):
|
||||||
|
"""Ask the user for passphrase."""
|
||||||
|
passphrase = None
|
||||||
|
if self.cached_passphrase_ack:
|
||||||
|
passphrase = self.cached_passphrase_ack.get()
|
||||||
|
if passphrase is None:
|
||||||
|
env_passphrase = os.environ.get("TREZOR_PASSPHRASE")
|
||||||
|
if env_passphrase is not None:
|
||||||
|
passphrase = env_passphrase
|
||||||
|
elif available_on_device:
|
||||||
|
passphrase = PASSPHRASE_ON_DEVICE
|
||||||
|
else:
|
||||||
|
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()
|
||||||
@@ -5,7 +5,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import ecdsa
|
import ecdsa
|
||||||
import ed25519
|
import nacl.signing
|
||||||
|
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
@@ -88,8 +88,10 @@ def parse_pubkey(blob):
|
|||||||
|
|
||||||
def ed25519_verify(sig, msg):
|
def ed25519_verify(sig, msg):
|
||||||
assert len(sig) == 64
|
assert len(sig) == 64
|
||||||
vk = ed25519.VerifyingKey(pubkey)
|
vk = nacl.signing.VerifyKey(bytes(pubkey),
|
||||||
vk.verify(sig, msg)
|
encoder=nacl.encoding.RawEncoder)
|
||||||
|
vk.verify(msg, sig)
|
||||||
|
log.debug('verify signature')
|
||||||
return sig
|
return sig
|
||||||
|
|
||||||
result.update(curve=CURVE_ED25519, verifier=ed25519_verify)
|
result.update(curve=CURVE_ED25519, verifier=ed25519_verify)
|
||||||
@@ -101,7 +103,9 @@ def _decompress_ed25519(pubkey):
|
|||||||
"""Load public key from the serialized blob (stripping the prefix byte)."""
|
"""Load public key from the serialized blob (stripping the prefix byte)."""
|
||||||
if pubkey[:1] == b'\x00':
|
if pubkey[:1] == b'\x00':
|
||||||
# set by Trezor fsm_msgSignIdentity() and fsm_msgGetPublicKey()
|
# set by Trezor fsm_msgSignIdentity() and fsm_msgGetPublicKey()
|
||||||
return ed25519.VerifyingKey(pubkey[1:])
|
return nacl.signing.VerifyKey(pubkey[1:], encoder=nacl.encoding.RawEncoder)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _decompress_nist256(pubkey):
|
def _decompress_nist256(pubkey):
|
||||||
@@ -126,6 +130,8 @@ def _decompress_nist256(pubkey):
|
|||||||
point = ecdsa.ellipticcurve.Point(curve.curve, x, y)
|
point = ecdsa.ellipticcurve.Point(curve.curve, x, y)
|
||||||
return ecdsa.VerifyingKey.from_public_point(point, curve=curve,
|
return ecdsa.VerifyingKey.from_public_point(point, curve=curve,
|
||||||
hashfunc=hashfunc)
|
hashfunc=hashfunc)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def decompress_pubkey(pubkey, curve_name):
|
def decompress_pubkey(pubkey, curve_name):
|
||||||
@@ -157,8 +163,8 @@ def serialize_verifying_key(vk):
|
|||||||
Currently, NIST256P1 and ED25519 elliptic curves are supported.
|
Currently, NIST256P1 and ED25519 elliptic curves are supported.
|
||||||
Raise TypeError on unsupported key format.
|
Raise TypeError on unsupported key format.
|
||||||
"""
|
"""
|
||||||
if isinstance(vk, ed25519.keys.VerifyingKey):
|
if isinstance(vk, nacl.signing.VerifyKey):
|
||||||
pubkey = vk.to_bytes()
|
pubkey = vk.encode(encoder=nacl.encoding.RawEncoder)
|
||||||
key_type = SSH_ED25519_KEY_TYPE
|
key_type = SSH_ED25519_KEY_TYPE
|
||||||
blob = util.frame(SSH_ED25519_KEY_TYPE) + util.frame(pubkey)
|
blob = util.frame(SSH_ED25519_KEY_TYPE) + util.frame(pubkey)
|
||||||
return key_type, blob
|
return key_type, blob
|
||||||
@@ -184,7 +190,7 @@ def export_public_key(vk, label):
|
|||||||
key_type, blob = serialize_verifying_key(vk)
|
key_type, blob = serialize_verifying_key(vk)
|
||||||
log.debug('fingerprint: %s', fingerprint(blob))
|
log.debug('fingerprint: %s', fingerprint(blob))
|
||||||
b64 = base64.b64encode(blob).decode('ascii')
|
b64 = base64.b64encode(blob).decode('ascii')
|
||||||
return '{} {} {}\n'.format(key_type.decode('ascii'), b64, label)
|
return u'{} {} {}\n'.format(key_type.decode('ascii'), b64, label)
|
||||||
|
|
||||||
|
|
||||||
def import_public_key(line):
|
def import_public_key(line):
|
||||||
@@ -193,7 +199,7 @@ def import_public_key(line):
|
|||||||
file_type, base64blob, name = line.split()
|
file_type, base64blob, name = line.split()
|
||||||
blob = base64.b64decode(base64blob)
|
blob = base64.b64decode(base64blob)
|
||||||
result = parse_pubkey(blob)
|
result = parse_pubkey(blob)
|
||||||
result['name'] = name.encode('ascii')
|
result['name'] = name.encode('utf-8')
|
||||||
assert result['type'] == file_type.encode('ascii')
|
assert result['type'] == file_type.encode('ascii')
|
||||||
log.debug('loaded %s public key: %s', file_type, result['fingerprint'])
|
log.debug('loaded %s public key: %s', file_type, result['fingerprint'])
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ See these links for more details:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import pkg_resources
|
||||||
import semver
|
import semver
|
||||||
|
|
||||||
|
|
||||||
from . import agent, client, encode, keyring, protocol
|
from . import agent, client, encode, keyring, protocol
|
||||||
from .. import device, formats, server, util
|
from .. import device, formats, server, util
|
||||||
|
|
||||||
@@ -28,10 +33,12 @@ def export_public_key(device_type, args):
|
|||||||
log.warning('NOTE: in order to re-generate the exact same GPG key later, '
|
log.warning('NOTE: in order to re-generate the exact same GPG key later, '
|
||||||
'run this command with "--time=%d" commandline flag (to set '
|
'run this command with "--time=%d" commandline flag (to set '
|
||||||
'the timestamp of the GPG key manually).', args.time)
|
'the timestamp of the GPG key manually).', args.time)
|
||||||
c = client.Client(user_id=args.user_id, curve_name=args.ecdsa_curve,
|
c = client.Client(device=device_type())
|
||||||
device_type=device_type)
|
identity = client.create_identity(user_id=args.user_id,
|
||||||
verifying_key = c.pubkey(ecdh=False)
|
curve_name=args.ecdsa_curve)
|
||||||
decryption_key = c.pubkey(ecdh=True)
|
verifying_key = c.pubkey(identity=identity, ecdh=False)
|
||||||
|
decryption_key = c.pubkey(identity=identity, ecdh=True)
|
||||||
|
signer_func = functools.partial(c.sign, identity=identity)
|
||||||
|
|
||||||
if args.subkey: # add as subkey
|
if args.subkey: # add as subkey
|
||||||
log.info('adding %s GPG subkey for "%s" to existing key',
|
log.info('adding %s GPG subkey for "%s" to existing key',
|
||||||
@@ -47,10 +54,10 @@ def export_public_key(device_type, args):
|
|||||||
primary_bytes = keyring.export_public_key(args.user_id)
|
primary_bytes = keyring.export_public_key(args.user_id)
|
||||||
result = encode.create_subkey(primary_bytes=primary_bytes,
|
result = encode.create_subkey(primary_bytes=primary_bytes,
|
||||||
subkey=signing_key,
|
subkey=signing_key,
|
||||||
signer_func=c.sign)
|
signer_func=signer_func)
|
||||||
result = encode.create_subkey(primary_bytes=result,
|
result = encode.create_subkey(primary_bytes=result,
|
||||||
subkey=encryption_key,
|
subkey=encryption_key,
|
||||||
signer_func=c.sign)
|
signer_func=signer_func)
|
||||||
else: # add as primary
|
else: # add as primary
|
||||||
log.info('creating new %s GPG primary key for "%s"',
|
log.info('creating new %s GPG primary key for "%s"',
|
||||||
args.ecdsa_curve, args.user_id)
|
args.ecdsa_curve, args.user_id)
|
||||||
@@ -65,28 +72,131 @@ def export_public_key(device_type, args):
|
|||||||
|
|
||||||
result = encode.create_primary(user_id=args.user_id,
|
result = encode.create_primary(user_id=args.user_id,
|
||||||
pubkey=primary,
|
pubkey=primary,
|
||||||
signer_func=c.sign)
|
signer_func=signer_func)
|
||||||
result = encode.create_subkey(primary_bytes=result,
|
result = encode.create_subkey(primary_bytes=result,
|
||||||
subkey=subkey,
|
subkey=subkey,
|
||||||
signer_func=c.sign)
|
signer_func=signer_func)
|
||||||
|
|
||||||
sys.stdout.write(protocol.armor(result, 'PUBLIC KEY BLOCK'))
|
return protocol.armor(result, 'PUBLIC KEY BLOCK')
|
||||||
|
|
||||||
|
|
||||||
def run_create(device_type, args):
|
def verify_gpg_version():
|
||||||
"""Export public GPG key."""
|
"""Make sure that the installed GnuPG is not too old."""
|
||||||
|
existing_gpg = keyring.gpg_version().decode('ascii')
|
||||||
|
required_gpg = '>=2.1.11'
|
||||||
|
msg = 'Existing GnuPG has version "{}" ({} required)'.format(existing_gpg,
|
||||||
|
required_gpg)
|
||||||
|
if not semver.match(existing_gpg, required_gpg):
|
||||||
|
log.error(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def check_output(args):
|
||||||
|
"""Runs command and returns the output as string."""
|
||||||
|
log.debug('run: %s', args)
|
||||||
|
out = subprocess.check_output(args=args).decode('utf-8')
|
||||||
|
log.debug('out: %r', out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check_call(args, stdin=None, env=None):
|
||||||
|
"""Runs command and verifies its success."""
|
||||||
|
log.debug('run: %s%s', args, ' {}'.format(env) if env else '')
|
||||||
|
subprocess.check_call(args=args, stdin=stdin, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path, data):
|
||||||
|
"""Writes data to specified path."""
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
log.debug('setting %s contents:\n%s', path, data)
|
||||||
|
f.write(data)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def run_init(device_type, args):
|
||||||
|
"""Initialize hardware-based GnuPG identity."""
|
||||||
util.setup_logging(verbosity=args.verbose)
|
util.setup_logging(verbosity=args.verbose)
|
||||||
log.warning('This GPG tool is still in EXPERIMENTAL mode, '
|
log.warning('This GPG tool is still in EXPERIMENTAL mode, '
|
||||||
'so please note that the API and features may '
|
'so please note that the API and features may '
|
||||||
'change without backwards compatibility!')
|
'change without backwards compatibility!')
|
||||||
|
|
||||||
existing_gpg = keyring.gpg_version().decode('ascii')
|
verify_gpg_version()
|
||||||
required_gpg = '>=2.1.11'
|
|
||||||
if semver.match(existing_gpg, required_gpg):
|
# Prepare new GPG home directory for hardware-based identity
|
||||||
export_public_key(device_type, args)
|
device_name = os.path.basename(sys.argv[0]).rsplit('-', 1)[0]
|
||||||
else:
|
log.info('device name: %s', device_name)
|
||||||
log.error('Existing gpg2 has version "%s" (%s required)',
|
homedir = args.homedir
|
||||||
existing_gpg, required_gpg)
|
if not homedir:
|
||||||
|
homedir = os.path.expanduser('~/.gnupg/{}'.format(device_name))
|
||||||
|
|
||||||
|
log.info('GPG home directory: %s', homedir)
|
||||||
|
|
||||||
|
if os.path.exists(homedir):
|
||||||
|
log.error('GPG home directory %s exists, '
|
||||||
|
'remove it manually if required', homedir)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
check_call(['mkdir', '-p', homedir])
|
||||||
|
check_call(['chmod', '700', homedir])
|
||||||
|
|
||||||
|
agent_path = util.which('{}-gpg-agent'.format(device_name))
|
||||||
|
|
||||||
|
# Prepare GPG agent invocation script (to pass the PATH from environment).
|
||||||
|
with open(os.path.join(homedir, 'run-agent.sh'), 'w') as f:
|
||||||
|
f.write(r"""#!/bin/sh
|
||||||
|
export PATH="{0}"
|
||||||
|
{1} \
|
||||||
|
-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])
|
||||||
|
run_agent_script = f.name
|
||||||
|
|
||||||
|
# Prepare GPG configuration file
|
||||||
|
with open(os.path.join(homedir, 'gpg.conf'), 'w') as f:
|
||||||
|
f.write("""# Hardware-based GPG configuration
|
||||||
|
agent-program {0}
|
||||||
|
personal-digest-preferences SHA512
|
||||||
|
default-key \"{1}\"
|
||||||
|
""".format(run_agent_script, args.user_id))
|
||||||
|
|
||||||
|
# Prepare a helper script for setting up the new identity
|
||||||
|
with open(os.path.join(homedir, 'env'), 'w') as f:
|
||||||
|
f.write("""#!/bin/bash
|
||||||
|
set -eu
|
||||||
|
export GNUPGHOME={0}
|
||||||
|
COMMAND=$*
|
||||||
|
if [ -z "${{COMMAND}}" ]
|
||||||
|
then
|
||||||
|
${{SHELL}}
|
||||||
|
else
|
||||||
|
${{COMMAND}}
|
||||||
|
fi
|
||||||
|
""".format(homedir))
|
||||||
|
check_call(['chmod', '700', f.name])
|
||||||
|
|
||||||
|
# Generate new GPG identity and import into GPG keyring
|
||||||
|
pubkey = write_file(os.path.join(homedir, 'pubkey.asc'),
|
||||||
|
export_public_key(device_type, args))
|
||||||
|
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(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(keyring.gpg_command(['--homedir', homedir,
|
||||||
|
'--import-ownertrust', f.name]))
|
||||||
|
|
||||||
|
# Load agent and make sure it responds with the new identity
|
||||||
|
check_call(keyring.gpg_command(['--homedir', homedir,
|
||||||
|
'--list-secret-keys', args.user_id]))
|
||||||
|
|
||||||
|
|
||||||
def run_unlock(device_type, args):
|
def run_unlock(device_type, args):
|
||||||
@@ -96,50 +206,115 @@ def run_unlock(device_type, args):
|
|||||||
log.info('unlocked %s device', d)
|
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):
|
def run_agent(device_type):
|
||||||
"""Run a simple GPG-agent server."""
|
"""Run a simple GPG-agent server."""
|
||||||
parser = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
parser.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
|
p.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
|
||||||
args, _ = parser.parse_known_args()
|
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()
|
||||||
|
|
||||||
assert args.homedir
|
assert args.homedir
|
||||||
config_file = os.path.join(args.homedir, 'gpg-agent.conf')
|
|
||||||
|
|
||||||
lines = (line.strip() for line in open(config_file))
|
log_file = os.path.join(args.homedir, 'gpg-agent.log')
|
||||||
lines = (line for line in lines if line and not line.startswith('#'))
|
util.setup_logging(verbosity=args.verbose, filename=log_file)
|
||||||
config = dict(line.split(' ', 1) for line in lines)
|
|
||||||
|
|
||||||
util.setup_logging(verbosity=int(config['verbosity']),
|
log.debug('sys.argv: %s', sys.argv)
|
||||||
filename=config['log-file'])
|
log.debug('os.environ: %s', os.environ)
|
||||||
sock_path = keyring.get_agent_sock_path()
|
log.debug('pid: %d, parent pid: %d', os.getpid(), os.getppid())
|
||||||
with server.unix_domain_socket_server(sock_path) as sock:
|
try:
|
||||||
for conn in agent.yield_connections(sock):
|
env = {'GNUPGHOME': args.homedir, 'PATH': os.environ['PATH']}
|
||||||
with contextlib.closing(conn):
|
pubkey_bytes = keyring.export_public_keys(env=env)
|
||||||
try:
|
device_type.ui = device.ui.UI(device_type=device_type,
|
||||||
agent.handle_connection(conn=conn, device_type=device_type)
|
config=vars(args))
|
||||||
except StopIteration:
|
handler = agent.Handler(device=device_type(),
|
||||||
log.info('stopping gpg-agent')
|
pubkey_bytes=pubkey_bytes)
|
||||||
return
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
sock_server = _server_from_assuan_fd(os.environ)
|
||||||
log.exception('gpg-agent failed: %s', e)
|
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:
|
||||||
|
handler.handle(conn)
|
||||||
|
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):
|
def main(device_type):
|
||||||
"""Parse command-line arguments."""
|
"""Parse command-line arguments."""
|
||||||
parser = argparse.ArgumentParser()
|
epilog = ('See https://github.com/romanz/trezor-agent/blob/master/'
|
||||||
subparsers = parser.add_subparsers()
|
'doc/README-GPG.md for usage examples.')
|
||||||
|
parser = argparse.ArgumentParser(epilog=epilog)
|
||||||
|
|
||||||
p = subparsers.add_parser('create', help='Export public GPG key')
|
agent_package = device_type.package_name()
|
||||||
|
resources_map = {r.key: r for r in pkg_resources.require(agent_package)}
|
||||||
|
resources = [resources_map[agent_package], resources_map['libagent']]
|
||||||
|
versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources)
|
||||||
|
parser.add_argument('--version', help='print the version info',
|
||||||
|
action='version', version=versions)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(title='Action', dest='action')
|
||||||
|
subparsers.required = True
|
||||||
|
|
||||||
|
p = subparsers.add_parser('init',
|
||||||
|
help='initialize hardware-based GnuPG identity')
|
||||||
p.add_argument('user_id')
|
p.add_argument('user_id')
|
||||||
p.add_argument('-e', '--ecdsa-curve', default='nist256p1')
|
p.add_argument('-e', '--ecdsa-curve', default='nist256p1')
|
||||||
p.add_argument('-t', '--time', type=int, default=int(time.time()))
|
p.add_argument('-t', '--time', type=int, default=0)
|
||||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||||
p.add_argument('-s', '--subkey', default=False, action='store_true')
|
p.add_argument('-s', '--subkey', default=False, action='store_true')
|
||||||
p.set_defaults(func=run_create)
|
|
||||||
|
|
||||||
p = subparsers.add_parser('unlock', help='Unlock the hardware device')
|
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)
|
||||||
|
|
||||||
|
p = subparsers.add_parser('unlock', help='unlock the hardware device')
|
||||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||||
p.set_defaults(func=run_unlock)
|
p.set_defaults(func=run_unlock)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
device_type.ui = device.ui.UI(device_type=device_type, config=vars(args))
|
||||||
|
|
||||||
return args.func(device_type=device_type, args=args)
|
return args.func(device_type=device_type, args=args)
|
||||||
|
|||||||
@@ -21,60 +21,17 @@ def yield_connections(sock):
|
|||||||
yield conn
|
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):
|
def sig_encode(r, s):
|
||||||
"""Serialize ECDSA signature data into GPG S-expression."""
|
"""Serialize ECDSA signature data into GPG S-expression."""
|
||||||
r = serialize(util.num2bytes(r, 32))
|
r = util.assuan_serialize(util.num2bytes(r, 32))
|
||||||
s = serialize(util.num2bytes(s, 32))
|
s = util.assuan_serialize(util.num2bytes(s, 32))
|
||||||
return b'(7:sig-val(5:ecdsa(1:r32:' + r + b')(1:s32:' + s + b')))'
|
return b'(7:sig-val(5:ecdsa(1:r32:' + r + b')(1:s32:' + s + b')))'
|
||||||
|
|
||||||
|
|
||||||
def open_connection(keygrip_bytes, device_type):
|
|
||||||
"""
|
|
||||||
Connect to the device for the specified keygrip.
|
|
||||||
|
|
||||||
Parse GPG public key to find the first user ID, which is used to
|
|
||||||
specify the correct signature/decryption key on the device.
|
|
||||||
"""
|
|
||||||
pubkey_dict, user_ids = decode.load_by_keygrip(
|
|
||||||
pubkey_bytes=keyring.export_public_keys(),
|
|
||||||
keygrip=keygrip_bytes)
|
|
||||||
# We assume the first user ID is used to generate TREZOR-based GPG keys.
|
|
||||||
user_id = user_ids[0]['value'].decode('ascii')
|
|
||||||
curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid'])
|
|
||||||
ecdh = (pubkey_dict['algo'] == protocol.ECDH_ALGO_ID)
|
|
||||||
|
|
||||||
conn = client.Client(user_id, curve_name=curve_name, device_type=device_type)
|
|
||||||
pubkey = protocol.PublicKey(
|
|
||||||
curve_name=curve_name, created=pubkey_dict['created'],
|
|
||||||
verifying_key=conn.pubkey(ecdh=ecdh), ecdh=ecdh)
|
|
||||||
assert pubkey.key_id() == pubkey_dict['key_id']
|
|
||||||
assert pubkey.keygrip() == keygrip_bytes
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def pksign(keygrip, digest, algo, device_type):
|
|
||||||
"""Sign a message digest using a private EC key."""
|
|
||||||
log.debug('signing %r digest (algo #%s)', digest, algo)
|
|
||||||
keygrip_bytes = binascii.unhexlify(keygrip)
|
|
||||||
conn = open_connection(keygrip_bytes, device_type=device_type)
|
|
||||||
r, s = conn.sign(binascii.unhexlify(digest))
|
|
||||||
result = sig_encode(r, s)
|
|
||||||
log.debug('result: %r', result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_point(data):
|
def _serialize_point(data):
|
||||||
prefix = '{}:'.format(len(data)).encode('ascii')
|
prefix = '{}:'.format(len(data)).encode('ascii')
|
||||||
# https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html
|
# 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):
|
def parse_ecdh(line):
|
||||||
@@ -92,80 +49,199 @@ def parse_ecdh(line):
|
|||||||
return dict(items)[b'e']
|
return dict(items)[b'e']
|
||||||
|
|
||||||
|
|
||||||
def pkdecrypt(keygrip, conn, device_type):
|
def _key_info(conn, args):
|
||||||
"""Handle decryption using ECDH."""
|
"""
|
||||||
for msg in [b'S INQUIRE_MAXLEN 4096', b'INQUIRE CIPHERTEXT']:
|
Dummy reply (mainly for 'gpg --edit' to succeed).
|
||||||
keyring.sendline(conn, msg)
|
|
||||||
|
|
||||||
line = keyring.recvline(conn)
|
For details, see GnuPG agent KEYINFO command help.
|
||||||
assert keyring.recvline(conn) == b'END'
|
https://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob;f=agent/command.c;h=c8b34e9882076b1b724346787781f657cac75499;hb=refs/heads/master#l1082
|
||||||
remote_pubkey = parse_ecdh(line)
|
"""
|
||||||
|
fmt = 'S KEYINFO {0} X - - - - - - -'
|
||||||
keygrip_bytes = binascii.unhexlify(keygrip)
|
keygrip, = args
|
||||||
conn = open_connection(keygrip_bytes, device_type=device_type)
|
keyring.sendline(conn, fmt.format(keygrip).encode('ascii'))
|
||||||
return _serialize_point(conn.ecdh(remote_pubkey))
|
|
||||||
|
|
||||||
|
|
||||||
@util.memoize
|
class AgentError(Exception):
|
||||||
def have_key(keygrip, device_type):
|
"""GnuPG agent-related error."""
|
||||||
"""Check if current keygrip correspond to a TREZOR-based key."""
|
|
||||||
try:
|
|
||||||
open_connection(keygrip_bytes=binascii.unhexlify(keygrip),
|
|
||||||
device_type=device_type)
|
|
||||||
return True
|
|
||||||
except KeyError as e:
|
|
||||||
log.warning('HAVEKEY(%s) failed: %s', keygrip, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-branches
|
class AgentStop(Exception):
|
||||||
def handle_connection(conn, device_type):
|
"""Raised to close the agent."""
|
||||||
"""Handle connection from GPG binary using the ASSUAN protocol."""
|
|
||||||
keygrip = None
|
|
||||||
digest = None
|
|
||||||
algo = None
|
|
||||||
version = keyring.gpg_version() # "Clone" existing GPG version
|
|
||||||
|
|
||||||
keyring.sendline(conn, b'OK')
|
|
||||||
for line in keyring.iterlines(conn):
|
# pylint: disable=too-many-instance-attributes
|
||||||
parts = line.split(b' ')
|
class Handler:
|
||||||
command = parts[0]
|
"""GPG agent requests' handler."""
|
||||||
args = parts[1:]
|
|
||||||
if command in {b'RESET', b'OPTION', b'SETKEYDESC'}:
|
def _get_options(self):
|
||||||
pass # reply with OK
|
return self.options
|
||||||
elif command == b'GETINFO':
|
|
||||||
keyring.sendline(conn, b'D ' + version)
|
def __init__(self, device, pubkey_bytes):
|
||||||
elif command == b'AGENT_ID':
|
"""C-tor."""
|
||||||
keyring.sendline(conn, b'D TREZOR') # "Fake" agent ID
|
self.reset()
|
||||||
elif command in {b'SIGKEY', b'SETKEY'}:
|
self.options = []
|
||||||
keygrip, = args
|
device.ui.options_getter = self._get_options
|
||||||
elif command == b'SETHASH':
|
self.client = client.Client(device=device)
|
||||||
algo, digest = args
|
# Cache public keys from GnuPG
|
||||||
elif command == b'PKSIGN':
|
self.pubkey_bytes = pubkey_bytes
|
||||||
sig = pksign(keygrip, digest, algo, device_type=device_type)
|
# "Clone" existing GPG version
|
||||||
keyring.sendline(conn, b'D ' + sig)
|
self.version = keyring.gpg_version()
|
||||||
elif command == b'PKDECRYPT':
|
|
||||||
sec = pkdecrypt(keygrip, conn, device_type=device_type)
|
self.handlers = {
|
||||||
keyring.sendline(conn, b'D ' + sec)
|
b'RESET': lambda *_: self.reset(),
|
||||||
elif command == b'HAVEKEY':
|
b'OPTION': lambda _, args: self.handle_option(*args),
|
||||||
if not have_key(keygrip=args[0], device_type=device_type):
|
b'SETKEYDESC': None,
|
||||||
keyring.sendline(conn,
|
b'NOP': None,
|
||||||
b'ERR 67108881 No secret key <GPG Agent>')
|
b'GETINFO': self.handle_getinfo,
|
||||||
return
|
b'AGENT_ID': lambda conn, _: keyring.sendline(conn, b'D TREZOR'), # "Fake" agent ID
|
||||||
elif command == b'KEYINFO':
|
b'SIGKEY': lambda _, args: self.set_key(*args),
|
||||||
keygrip, = args
|
b'SETKEY': lambda _, args: self.set_key(*args),
|
||||||
# Dummy reply (mainly for 'gpg --edit' to succeed).
|
b'SETHASH': lambda _, args: self.set_hash(*args),
|
||||||
# For details, see GnuPG agent KEYINFO command help.
|
b'PKSIGN': lambda conn, _: self.pksign(conn),
|
||||||
# https://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob;f=agent/command.c;h=c8b34e9882076b1b724346787781f657cac75499;hb=refs/heads/master#l1082
|
b'PKDECRYPT': lambda conn, _: self.pkdecrypt(conn),
|
||||||
fmt = 'S KEYINFO {0} X - - - - - - -'
|
b'HAVEKEY': lambda _, args: self.have_key(*args),
|
||||||
keyring.sendline(conn, fmt.format(keygrip).encode('ascii'))
|
b'KEYINFO': _key_info,
|
||||||
elif command == b'BYE':
|
b'SCD': self.handle_scd,
|
||||||
return
|
b'GET_PASSPHRASE': self.handle_get_passphrase,
|
||||||
elif command == b'KILLAGENT':
|
}
|
||||||
keyring.sendline(conn, b'OK')
|
|
||||||
raise StopIteration
|
def reset(self):
|
||||||
|
"""Reset agent's state variables."""
|
||||||
|
self.keygrip = None
|
||||||
|
self.digest = None
|
||||||
|
self.algo = None
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
log.error('unknown request: %r', line)
|
log.warning('Passphrase does not match!')
|
||||||
return
|
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
(b'GETINFO', b'version'): self.version,
|
||||||
|
}.get(args)
|
||||||
|
if reply is None:
|
||||||
|
raise AgentError(b'ERR 100696144 No such device <SCD>')
|
||||||
|
keyring.sendline(conn, b'D ' + reply)
|
||||||
|
|
||||||
|
@util.memoize_method # global cache for key grips
|
||||||
|
def get_identity(self, keygrip):
|
||||||
|
"""
|
||||||
|
Returns device.interface.Identity that matches specified keygrip.
|
||||||
|
|
||||||
|
In case of missing keygrip, KeyError will be raised.
|
||||||
|
"""
|
||||||
|
keygrip_bytes = binascii.unhexlify(keygrip)
|
||||||
|
pubkey_dict, user_ids = decode.load_by_keygrip(
|
||||||
|
pubkey_bytes=self.pubkey_bytes, keygrip=keygrip_bytes)
|
||||||
|
# We assume the first user ID is used to generate TREZOR-based GPG keys.
|
||||||
|
user_id = user_ids[0]['value'].decode('utf-8')
|
||||||
|
curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid'])
|
||||||
|
ecdh = (pubkey_dict['algo'] == protocol.ECDH_ALGO_ID)
|
||||||
|
|
||||||
|
identity = client.create_identity(user_id=user_id, curve_name=curve_name)
|
||||||
|
verifying_key = self.client.pubkey(identity=identity, ecdh=ecdh)
|
||||||
|
pubkey = protocol.PublicKey(
|
||||||
|
curve_name=curve_name, created=pubkey_dict['created'],
|
||||||
|
verifying_key=verifying_key, ecdh=ecdh)
|
||||||
|
assert pubkey.key_id() == pubkey_dict['key_id']
|
||||||
|
assert pubkey.keygrip() == keygrip_bytes
|
||||||
|
return identity
|
||||||
|
|
||||||
|
def pksign(self, conn):
|
||||||
|
"""Sign a message digest using a private EC key."""
|
||||||
|
log.debug('signing %r digest (algo #%s)', self.digest, self.algo)
|
||||||
|
identity = self.get_identity(keygrip=self.keygrip)
|
||||||
|
r, s = self.client.sign(identity=identity,
|
||||||
|
digest=binascii.unhexlify(self.digest))
|
||||||
|
result = sig_encode(r, s)
|
||||||
|
log.debug('result: %r', result)
|
||||||
|
keyring.sendline(conn, b'D ' + result)
|
||||||
|
|
||||||
|
def pkdecrypt(self, conn):
|
||||||
|
"""Handle decryption using ECDH."""
|
||||||
|
for msg in [b'S INQUIRE_MAXLEN 4096', b'INQUIRE CIPHERTEXT']:
|
||||||
|
keyring.sendline(conn, msg)
|
||||||
|
|
||||||
|
line = keyring.recvline(conn)
|
||||||
|
assert keyring.recvline(conn) == b'END'
|
||||||
|
remote_pubkey = parse_ecdh(line)
|
||||||
|
|
||||||
|
identity = self.get_identity(keygrip=self.keygrip)
|
||||||
|
ec_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey)
|
||||||
|
keyring.sendline(conn, b'D ' + _serialize_point(ec_point))
|
||||||
|
|
||||||
|
def have_key(self, *keygrips):
|
||||||
|
"""Check if any keygrip corresponds to a TREZOR-based key."""
|
||||||
|
for keygrip in keygrips:
|
||||||
|
try:
|
||||||
|
self.get_identity(keygrip=keygrip)
|
||||||
|
break
|
||||||
|
except KeyError as e:
|
||||||
|
log.warning('HAVEKEY(%s) failed: %s', keygrip, e)
|
||||||
|
else:
|
||||||
|
raise AgentError(b'ERR 67108881 No secret key <GPG Agent>')
|
||||||
|
|
||||||
|
def set_key(self, keygrip):
|
||||||
|
"""Set hexadecimal keygrip for next operation."""
|
||||||
|
self.keygrip = keygrip
|
||||||
|
|
||||||
|
def set_hash(self, algo, digest):
|
||||||
|
"""Set algorithm ID and hexadecimal digest for next operation."""
|
||||||
|
self.algo = algo
|
||||||
|
self.digest = digest
|
||||||
|
|
||||||
|
def handle(self, conn):
|
||||||
|
"""Handle connection from GPG binary using the ASSUAN protocol."""
|
||||||
keyring.sendline(conn, b'OK')
|
keyring.sendline(conn, b'OK')
|
||||||
|
for line in keyring.iterlines(conn):
|
||||||
|
parts = line.split(b' ')
|
||||||
|
command = parts[0]
|
||||||
|
args = tuple(parts[1:])
|
||||||
|
|
||||||
|
if command == b'BYE':
|
||||||
|
return
|
||||||
|
elif command == b'KILLAGENT':
|
||||||
|
keyring.sendline(conn, b'OK')
|
||||||
|
raise AgentStop()
|
||||||
|
|
||||||
|
if command not in self.handlers:
|
||||||
|
log.error('unknown request: %r', line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
handler = self.handlers[command]
|
||||||
|
if handler:
|
||||||
|
try:
|
||||||
|
handler(conn, args)
|
||||||
|
except AgentError as e:
|
||||||
|
msg, = e.args
|
||||||
|
keyring.sendline(conn, msg)
|
||||||
|
continue
|
||||||
|
keyring.sendline(conn, b'OK')
|
||||||
|
|||||||
@@ -2,43 +2,45 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .. import device, formats, util
|
from .. import formats, util
|
||||||
|
from ..device import interface
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Client(object):
|
def create_identity(user_id, curve_name):
|
||||||
|
"""Create GPG identity for hardware device."""
|
||||||
|
result = interface.Identity(identity_str='gpg://', curve_name=curve_name)
|
||||||
|
result.identity_dict['host'] = user_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
"""Sign messages and get public keys from a hardware device."""
|
"""Sign messages and get public keys from a hardware device."""
|
||||||
|
|
||||||
def __init__(self, user_id, curve_name, device_type):
|
def __init__(self, device):
|
||||||
"""Connect to the device and retrieve required public key."""
|
"""C-tor."""
|
||||||
self.device = device_type()
|
self.device = device
|
||||||
self.user_id = user_id
|
|
||||||
self.identity = device.interface.Identity(
|
|
||||||
identity_str='gpg://', curve_name=curve_name)
|
|
||||||
self.identity.identity_dict['host'] = user_id
|
|
||||||
|
|
||||||
def pubkey(self, ecdh=False):
|
def pubkey(self, identity, ecdh=False):
|
||||||
"""Return public key as VerifyingKey object."""
|
"""Return public key as VerifyingKey object."""
|
||||||
with self.device:
|
with self.device:
|
||||||
pubkey = self.device.pubkey(ecdh=ecdh, identity=self.identity)
|
return self.device.pubkey(ecdh=ecdh, identity=identity)
|
||||||
return formats.decompress_pubkey(
|
|
||||||
pubkey=pubkey, curve_name=self.identity.curve_name)
|
|
||||||
|
|
||||||
def sign(self, digest):
|
def sign(self, identity, digest):
|
||||||
"""Sign the digest and return a serialized signature."""
|
"""Sign the digest and return a serialized signature."""
|
||||||
log.info('please confirm GPG signature on %s for "%s"...',
|
log.info('please confirm GPG signature on %s for "%s"...',
|
||||||
self.device, self.user_id)
|
self.device, identity.to_string())
|
||||||
if self.identity.curve_name == formats.CURVE_NIST256:
|
if identity.curve_name == formats.CURVE_NIST256:
|
||||||
digest = digest[:32] # sign the first 256 bits
|
digest = digest[:32] # sign the first 256 bits
|
||||||
log.debug('signing digest: %s', util.hexlify(digest))
|
log.debug('signing digest: %s', util.hexlify(digest))
|
||||||
with self.device:
|
with self.device:
|
||||||
sig = self.device.sign(blob=digest, identity=self.identity)
|
sig = self.device.sign(blob=digest, identity=identity)
|
||||||
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
|
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
|
||||||
|
|
||||||
def ecdh(self, pubkey):
|
def ecdh(self, identity, pubkey):
|
||||||
"""Derive shared secret using ECDH from remote public key."""
|
"""Derive shared secret using ECDH from remote public key."""
|
||||||
log.info('please confirm GPG decryption on %s for "%s"...',
|
log.info('please confirm GPG decryption on %s for "%s"...',
|
||||||
self.device, self.user_id)
|
self.device, identity.to_string())
|
||||||
with self.device:
|
with self.device:
|
||||||
return self.device.ecdh(pubkey=pubkey, identity=self.identity)
|
return self.device.ecdh(pubkey=pubkey, identity=identity)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import struct
|
import struct
|
||||||
|
|
||||||
import ecdsa
|
import ecdsa
|
||||||
import ed25519
|
import nacl.signing
|
||||||
|
|
||||||
from . import protocol
|
from . import protocol
|
||||||
from .. import util
|
from .. import util
|
||||||
@@ -67,7 +67,8 @@ def _parse_ed25519_pubkey(mpi):
|
|||||||
prefix, value = util.split_bits(mpi, 8, 256)
|
prefix, value = util.split_bits(mpi, 8, 256)
|
||||||
if prefix != 0x40:
|
if prefix != 0x40:
|
||||||
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
|
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
|
||||||
return ed25519.VerifyingKey(util.num2bytes(value, size=32))
|
vk = nacl.signing.VerifyKey(util.num2bytes(value, size=32), encoder=nacl.encoding.RawEncoder)
|
||||||
|
return vk
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_CURVES = {
|
SUPPORTED_CURVES = {
|
||||||
@@ -186,6 +187,7 @@ def _parse_pubkey(stream, packet_type='pubkey'):
|
|||||||
log.debug('key ID: %s', util.hexlify(p['key_id']))
|
log.debug('key ID: %s', util.hexlify(p['key_id']))
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
_parse_subkey = functools.partial(_parse_pubkey, packet_type='subkey')
|
_parse_subkey = functools.partial(_parse_pubkey, packet_type='subkey')
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ def _parse_user_id(stream, packet_type='user_id'):
|
|||||||
to_hash = b'\xb4' + util.prefix_len('>L', value)
|
to_hash = b'\xb4' + util.prefix_len('>L', value)
|
||||||
return {'type': packet_type, 'value': value, '_to_hash': to_hash}
|
return {'type': packet_type, 'value': value, '_to_hash': to_hash}
|
||||||
|
|
||||||
|
|
||||||
# User attribute is handled as an opaque user ID
|
# User attribute is handled as an opaque user ID
|
||||||
_parse_attribute = functools.partial(_parse_user_id,
|
_parse_attribute = functools.partial(_parse_user_id,
|
||||||
packet_type='user_attribute')
|
packet_type='user_attribute')
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
|
|||||||
"""Export new primary GPG public key, ready for "gpg2 --import"."""
|
"""Export new primary GPG public key, ready for "gpg2 --import"."""
|
||||||
pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6),
|
pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6),
|
||||||
blob=(pubkey.data() + secret_bytes))
|
blob=(pubkey.data() + secret_bytes))
|
||||||
user_id_packet = protocol.packet(tag=13,
|
user_id_bytes = user_id.encode('utf-8')
|
||||||
blob=user_id.encode('ascii'))
|
user_id_packet = protocol.packet(tag=13, blob=user_id_bytes)
|
||||||
|
data_to_sign = (pubkey.data_to_hash() + user_id_packet[:1] +
|
||||||
data_to_sign = (pubkey.data_to_hash() +
|
util.prefix_len('>L', user_id_bytes))
|
||||||
user_id_packet[:1] +
|
|
||||||
util.prefix_len('>L', user_id.encode('ascii')))
|
|
||||||
hashed_subpackets = [
|
hashed_subpackets = [
|
||||||
protocol.subpacket_time(pubkey.created), # signature time
|
protocol.subpacket_time(pubkey.created), # signature time
|
||||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.7
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.7
|
||||||
@@ -25,12 +23,14 @@ def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
|
|||||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.4
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.4
|
||||||
protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign)
|
protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign)
|
||||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||||
protocol.subpacket_byte(0x15, 8), # preferred hash (SHA256)
|
protocol.subpacket_bytes(0x15, [8, 9, 10]), # preferred hash
|
||||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
|
||||||
protocol.subpacket_byte(0x16, 0), # preferred compression (none)
|
protocol.subpacket_bytes(0x16, [2, 3, 1]), # preferred compression
|
||||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
|
||||||
protocol.subpacket_byte(0x17, 0x80) # key server prefs (no-modify)
|
protocol.subpacket_byte(0x17, 0x80), # key server prefs (no-modify)
|
||||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.17
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.17
|
||||||
|
protocol.subpacket_byte(0x1E, 0x01), # advanced features (MDC)
|
||||||
|
# https://tools.ietf.org/html/rfc4880#section-5.2.3.24
|
||||||
]
|
]
|
||||||
unhashed_subpackets = [
|
unhashed_subpackets = [
|
||||||
protocol.subpacket(16, pubkey.key_id()), # issuer key id
|
protocol.subpacket(16, pubkey.key_id()), # issuer key id
|
||||||
|
|||||||
@@ -14,17 +14,29 @@ from .. import util
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_agent_sock_path(sp=subprocess):
|
def check_output(args, env=None, sp=subprocess):
|
||||||
|
"""Call an external binary and return its stdout."""
|
||||||
|
log.debug('calling %s with env %s', args, env)
|
||||||
|
output = sp.check_output(args=args, env=env)
|
||||||
|
log.debug('output: %r', output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_sock_path(env=None, sp=subprocess):
|
||||||
"""Parse gpgconf output to find out GPG agent UNIX socket path."""
|
"""Parse gpgconf output to find out GPG agent UNIX socket path."""
|
||||||
lines = sp.check_output(['gpgconf', '--list-dirs']).strip().split(b'\n')
|
args = [util.which('gpgconf'), '--list-dirs']
|
||||||
|
output = check_output(args=args, env=env, sp=sp)
|
||||||
|
lines = output.strip().split(b'\n')
|
||||||
dirs = dict(line.split(b':', 1) for line in lines)
|
dirs = dict(line.split(b':', 1) for line in lines)
|
||||||
|
log.debug('%s: %s', args, dirs)
|
||||||
return dirs[b'agent-socket']
|
return dirs[b'agent-socket']
|
||||||
|
|
||||||
|
|
||||||
def connect_to_agent(sp=subprocess):
|
def connect_to_agent(env=None, sp=subprocess):
|
||||||
"""Connect to GPG agent's UNIX socket."""
|
"""Connect to GPG agent's UNIX socket."""
|
||||||
sock_path = get_agent_sock_path(sp=sp)
|
sock_path = get_agent_sock_path(sp=sp, env=env)
|
||||||
sp.check_call(['gpg-connect-agent', '/bye']) # Make sure it's running
|
# Make sure the original gpg-agent is running.
|
||||||
|
check_output(args=['gpg-connect-agent', '/bye'], sp=sp)
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
sock.connect(sock_path)
|
sock.connect(sock_path)
|
||||||
return sock
|
return sock
|
||||||
@@ -36,9 +48,9 @@ def communicate(sock, msg):
|
|||||||
return recvline(sock)
|
return recvline(sock)
|
||||||
|
|
||||||
|
|
||||||
def sendline(sock, msg):
|
def sendline(sock, msg, confidential=False):
|
||||||
"""Send a binary message, followed by EOL."""
|
"""Send a binary message, followed by EOL."""
|
||||||
log.debug('<- %r', msg)
|
log.debug('<- %r', ('<snip>' if confidential else msg))
|
||||||
sock.sendall(msg + b'\n')
|
sock.sendall(msg + b'\n')
|
||||||
|
|
||||||
|
|
||||||
@@ -99,8 +111,8 @@ def parse(s):
|
|||||||
value, s = parse(s)
|
value, s = parse(s)
|
||||||
values.append(value)
|
values.append(value)
|
||||||
return values, s[1:]
|
return values, s[1:]
|
||||||
else:
|
|
||||||
return parse_term(s)
|
return parse_term(s)
|
||||||
|
|
||||||
|
|
||||||
def _parse_ecdsa_sig(args):
|
def _parse_ecdsa_sig(args):
|
||||||
@@ -110,6 +122,7 @@ def _parse_ecdsa_sig(args):
|
|||||||
return (util.bytes2num(sig_r),
|
return (util.bytes2num(sig_r),
|
||||||
util.bytes2num(sig_s))
|
util.bytes2num(sig_s))
|
||||||
|
|
||||||
|
|
||||||
# DSA and EDDSA happen to have the same structure as ECDSA signatures
|
# DSA and EDDSA happen to have the same structure as ECDSA signatures
|
||||||
_parse_dsa_sig = _parse_ecdsa_sig
|
_parse_dsa_sig = _parse_ecdsa_sig
|
||||||
_parse_eddsa_sig = _parse_ecdsa_sig
|
_parse_eddsa_sig = _parse_ecdsa_sig
|
||||||
@@ -140,7 +153,7 @@ def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
|
|||||||
|
|
||||||
assert communicate(sock, 'RESET').startswith(b'OK')
|
assert communicate(sock, 'RESET').startswith(b'OK')
|
||||||
|
|
||||||
ttyname = sp.check_output(['tty']).strip()
|
ttyname = check_output(args=['tty'], sp=sp).strip()
|
||||||
options = ['ttyname={}'.format(ttyname)] # set TTY for passphrase entry
|
options = ['ttyname={}'.format(ttyname)] # set TTY for passphrase entry
|
||||||
|
|
||||||
display = (environ or os.environ).get('DISPLAY')
|
display = (environ or os.environ).get('DISPLAY')
|
||||||
@@ -160,9 +173,7 @@ def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
|
|||||||
assert communicate(sock, 'PKSIGN') == b'OK'
|
assert communicate(sock, 'PKSIGN') == b'OK'
|
||||||
while True:
|
while True:
|
||||||
line = recvline(sock).strip()
|
line = recvline(sock).strip()
|
||||||
if line.startswith(b'S PROGRESS'):
|
if not line.startswith(b'S PROGRESS'):
|
||||||
continue
|
|
||||||
else:
|
|
||||||
break
|
break
|
||||||
line = unescape(line)
|
line = unescape(line)
|
||||||
log.debug('unescaped: %r', line)
|
log.debug('unescaped: %r', line)
|
||||||
@@ -175,51 +186,70 @@ def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
|
|||||||
return parse_sig(sig)
|
return parse_sig(sig)
|
||||||
|
|
||||||
|
|
||||||
|
def get_gnupg_components(sp=subprocess):
|
||||||
|
"""Parse GnuPG components' paths."""
|
||||||
|
args = [util.which('gpgconf'), '--list-components']
|
||||||
|
output = check_output(args=args, sp=sp)
|
||||||
|
components = dict(re.findall('(.*):.*:(.*)', output.decode('utf-8')))
|
||||||
|
log.debug('gpgconf --list-components: %s', components)
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
@util.memoize
|
||||||
|
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']
|
||||||
|
|
||||||
|
|
||||||
def gpg_command(args, env=None):
|
def gpg_command(args, env=None):
|
||||||
"""Prepare common GPG command line arguments."""
|
"""Prepare common GPG command line arguments."""
|
||||||
if env is None:
|
if env is None:
|
||||||
env = os.environ
|
env = os.environ
|
||||||
cmd = ['gpg2']
|
cmd = get_gnupg_binary(neopg_binary=env.get('NEOPG_BINARY'))
|
||||||
homedir = env.get('GNUPGHOME')
|
return [cmd] + args
|
||||||
if homedir:
|
|
||||||
cmd.extend(['--homedir', homedir])
|
|
||||||
return cmd + args
|
|
||||||
|
|
||||||
|
|
||||||
def get_keygrip(user_id, sp=subprocess):
|
def get_keygrip(user_id, sp=subprocess):
|
||||||
"""Get a keygrip of the primary GPG key of the specified user."""
|
"""Get a keygrip of the primary GPG key of the specified user."""
|
||||||
args = gpg_command(['--list-keys', '--with-keygrip', user_id])
|
args = gpg_command(['--list-keys', '--with-keygrip', user_id])
|
||||||
output = sp.check_output(args).decode('ascii')
|
output = check_output(args=args, sp=sp).decode('utf-8')
|
||||||
return re.findall(r'Keygrip = (\w+)', output)[0]
|
return re.findall(r'Keygrip = (\w+)', output)[0]
|
||||||
|
|
||||||
|
|
||||||
def gpg_version(sp=subprocess):
|
def gpg_version(sp=subprocess):
|
||||||
"""Get a keygrip of the primary GPG key of the specified user."""
|
"""Get a keygrip of the primary GPG key of the specified user."""
|
||||||
args = gpg_command(['--version'])
|
args = gpg_command(['--version'])
|
||||||
output = sp.check_output(args)
|
output = check_output(args=args, sp=sp)
|
||||||
line = output.split(b'\n')[0] # b'gpg (GnuPG) 2.1.11'
|
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, sp=subprocess):
|
def export_public_key(user_id, env=None, sp=subprocess):
|
||||||
"""Export GPG public key for specified `user_id`."""
|
"""Export GPG public key for specified `user_id`."""
|
||||||
args = gpg_command(['--export', user_id])
|
args = gpg_command(['--export', user_id])
|
||||||
result = sp.check_output(args=args)
|
result = check_output(args=args, env=env, sp=sp)
|
||||||
if not result:
|
if not result:
|
||||||
log.error('could not find public key %r in local GPG keyring', user_id)
|
log.error('could not find public key %r in local GPG keyring', user_id)
|
||||||
raise KeyError(user_id)
|
raise KeyError(user_id)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def export_public_keys(sp=subprocess):
|
def export_public_keys(env=None, sp=subprocess):
|
||||||
"""Export all GPG public keys."""
|
"""Export all GPG public keys."""
|
||||||
args = gpg_command(['--export'])
|
args = gpg_command(['--export'])
|
||||||
return sp.check_output(args=args)
|
result = check_output(args=args, env=env, sp=sp)
|
||||||
|
if not result:
|
||||||
|
raise KeyError('No GPG public keys found at env: {!r}'.format(env))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def create_agent_signer(user_id):
|
def create_agent_signer(user_id):
|
||||||
"""Sign digest with existing GPG keys using gpg-agent tool."""
|
"""Sign digest with existing GPG keys using gpg-agent tool."""
|
||||||
sock = connect_to_agent()
|
sock = connect_to_agent(env=os.environ)
|
||||||
keygrip = get_keygrip(user_id)
|
keygrip = get_keygrip(user_id)
|
||||||
|
|
||||||
def sign(digest):
|
def sign(digest):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
import nacl.signing
|
||||||
|
|
||||||
from .. import formats, util
|
from .. import formats, util
|
||||||
|
|
||||||
@@ -47,6 +48,11 @@ def subpacket_byte(subpacket_type, value):
|
|||||||
return subpacket(subpacket_type, '>B', value)
|
return subpacket(subpacket_type, '>B', value)
|
||||||
|
|
||||||
|
|
||||||
|
def subpacket_bytes(subpacket_type, values):
|
||||||
|
"""Create GPG subpacket with 8-bit unsigned integers."""
|
||||||
|
return subpacket(subpacket_type, '>' + 'B'*len(values), *values)
|
||||||
|
|
||||||
|
|
||||||
def subpacket_prefix_len(item):
|
def subpacket_prefix_len(item):
|
||||||
"""Prefix subpacket length according to RFC 4880 section-5.2.3.1."""
|
"""Prefix subpacket length according to RFC 4880 section-5.2.3.1."""
|
||||||
n = len(item)
|
n = len(item)
|
||||||
@@ -87,7 +93,7 @@ def _serialize_nist256(vk):
|
|||||||
|
|
||||||
def _serialize_ed25519(vk):
|
def _serialize_ed25519(vk):
|
||||||
return mpi((0x40 << 256) |
|
return mpi((0x40 << 256) |
|
||||||
util.bytes2num(vk.to_bytes()))
|
util.bytes2num(vk.encode(encoder=nacl.encoding.RawEncoder)))
|
||||||
|
|
||||||
|
|
||||||
def _compute_keygrip(params):
|
def _compute_keygrip(params):
|
||||||
@@ -126,7 +132,7 @@ def keygrip_ed25519(vk):
|
|||||||
['b', util.num2bytes(0x2DFC9311D490018C7338BF8688861767FF8FF5B2BEBE27548A14B235ECA6874A, size=32)], # nopep8
|
['b', util.num2bytes(0x2DFC9311D490018C7338BF8688861767FF8FF5B2BEBE27548A14B235ECA6874A, size=32)], # nopep8
|
||||||
['g', util.num2bytes(0x04216936D3CD6E53FEC0A4E231FDD6DC5C692CC7609525A7B2C9562D608F25D51A6666666666666666666666666666666666666666666666666666666666666658, size=65)], # nopep8
|
['g', util.num2bytes(0x04216936D3CD6E53FEC0A4E231FDD6DC5C692CC7609525A7B2C9562D608F25D51A6666666666666666666666666666666666666666666666666666666666666658, size=65)], # nopep8
|
||||||
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
|
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
|
||||||
['q', vk.to_bytes()],
|
['q', vk.encode(encoder=nacl.encoding.RawEncoder)],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +145,7 @@ def keygrip_curve25519(vk):
|
|||||||
['b', b'\x01'],
|
['b', b'\x01'],
|
||||||
['g', util.num2bytes(0x04000000000000000000000000000000000000000000000000000000000000000920ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9, size=65)], # nopep8
|
['g', util.num2bytes(0x04000000000000000000000000000000000000000000000000000000000000000920ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9, size=65)], # nopep8
|
||||||
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
|
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
|
||||||
['q', vk.to_bytes()],
|
['q', vk.encode(encoder=nacl.encoding.RawEncoder)],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ def get_curve_name_by_oid(oid):
|
|||||||
raise KeyError('Unknown OID: {!r}'.format(oid))
|
raise KeyError('Unknown OID: {!r}'.format(oid))
|
||||||
|
|
||||||
|
|
||||||
class PublicKey(object):
|
class PublicKey:
|
||||||
"""GPG representation for public key packets."""
|
"""GPG representation for public key packets."""
|
||||||
|
|
||||||
def __init__(self, curve_name, created, verifying_key, ecdh=False):
|
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,)
|
assert keyring.parse_sig(sig) == (0x1020304,)
|
||||||
|
|
||||||
|
|
||||||
class FakeSocket(object):
|
class FakeSocket:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rx = io.BytesIO()
|
self.rx = io.BytesIO()
|
||||||
self.tx = io.BytesIO()
|
self.tx = io.BytesIO()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ecdsa
|
import ecdsa
|
||||||
import ed25519
|
import nacl.signing
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .. import protocol
|
from .. import protocol
|
||||||
@@ -83,8 +83,8 @@ def test_nist256p1_ecdh():
|
|||||||
|
|
||||||
|
|
||||||
def test_ed25519():
|
def test_ed25519():
|
||||||
sk = ed25519.SigningKey(b'\x00' * 32)
|
sk = nacl.signing.SigningKey(b'\x00'*32, encoder=nacl.encoding.RawEncoder)
|
||||||
vk = sk.get_verifying_key()
|
vk = sk.verify_key
|
||||||
pk = protocol.PublicKey(curve_name=formats.CURVE_ED25519,
|
pk = protocol.PublicKey(curve_name=formats.CURVE_ED25519,
|
||||||
created=42, verifying_key=vk)
|
created=42, verifying_key=vk)
|
||||||
assert repr(pk) == 'GPG public key ed25519/36B40FE6'
|
assert repr(pk) == 'GPG public key ed25519/36B40FE6'
|
||||||
@@ -92,8 +92,8 @@ def test_ed25519():
|
|||||||
|
|
||||||
|
|
||||||
def test_curve25519():
|
def test_curve25519():
|
||||||
sk = ed25519.SigningKey(b'\x00' * 32)
|
sk = nacl.signing.SigningKey(b'\x00'*32, encoder=nacl.encoding.RawEncoder)
|
||||||
vk = sk.get_verifying_key()
|
vk = sk.verify_key
|
||||||
pk = protocol.PublicKey(curve_name=formats.ECDH_CURVE25519,
|
pk = protocol.PublicKey(curve_name=formats.ECDH_CURVE25519,
|
||||||
created=42, verifying_key=vk)
|
created=42, verifying_key=vk)
|
||||||
assert repr(pk) == 'GPG public key curve25519/69460384'
|
assert repr(pk) == 'GPG public key curve25519/69460384'
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
"""UNIX-domain socket server for ssh-agent implementation."""
|
"""UNIX-domain socket server for ssh-agent implementation."""
|
||||||
import contextlib
|
import contextlib
|
||||||
import functools
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
UNIX_SOCKET_TIMEOUT = 0.1
|
|
||||||
|
|
||||||
|
|
||||||
def remove_file(path, remove=os.remove, exists=os.path.exists):
|
def remove_file(path, remove=os.remove, exists=os.path.exists):
|
||||||
"""Remove file, and raise OSError if still exists."""
|
"""Remove file, and raise OSError if still exists."""
|
||||||
@@ -43,6 +39,43 @@ def unix_domain_socket_server(sock_path):
|
|||||||
remove_file(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):
|
def handle_connection(conn, handler, mutex):
|
||||||
"""
|
"""
|
||||||
Handle a single connection using the specified protocol handler in a loop.
|
Handle a single connection using the specified protocol handler in a loop.
|
||||||
@@ -114,39 +147,6 @@ def spawn(func, kwargs):
|
|||||||
t.join()
|
t.join()
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def serve(handler, sock_path=None, timeout=UNIX_SOCKET_TIMEOUT):
|
|
||||||
"""
|
|
||||||
Start the ssh-agent server on a UNIX-domain socket.
|
|
||||||
|
|
||||||
If no connection is made during the specified timeout,
|
|
||||||
retry until the context is over.
|
|
||||||
"""
|
|
||||||
ssh_version = subprocess.check_output(['ssh', '-V'],
|
|
||||||
stderr=subprocess.STDOUT)
|
|
||||||
log.debug('local SSH version: %r', ssh_version)
|
|
||||||
if sock_path is None:
|
|
||||||
sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-')
|
|
||||||
|
|
||||||
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
|
|
||||||
device_mutex = threading.Lock()
|
|
||||||
with unix_domain_socket_server(sock_path) as sock:
|
|
||||||
sock.settimeout(timeout)
|
|
||||||
quit_event = threading.Event()
|
|
||||||
handle_conn = functools.partial(handle_connection,
|
|
||||||
handler=handler,
|
|
||||||
mutex=device_mutex)
|
|
||||||
kwargs = dict(sock=sock,
|
|
||||||
handle_conn=handle_conn,
|
|
||||||
quit_event=quit_event)
|
|
||||||
with spawn(server_thread, kwargs):
|
|
||||||
try:
|
|
||||||
yield environ
|
|
||||||
finally:
|
|
||||||
log.debug('closing server')
|
|
||||||
quit_event.set()
|
|
||||||
|
|
||||||
|
|
||||||
def run_process(command, environ):
|
def run_process(command, environ):
|
||||||
"""
|
"""
|
||||||
Run the specified process and wait until it finishes.
|
Run the specified process and wait until it finishes.
|
||||||
@@ -159,7 +159,7 @@ def run_process(command, environ):
|
|||||||
try:
|
try:
|
||||||
p = subprocess.Popen(args=command, env=env)
|
p = subprocess.Popen(args=command, env=env)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise OSError('cannot run %r: %s' % (command, e))
|
raise OSError('cannot run %r: %s' % (command, e)) from e
|
||||||
log.debug('subprocess %d is running', p.pid)
|
log.debug('subprocess %d is running', p.pid)
|
||||||
ret = p.wait()
|
ret = p.wait()
|
||||||
log.debug('subprocess %d exited: %d', p.pid, ret)
|
log.debug('subprocess %d exited: %d', p.pid, ret)
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
"""SSH-agent implementation using hardware authentication devices."""
|
"""SSH-agent implementation using hardware authentication devices."""
|
||||||
import argparse
|
import contextlib
|
||||||
import functools
|
import functools
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
from .. import client, device, formats, protocol, server, util
|
import pkg_resources
|
||||||
|
import configargparse
|
||||||
|
import daemon
|
||||||
|
|
||||||
|
from .. import device, formats, server, util
|
||||||
|
from . import client, protocol
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNIX_SOCKET_TIMEOUT = 0.1
|
||||||
|
|
||||||
def ssh_args(label):
|
|
||||||
|
def ssh_args(conn):
|
||||||
"""Create SSH command for connecting specified server."""
|
"""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 = []
|
args = []
|
||||||
if 'port' in identity:
|
if 'port' in identity:
|
||||||
@@ -22,12 +35,15 @@ def ssh_args(label):
|
|||||||
if 'user' in identity:
|
if 'user' in identity:
|
||||||
args += ['-l', identity['user']]
|
args += ['-l', identity['user']]
|
||||||
|
|
||||||
|
args += ['-o', 'IdentityFile={}'.format(pubkey_tempfile.name)]
|
||||||
|
args += ['-o', 'IdentitiesOnly=true']
|
||||||
return args + [identity['host']]
|
return args + [identity['host']]
|
||||||
|
|
||||||
|
|
||||||
def mosh_args(label):
|
def mosh_args(conn):
|
||||||
"""Create SSH command for connecting specified server."""
|
"""Create SSH command for connecting specified server."""
|
||||||
identity = device.interface.string_to_identity(label)
|
I, = conn.identities
|
||||||
|
identity = I.identity_dict
|
||||||
|
|
||||||
args = []
|
args = []
|
||||||
if 'port' in identity:
|
if 'port' in identity:
|
||||||
@@ -40,84 +56,113 @@ def mosh_args(label):
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def create_parser():
|
def _to_unicode(s):
|
||||||
"""Create argparse.ArgumentParser for this tool."""
|
try:
|
||||||
p = argparse.ArgumentParser()
|
return unicode(s, 'utf-8')
|
||||||
|
except NameError:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def create_agent_parser(device_type):
|
||||||
|
"""Create an ArgumentParser for this tool."""
|
||||||
|
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')
|
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||||
|
|
||||||
curve_names = [name for name in formats.SUPPORTED_CURVES]
|
agent_package = device_type.package_name()
|
||||||
curve_names = ', '.join(sorted(curve_names))
|
resources_map = {r.key: r for r in pkg_resources.require(agent_package)}
|
||||||
|
resources = [resources_map[agent_package], resources_map['libagent']]
|
||||||
|
versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources)
|
||||||
|
p.add_argument('--version', help='print the version info',
|
||||||
|
action='version', version=versions)
|
||||||
|
|
||||||
|
curve_names = ', '.join(sorted(formats.SUPPORTED_CURVES))
|
||||||
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
|
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
|
||||||
default=formats.CURVE_NIST256,
|
default=formats.CURVE_NIST256,
|
||||||
help='specify ECDSA curve name: ' + curve_names)
|
help='specify ECDSA curve name: ' + curve_names)
|
||||||
p.add_argument('--timeout',
|
p.add_argument('--timeout',
|
||||||
default=server.UNIX_SOCKET_TIMEOUT, type=float,
|
default=UNIX_SOCKET_TIMEOUT, type=float,
|
||||||
help='Timeout for accepting SSH client connections')
|
help='timeout for accepting SSH client connections')
|
||||||
p.add_argument('--debug', default=False, action='store_true',
|
p.add_argument('--debug', default=False, action='store_true',
|
||||||
help='Log SSH protocol messages for debugging.')
|
help='log SSH protocol messages for debugging.')
|
||||||
return p
|
p.add_argument('--log-file', type=str,
|
||||||
|
help='Path to the log file (to be written by the agent).')
|
||||||
|
p.add_argument('--sock-path', type=str,
|
||||||
|
help='Path to the UNIX domain socket of the agent.')
|
||||||
|
|
||||||
|
p.add_argument('--pin-entry-binary', type=str, default='pinentry',
|
||||||
def create_agent_parser():
|
help='Path to PIN entry UI helper.')
|
||||||
"""Specific parser for SSH connection."""
|
p.add_argument('--passphrase-entry-binary', type=str, default='pinentry',
|
||||||
p = create_parser()
|
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 = p.add_mutually_exclusive_group()
|
||||||
|
g.add_argument('-d', '--daemonize', default=False, action='store_true',
|
||||||
|
help='Daemonize the agent and print its UNIX socket path')
|
||||||
|
g.add_argument('-f', '--foreground', default=False, action='store_true',
|
||||||
|
help='Run agent in foreground with specified UNIX socket path')
|
||||||
g.add_argument('-s', '--shell', default=False, action='store_true',
|
g.add_argument('-s', '--shell', default=False, action='store_true',
|
||||||
help='run ${SHELL} as subprocess under SSH agent')
|
help=('run ${SHELL} as subprocess under SSH agent, allowing '
|
||||||
|
'regular SSH-based tools to be used in the shell'))
|
||||||
g.add_argument('-c', '--connect', default=False, action='store_true',
|
g.add_argument('-c', '--connect', default=False, action='store_true',
|
||||||
help='connect to specified host via SSH')
|
help='connect to specified host via SSH')
|
||||||
g.add_argument('--mosh', default=False, action='store_true',
|
g.add_argument('--mosh', default=False, action='store_true',
|
||||||
help='connect to specified host via using Mosh')
|
help='connect to specified host via using Mosh')
|
||||||
|
|
||||||
p.add_argument('identity', type=str, default=None,
|
p.add_argument('identity', type=_to_unicode, default=None,
|
||||||
help='proto://[user@]host[:port][/path]')
|
help='proto://[user@]host[:port][/path]')
|
||||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||||
help='command to run under the SSH agent')
|
help='command to run under the SSH agent')
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def create_git_parser():
|
@contextlib.contextmanager
|
||||||
"""Specific parser for git commands."""
|
def serve(handler, sock_path, timeout=UNIX_SOCKET_TIMEOUT):
|
||||||
p = create_parser()
|
"""
|
||||||
|
Start the ssh-agent server on a UNIX-domain socket.
|
||||||
|
|
||||||
p.add_argument('-r', '--remote', default='origin',
|
If no connection is made during the specified timeout,
|
||||||
help='use this git remote URL to generate SSH identity')
|
retry until the context is over.
|
||||||
p.add_argument('-t', '--test', action='store_true',
|
"""
|
||||||
help='test connection using `ssh -T user@host` command')
|
ssh_version = subprocess.check_output(['ssh', '-V'],
|
||||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
stderr=subprocess.STDOUT)
|
||||||
help='Git command to run under the SSH agent')
|
log.debug('local SSH version: %r', ssh_version)
|
||||||
return p
|
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
|
||||||
|
device_mutex = threading.Lock()
|
||||||
|
with server.unix_domain_socket_server(sock_path) as sock:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
quit_event = threading.Event()
|
||||||
|
handle_conn = functools.partial(server.handle_connection,
|
||||||
|
handler=handler,
|
||||||
|
mutex=device_mutex)
|
||||||
|
kwargs = dict(sock=sock,
|
||||||
|
handle_conn=handle_conn,
|
||||||
|
quit_event=quit_event)
|
||||||
|
with server.spawn(server.server_thread, kwargs):
|
||||||
|
try:
|
||||||
|
yield environ
|
||||||
|
finally:
|
||||||
|
log.debug('closing server')
|
||||||
|
quit_event.set()
|
||||||
|
|
||||||
|
|
||||||
def git_host(remote_name, attributes):
|
def run_server(conn, command, sock_path, debug, timeout):
|
||||||
"""Extract git SSH host for specified remote name."""
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output('git config --local --list'.split())
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return
|
|
||||||
|
|
||||||
for attribute in attributes:
|
|
||||||
name = r'remote.{0}.{1}'.format(remote_name, attribute)
|
|
||||||
matches = re.findall(re.escape(name) + '=(.*)', output)
|
|
||||||
log.debug('%r: %r', name, matches)
|
|
||||||
if not matches:
|
|
||||||
continue
|
|
||||||
|
|
||||||
url = matches[0].strip()
|
|
||||||
match = re.match('(?P<user>.*?)@(?P<host>.*?):(?P<path>.*)', url)
|
|
||||||
if match:
|
|
||||||
return '{user}@{host}'.format(**match.groupdict())
|
|
||||||
|
|
||||||
|
|
||||||
def run_server(conn, command, debug, timeout):
|
|
||||||
"""Common code for run_agent and run_git below."""
|
"""Common code for run_agent and run_git below."""
|
||||||
|
ret = 0
|
||||||
try:
|
try:
|
||||||
handler = protocol.Handler(conn=conn, debug=debug)
|
handler = protocol.Handler(conn=conn, debug=debug)
|
||||||
with server.serve(handler=handler, timeout=timeout) as env:
|
with serve(handler=handler, sock_path=sock_path,
|
||||||
return server.run_process(command=command, environ=env)
|
timeout=timeout) as env:
|
||||||
|
if command:
|
||||||
|
ret = server.run_process(command=command, environ=env)
|
||||||
|
else:
|
||||||
|
signal.pause() # wait for signal (e.g. SIGINT)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log.info('server stopped')
|
log.info('server stopped')
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def handle_connection_error(func):
|
def handle_connection_error(func):
|
||||||
@@ -126,33 +171,43 @@ def handle_connection_error(func):
|
|||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except IOError as e:
|
except device.interface.NotFoundError as e:
|
||||||
log.error('Connection error (try unplugging and replugging your device): %s', e)
|
log.error('Connection error (try unplugging and replugging your device): %s', e)
|
||||||
return 1
|
return 1
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def parse_config(fname):
|
def parse_config(contents):
|
||||||
"""Parse config file into a list of Identity objects."""
|
"""Parse config file into a list of Identity objects."""
|
||||||
contents = open(fname).read()
|
|
||||||
for identity_str, curve_name in re.findall(r'\<(.*?)\|(.*?)\>', contents):
|
for identity_str, curve_name in re.findall(r'\<(.*?)\|(.*?)\>', contents):
|
||||||
yield device.interface.Identity(identity_str=identity_str,
|
yield device.interface.Identity(identity_str=identity_str,
|
||||||
curve_name=curve_name)
|
curve_name=curve_name)
|
||||||
|
|
||||||
|
|
||||||
class JustInTimeConnection(object):
|
def import_public_keys(contents):
|
||||||
|
"""Load (previously exported) SSH public keys from a file's contents."""
|
||||||
|
for line in io.StringIO(contents):
|
||||||
|
# Verify this line represents valid SSH public key
|
||||||
|
formats.import_public_key(line)
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
|
class JustInTimeConnection:
|
||||||
"""Connect to the device just before the needed operation."""
|
"""Connect to the device just before the needed operation."""
|
||||||
|
|
||||||
def __init__(self, conn_factory, identities):
|
def __init__(self, conn_factory, identities, public_keys=None):
|
||||||
"""Create a JIT connection object."""
|
"""Create a JIT connection object."""
|
||||||
self.conn_factory = conn_factory
|
self.conn_factory = conn_factory
|
||||||
self.identities = identities
|
self.identities = identities
|
||||||
self.public_keys = util.memoize(self._public_keys) # a simple cache
|
self.public_keys_cache = public_keys
|
||||||
|
self.public_keys_tempfiles = []
|
||||||
|
|
||||||
def _public_keys(self):
|
def public_keys(self):
|
||||||
"""Return a list of SSH public keys (in textual format)."""
|
"""Return a list of SSH public keys (in textual format)."""
|
||||||
conn = self.conn_factory()
|
if not self.public_keys_cache:
|
||||||
return conn.export_public_keys(self.identities)
|
conn = self.conn_factory()
|
||||||
|
self.public_keys_cache = conn.export_public_keys(self.identities)
|
||||||
|
return self.public_keys_cache
|
||||||
|
|
||||||
def parse_public_keys(self):
|
def parse_public_keys(self):
|
||||||
"""Parse SSH public keys into dictionaries."""
|
"""Parse SSH public keys into dictionaries."""
|
||||||
@@ -162,44 +217,94 @@ class JustInTimeConnection(object):
|
|||||||
pk['identity'] = identity
|
pk['identity'] = identity
|
||||||
return public_keys
|
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):
|
def sign(self, blob, identity):
|
||||||
"""Sign a given blob using the specified identity on the device."""
|
"""Sign a given blob using the specified identity on the device."""
|
||||||
conn = self.conn_factory()
|
conn = self.conn_factory()
|
||||||
return conn.sign_ssh_challenge(blob=blob, identity=identity)
|
return conn.sign_ssh_challenge(blob=blob, identity=identity)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _dummy_context():
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sock_path(args):
|
||||||
|
sock_path = args.sock_path
|
||||||
|
if not sock_path:
|
||||||
|
if args.foreground:
|
||||||
|
log.error('running in foreground mode requires specifying UNIX socket path')
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-')
|
||||||
|
return sock_path
|
||||||
|
|
||||||
|
|
||||||
@handle_connection_error
|
@handle_connection_error
|
||||||
def main(device_type):
|
def main(device_type):
|
||||||
"""Run ssh-agent using given hardware client factory."""
|
"""Run ssh-agent using given hardware client factory."""
|
||||||
args = create_agent_parser().parse_args()
|
args = create_agent_parser(device_type=device_type).parse_args()
|
||||||
util.setup_logging(verbosity=args.verbose)
|
util.setup_logging(verbosity=args.verbose, filename=args.log_file)
|
||||||
|
|
||||||
|
public_keys = None
|
||||||
|
filename = None
|
||||||
if args.identity.startswith('/'):
|
if args.identity.startswith('/'):
|
||||||
identities = list(parse_config(fname=args.identity))
|
filename = args.identity
|
||||||
|
contents = open(filename, 'rb').read().decode('utf-8')
|
||||||
|
# Allow loading previously exported SSH public keys
|
||||||
|
if filename.endswith('.pub'):
|
||||||
|
public_keys = list(import_public_keys(contents))
|
||||||
|
identities = list(parse_config(contents))
|
||||||
else:
|
else:
|
||||||
identities = [device.interface.Identity(
|
identities = [device.interface.Identity(
|
||||||
identity_str=args.identity, curve_name=args.ecdsa_curve_name)]
|
identity_str=args.identity, curve_name=args.ecdsa_curve_name)]
|
||||||
for index, identity in enumerate(identities):
|
for index, identity in enumerate(identities):
|
||||||
identity.identity_dict['proto'] = 'ssh'
|
identity.identity_dict['proto'] = u'ssh'
|
||||||
log.info('identity #%d: %s', index, identity)
|
log.info('identity #%d: %s', index, identity.to_string())
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
sock_path = _get_sock_path(args)
|
||||||
|
command = args.command
|
||||||
|
context = _dummy_context()
|
||||||
if args.connect:
|
if args.connect:
|
||||||
command = ['ssh'] + ssh_args(args.identity) + args.command
|
command = ['ssh'] + ssh_args(conn) + args.command
|
||||||
elif args.mosh:
|
elif args.mosh:
|
||||||
command = ['mosh'] + mosh_args(args.identity) + args.command
|
command = ['mosh'] + mosh_args(conn) + args.command
|
||||||
else:
|
elif args.daemonize:
|
||||||
command = args.command
|
out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path)
|
||||||
|
sys.stdout.write(out)
|
||||||
|
sys.stdout.flush()
|
||||||
|
context = daemon.DaemonContext()
|
||||||
|
log.info('running the agent as a daemon on %s', sock_path)
|
||||||
|
elif args.foreground:
|
||||||
|
log.info('running the agent on %s', sock_path)
|
||||||
|
|
||||||
use_shell = bool(args.shell)
|
use_shell = bool(args.shell)
|
||||||
if use_shell:
|
if use_shell:
|
||||||
command = os.environ['SHELL']
|
command = os.environ['SHELL']
|
||||||
|
sys.stdin.close()
|
||||||
|
|
||||||
conn = JustInTimeConnection(
|
if command or args.daemonize or args.foreground:
|
||||||
conn_factory=lambda: client.Client(device_type()),
|
with context:
|
||||||
identities=identities)
|
return run_server(conn=conn, command=command, sock_path=sock_path,
|
||||||
if command:
|
debug=args.debug, timeout=args.timeout)
|
||||||
return run_server(conn=conn, command=command, debug=args.debug,
|
|
||||||
timeout=args.timeout)
|
|
||||||
else:
|
else:
|
||||||
for pk in conn.public_keys():
|
for pk in conn.public_keys():
|
||||||
sys.stdout.write(pk)
|
sys.stdout.write(pk)
|
||||||
|
return 0 # success exit code
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from . import formats, util
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Client(object):
|
class Client:
|
||||||
"""Client wrapper for SSH authentication device."""
|
"""Client wrapper for SSH authentication device."""
|
||||||
|
|
||||||
def __init__(self, device):
|
def __init__(self, device):
|
||||||
@@ -20,15 +20,14 @@ class Client(object):
|
|||||||
|
|
||||||
def export_public_keys(self, identities):
|
def export_public_keys(self, identities):
|
||||||
"""Export SSH public keys from the device."""
|
"""Export SSH public keys from the device."""
|
||||||
public_keys = []
|
pubkeys = []
|
||||||
with self.device:
|
with self.device:
|
||||||
for i in identities:
|
for i in identities:
|
||||||
pubkey = self.device.pubkey(identity=i)
|
vk = self.device.pubkey(identity=i)
|
||||||
vk = formats.decompress_pubkey(pubkey=pubkey,
|
label = i.to_string()
|
||||||
curve_name=i.curve_name)
|
pubkey = formats.export_public_key(vk=vk, label=label)
|
||||||
public_keys.append(formats.export_public_key(vk=vk,
|
pubkeys.append(pubkey)
|
||||||
label=str(i)))
|
return pubkeys
|
||||||
return public_keys
|
|
||||||
|
|
||||||
def sign_ssh_challenge(self, blob, identity):
|
def sign_ssh_challenge(self, blob, identity):
|
||||||
"""Sign given blob using a private key on the device."""
|
"""Sign given blob using a private key on the device."""
|
||||||
@@ -41,7 +40,7 @@ class Client(object):
|
|||||||
log.debug('hidden challenge size: %d bytes', len(blob))
|
log.debug('hidden challenge size: %d bytes', len(blob))
|
||||||
|
|
||||||
log.info('please confirm user "%s" login to "%s" using %s...',
|
log.info('please confirm user "%s" login to "%s" using %s...',
|
||||||
msg['user'].decode('ascii'), identity,
|
msg['user'].decode('ascii'), identity.to_string(),
|
||||||
self.device)
|
self.device)
|
||||||
|
|
||||||
with self.device:
|
with self.device:
|
||||||
@@ -70,7 +70,7 @@ def _legacy_pubs(buf):
|
|||||||
return util.frame(code, num)
|
return util.frame(code, num)
|
||||||
|
|
||||||
|
|
||||||
class Handler(object):
|
class Handler:
|
||||||
"""ssh-agent protocol handler."""
|
"""ssh-agent protocol handler."""
|
||||||
|
|
||||||
def __init__(self, conn, debug=False):
|
def __init__(self, conn, debug=False):
|
||||||
@@ -138,7 +138,7 @@ class Handler(object):
|
|||||||
else:
|
else:
|
||||||
raise KeyError('key not found')
|
raise KeyError('key not found')
|
||||||
|
|
||||||
label = key['name'].decode('ascii') # label should be a string
|
label = key['name'].decode('utf-8')
|
||||||
log.debug('signing %d-byte blob with "%s" key', len(blob), label)
|
log.debug('signing %d-byte blob with "%s" key', len(blob), label)
|
||||||
try:
|
try:
|
||||||
signature = self.conn.sign(blob=blob, identity=key['identity'])
|
signature = self.conn.sign(blob=blob, identity=key['identity'])
|
||||||
@@ -149,9 +149,9 @@ class Handler(object):
|
|||||||
try:
|
try:
|
||||||
sig_bytes = key['verifier'](sig=signature, msg=blob)
|
sig_bytes = key['verifier'](sig=signature, msg=blob)
|
||||||
log.info('signature status: OK')
|
log.info('signature status: OK')
|
||||||
except formats.ecdsa.BadSignatureError:
|
except formats.ecdsa.BadSignatureError as e:
|
||||||
log.exception('signature status: ERROR')
|
log.exception('signature status: ERROR')
|
||||||
raise ValueError('invalid ECDSA signature')
|
raise ValueError('invalid ECDSA signature') from e
|
||||||
|
|
||||||
log.debug('signature size: %d bytes', len(sig_bytes))
|
log.debug('signature size: %d bytes', len(sig_bytes))
|
||||||
|
|
||||||
1
libagent/ssh/tests/__init__.py
Normal file
1
libagent/ssh/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Unit-tests for this package."""
|
||||||
@@ -17,12 +17,16 @@ PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
|
|||||||
|
|
||||||
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
|
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def package_name(cls):
|
||||||
|
return 'fake-device-agent'
|
||||||
|
|
||||||
def connect(self): # pylint: disable=no-self-use
|
def connect(self): # pylint: disable=no-self-use
|
||||||
return mock.Mock()
|
return mock.Mock()
|
||||||
|
|
||||||
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
|
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
|
||||||
assert self.conn
|
assert self.conn
|
||||||
return PUBKEY
|
return formats.decompress_pubkey(pubkey=PUBKEY, curve_name=identity.curve_name)
|
||||||
|
|
||||||
def sign(self, identity, blob):
|
def sign(self, identity, blob):
|
||||||
"""Sign given blob and return the signature (as bytes)."""
|
"""Sign given blob and return the signature (as bytes)."""
|
||||||
@@ -45,7 +45,7 @@ def test_unsupported():
|
|||||||
|
|
||||||
|
|
||||||
def ecdsa_signer(identity, blob):
|
def ecdsa_signer(identity, blob):
|
||||||
assert str(identity) == '<ssh://localhost|nist256p1>'
|
assert identity.to_string() == '<ssh://localhost|nist256p1>'
|
||||||
assert blob == NIST256_BLOB
|
assert blob == NIST256_BLOB
|
||||||
return NIST256_SIG
|
return NIST256_SIG
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ def test_sign_missing():
|
|||||||
|
|
||||||
def test_sign_wrong():
|
def test_sign_wrong():
|
||||||
def wrong_signature(identity, blob):
|
def wrong_signature(identity, blob):
|
||||||
assert str(identity) == '<ssh://localhost|nist256p1>'
|
assert identity.to_string() == '<ssh://localhost|nist256p1>'
|
||||||
assert blob == NIST256_BLOB
|
assert blob == NIST256_BLOB
|
||||||
return b'\x00' * 64
|
return b'\x00' * 64
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ ED25519_SIG = b'''\x8eb)\xa6\xe9P\x83VE\xfbq\xc6\xbf\x1dV3\xe3<O\x11\xc0\xfa\xe4
|
|||||||
|
|
||||||
|
|
||||||
def ed25519_signer(identity, blob):
|
def ed25519_signer(identity, blob):
|
||||||
assert str(identity) == '<ssh://localhost|ed25519>'
|
assert identity.to_string() == '<ssh://localhost|ed25519>'
|
||||||
assert blob == ED25519_BLOB
|
assert blob == ED25519_BLOB
|
||||||
return ED25519_SIG
|
return ED25519_SIG
|
||||||
|
|
||||||
7
libagent/tests/test_interface.py
Normal file
7
libagent/tests/test_interface.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from ..device import interface
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode():
|
||||||
|
i = interface.Identity(u'ko\u017eu\u0161\u010dek@host', 'ed25519')
|
||||||
|
assert i.to_bytes() == b'kozuscek@host'
|
||||||
|
assert sorted(i.items()) == [('host', 'host'), ('user', 'kozuscek')]
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import functools
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -8,7 +7,8 @@ import threading
|
|||||||
import mock
|
import mock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .. import protocol, server, util
|
from .. import server, util
|
||||||
|
from ..ssh import protocol
|
||||||
|
|
||||||
|
|
||||||
def test_socket():
|
def test_socket():
|
||||||
@@ -18,7 +18,7 @@ def test_socket():
|
|||||||
assert not os.path.isfile(path)
|
assert not os.path.isfile(path)
|
||||||
|
|
||||||
|
|
||||||
class FakeSocket(object):
|
class FakeSocket:
|
||||||
|
|
||||||
def __init__(self, data=b''):
|
def __init__(self, data=b''):
|
||||||
self.rx = io.BytesIO(data)
|
self.rx = io.BytesIO(data)
|
||||||
@@ -73,26 +73,27 @@ def test_handle():
|
|||||||
|
|
||||||
|
|
||||||
def test_server_thread():
|
def test_server_thread():
|
||||||
connections = [FakeSocket()]
|
sock = FakeSocket()
|
||||||
|
connections = [sock]
|
||||||
quit_event = threading.Event()
|
quit_event = threading.Event()
|
||||||
|
|
||||||
class FakeServer(object):
|
class FakeServer:
|
||||||
def accept(self): # pylint: disable=no-self-use
|
def accept(self): # pylint: disable=no-self-use
|
||||||
if connections:
|
if not connections:
|
||||||
return connections.pop(), 'address'
|
raise socket.timeout()
|
||||||
quit_event.set()
|
return connections.pop(), 'address'
|
||||||
raise socket.timeout()
|
|
||||||
|
|
||||||
def getsockname(self): # pylint: disable=no-self-use
|
def getsockname(self): # pylint: disable=no-self-use
|
||||||
return 'fake_server'
|
return 'fake_server'
|
||||||
|
|
||||||
handler = protocol.Handler(conn=empty_device()),
|
def handle_conn(conn):
|
||||||
handle_conn = functools.partial(server.handle_connection,
|
assert conn is sock
|
||||||
handler=handler,
|
quit_event.set()
|
||||||
mutex=None)
|
|
||||||
server.server_thread(sock=FakeServer(),
|
server.server_thread(sock=FakeServer(),
|
||||||
handle_conn=handle_conn,
|
handle_conn=handle_conn,
|
||||||
quit_event=quit_event)
|
quit_event=quit_event)
|
||||||
|
quit_event.wait()
|
||||||
|
|
||||||
|
|
||||||
def test_spawn():
|
def test_spawn():
|
||||||
@@ -117,12 +118,6 @@ def test_run():
|
|||||||
server.run_process([''], environ={})
|
server.run_process([''], environ={})
|
||||||
|
|
||||||
|
|
||||||
def test_serve_main():
|
|
||||||
handler = protocol.Handler(conn=empty_device())
|
|
||||||
with server.serve(handler=handler, sock_path=None):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove():
|
def test_remove():
|
||||||
path = 'foo.bar'
|
path = 'foo.bar'
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def test_frames():
|
|||||||
assert util.read_frame(io.BytesIO(f)) == b''.join(msgs)
|
assert util.read_frame(io.BytesIO(f)) == b''.join(msgs)
|
||||||
|
|
||||||
|
|
||||||
class FakeSocket(object):
|
class FakeSocket:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.buf = io.BytesIO()
|
self.buf = io.BytesIO()
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ def test_reader():
|
|||||||
|
|
||||||
|
|
||||||
def test_setup_logging():
|
def test_setup_logging():
|
||||||
util.setup_logging(verbosity=10)
|
util.setup_logging(verbosity=10, filename='/dev/null')
|
||||||
|
|
||||||
|
|
||||||
def test_memoize():
|
def test_memoize():
|
||||||
@@ -115,3 +115,32 @@ def test_memoize():
|
|||||||
assert g(1) == g(1)
|
assert g(1) == g(1)
|
||||||
assert g(1) != g(2)
|
assert g(1) != g(2)
|
||||||
assert f.mock_calls == [mock.call(1), mock.call(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 io
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ def hexlify(blob):
|
|||||||
return binascii.hexlify(blob).decode('ascii').upper()
|
return binascii.hexlify(blob).decode('ascii').upper()
|
||||||
|
|
||||||
|
|
||||||
class Reader(object):
|
class Reader:
|
||||||
"""Read basic type objects out of given stream."""
|
"""Read basic type objects out of given stream."""
|
||||||
|
|
||||||
def __init__(self, stream):
|
def __init__(self, stream):
|
||||||
@@ -179,13 +180,22 @@ class Reader(object):
|
|||||||
self._captured = None
|
self._captured = None
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(verbosity, **kwargs):
|
def setup_logging(verbosity, filename=None):
|
||||||
"""Configure logging for this tool."""
|
"""Configure logging for this tool."""
|
||||||
fmt = ('%(asctime)s %(levelname)-12s %(message)-100s '
|
|
||||||
'[%(filename)s:%(lineno)d]')
|
|
||||||
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||||
level = levels[min(verbosity, len(levels) - 1)]
|
level = levels[min(verbosity, len(levels) - 1)]
|
||||||
logging.basicConfig(format=fmt, level=level, **kwargs)
|
logging.root.setLevel(level)
|
||||||
|
|
||||||
|
fmt = logging.Formatter('%(asctime)s %(levelname)-12s %(message)-100s '
|
||||||
|
'[%(filename)s:%(lineno)d]')
|
||||||
|
hdlr = logging.StreamHandler() # stderr
|
||||||
|
hdlr.setFormatter(fmt)
|
||||||
|
logging.root.addHandler(hdlr)
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
hdlr = logging.FileHandler(filename, 'a')
|
||||||
|
hdlr.setFormatter(fmt)
|
||||||
|
logging.root.addHandler(hdlr)
|
||||||
|
|
||||||
|
|
||||||
def memoize(func):
|
def memoize(func):
|
||||||
@@ -204,3 +214,67 @@ def memoize(func):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
return wrapper
|
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."""
|
||||||
|
try:
|
||||||
|
# For Python 3
|
||||||
|
from shutil import which as _which
|
||||||
|
except ImportError:
|
||||||
|
# For Python 2
|
||||||
|
from backports.shutil_which import which as _which
|
||||||
|
full_path = _which(cmd)
|
||||||
|
if full_path is None:
|
||||||
|
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
|
||||||
|
|||||||
6
release.sh
Executable file
6
release.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -eux
|
||||||
|
rm -rv dist/*
|
||||||
|
python3 setup.py sdist
|
||||||
|
gpg2 -v --detach-sign -a dist/*.tar.gz
|
||||||
|
twine upload dist/*
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
gpg2 --version >/dev/null # verify that GnuPG 2 is installed
|
|
||||||
|
|
||||||
USER_ID="${1}"
|
|
||||||
DEVICE=${DEVICE:="trezor"} # or "ledger"
|
|
||||||
CURVE=${CURVE:="nist256p1"} # or "ed25519"
|
|
||||||
TIMESTAMP=${TIMESTAMP:=`date +%s`} # key creation timestamp
|
|
||||||
HOMEDIR=~/.gnupg/${DEVICE}
|
|
||||||
|
|
||||||
# Prepare new GPG home directory for hardware-based identity
|
|
||||||
rm -rf "${HOMEDIR}"
|
|
||||||
mkdir -p "${HOMEDIR}"
|
|
||||||
chmod 700 "${HOMEDIR}"
|
|
||||||
|
|
||||||
# Generate new GPG identity and import into GPG keyring
|
|
||||||
$DEVICE-gpg create -v "${USER_ID}" -t "${TIMESTAMP}" -e "${CURVE}" > "${HOMEDIR}/pubkey.asc"
|
|
||||||
gpg2 --homedir "${HOMEDIR}" --import < "${HOMEDIR}/pubkey.asc" 2> /dev/null
|
|
||||||
rm -f "${HOMEDIR}/S.gpg-agent" # (otherwise, our agent won't be started automatically)
|
|
||||||
|
|
||||||
# Make new GPG identity with "ultimate" trust (via its fingerprint)
|
|
||||||
FINGERPRINT=$(gpg2 --homedir "${HOMEDIR}" --list-public-keys --with-fingerprint --with-colons | sed -n -E 's/^fpr:::::::::([0-9A-F]+):$/\1/p' | head -n1)
|
|
||||||
echo "${FINGERPRINT}:6" | gpg2 --homedir "${HOMEDIR}" --import-ownertrust 2> /dev/null
|
|
||||||
|
|
||||||
AGENT_PATH="$(which ${DEVICE}-gpg-agent)"
|
|
||||||
|
|
||||||
# Prepare GPG configuration file
|
|
||||||
echo "# Hardware-based GPG configuration
|
|
||||||
agent-program ${AGENT_PATH}
|
|
||||||
personal-digest-preferences SHA512
|
|
||||||
" > "${HOMEDIR}/gpg.conf"
|
|
||||||
|
|
||||||
# Prepare GPG agent configuration file
|
|
||||||
echo "# Hardware-based GPG agent emulator
|
|
||||||
log-file ${HOMEDIR}/gpg-agent.log
|
|
||||||
verbosity 2
|
|
||||||
" > "${HOMEDIR}/gpg-agent.conf"
|
|
||||||
|
|
||||||
# Prepare a helper script for setting up the new identity
|
|
||||||
echo "#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
export GNUPGHOME=${HOMEDIR}
|
|
||||||
COMMAND=\$*
|
|
||||||
if [ -z \"\${COMMAND}\" ]
|
|
||||||
then
|
|
||||||
\${SHELL}
|
|
||||||
else
|
|
||||||
\${COMMAND}
|
|
||||||
fi
|
|
||||||
" > "${HOMEDIR}/env"
|
|
||||||
chmod u+x "${HOMEDIR}/env"
|
|
||||||
|
|
||||||
# Load agent and make sure it responds with the new identity
|
|
||||||
GNUPGHOME="$HOMEDIR" gpg2 -K 2> /dev/null
|
|
||||||
27
setup.py
27
setup.py
@@ -3,13 +3,30 @@ from setuptools import setup
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='libagent',
|
name='libagent',
|
||||||
version='0.9.0',
|
version='0.14.2',
|
||||||
description='Using hardware wallets as SSH/GPG agent',
|
description='Using hardware wallets as SSH/GPG agent',
|
||||||
author='Roman Zeyde',
|
author='Roman Zeyde',
|
||||||
author_email='roman.zeyde@gmail.com',
|
author_email='roman.zeyde@gmail.com',
|
||||||
url='http://github.com/romanz/trezor-agent',
|
url='http://github.com/romanz/trezor-agent',
|
||||||
packages=['libagent', 'libagent.device', 'libagent.gpg', 'libagent.ssh'],
|
packages=[
|
||||||
install_requires=['ecdsa>=0.13', 'ed25519>=1.4', 'semver>=2.2'],
|
'libagent',
|
||||||
|
'libagent.device',
|
||||||
|
'libagent.gpg',
|
||||||
|
'libagent.ssh'
|
||||||
|
],
|
||||||
|
install_requires=[
|
||||||
|
'docutils>=0.14',
|
||||||
|
'wheel>=0.32.3',
|
||||||
|
'backports.shutil_which>=3.5.1',
|
||||||
|
'ConfigArgParse>=0.12.1',
|
||||||
|
'python-daemon>=2.1.2',
|
||||||
|
'ecdsa>=0.13',
|
||||||
|
'pynacl>=1.4.0',
|
||||||
|
'mnemonic>=0.18',
|
||||||
|
'pymsgbox>=1.0.6',
|
||||||
|
'semver>=2.2',
|
||||||
|
'unidecode>=0.4.20',
|
||||||
|
],
|
||||||
platforms=['POSIX'],
|
platforms=['POSIX'],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
@@ -19,9 +36,7 @@ setup(
|
|||||||
'Intended Audience :: System Administrators',
|
'Intended Audience :: System Administrators',
|
||||||
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
|
||||||
'Operating System :: POSIX',
|
'Operating System :: POSIX',
|
||||||
'Programming Language :: Python :: 2.7',
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
'Programming Language :: Python :: 3.4',
|
|
||||||
'Programming Language :: Python :: 3.5',
|
|
||||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
'Topic :: System :: Networking',
|
'Topic :: System :: Networking',
|
||||||
'Topic :: Communications',
|
'Topic :: Communications',
|
||||||
|
|||||||
12
tox.ini
12
tox.ini
@@ -1,6 +1,6 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py27,py3
|
envlist = py3
|
||||||
[pep8]
|
[pycodestyle]
|
||||||
max-line-length = 100
|
max-line-length = 100
|
||||||
[pep257]
|
[pep257]
|
||||||
add-ignore = D401
|
add-ignore = D401
|
||||||
@@ -8,15 +8,15 @@ add-ignore = D401
|
|||||||
deps=
|
deps=
|
||||||
pytest
|
pytest
|
||||||
mock
|
mock
|
||||||
pep8
|
pycodestyle
|
||||||
coverage
|
coverage
|
||||||
pylint
|
pylint
|
||||||
semver
|
semver
|
||||||
pydocstyle
|
pydocstyle
|
||||||
isort
|
isort<5
|
||||||
commands=
|
commands=
|
||||||
pep8 libagent
|
pycodestyle libagent
|
||||||
isort --skip-glob .tox -c -r libagent
|
# isort --skip-glob .tox -c -r libagent
|
||||||
pylint --reports=no --rcfile .pylintrc libagent
|
pylint --reports=no --rcfile .pylintrc libagent
|
||||||
pydocstyle libagent
|
pydocstyle libagent
|
||||||
coverage run --source libagent -m py.test -v libagent
|
coverage run --source libagent -m py.test -v libagent
|
||||||
|
|||||||
Reference in New Issue
Block a user