mirror of
https://github.com/romanz/amodem.git
synced 2026-05-03 08:27:26 +08:00
Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7d9b3ff1d0 | ||
|
|
4af881b3cb | ||
|
|
eb525e1b62 | ||
|
|
02c8e729b7 | ||
|
|
12359938ad | ||
|
|
93cd3e688b | ||
|
|
26d7dd3124 | ||
|
|
0d5c3a9ca7 | ||
|
|
97ec6b2719 | ||
|
|
8ba9be1780 | ||
|
|
b2bc87c0c7 | ||
|
|
d522d148ef | ||
|
|
c796a3b01d | ||
|
|
a3362bbf3e | ||
|
|
9a271d115b | ||
|
|
6a7165298f | ||
|
|
c4f3fa6e04 | ||
|
|
8a77fa519f | ||
|
|
59560ec0b0 | ||
|
|
7a91196dd5 | ||
|
|
43c424a402 | ||
|
|
6672ea9bc4 | ||
|
|
002dc2a0e0 | ||
|
|
61ced2808f | ||
|
|
71a8930021 | ||
|
|
74e8f21a22 | ||
|
|
897236d556 | ||
|
|
5bec0e8382 | ||
|
|
3cb7f6fd21 | ||
|
|
cad2ec1239 | ||
|
|
604b2b7e99 | ||
|
|
159bd79b5f | ||
|
|
dde0b60e83 | ||
|
|
109bb3b47f | ||
|
|
0f20bfa239 | ||
|
|
798597c436 | ||
|
|
a13b1103f7 | ||
|
|
9fe1a235c1 | ||
|
|
f86aae9a40 | ||
|
|
fc070e3ca0 | ||
|
|
05fac995eb | ||
|
|
188b74b327 | ||
|
|
fc31847f8e | ||
|
|
0faf21a102 | ||
|
|
6b82f8b9b7 | ||
|
|
fabfcaaae2 | ||
|
|
f0f89310ac | ||
|
|
47ff7c5cb3 | ||
|
|
0440025083 | ||
|
|
c49fe97f63 | ||
|
|
7f8abcb5c5 | ||
|
|
e13039e52d | ||
|
|
c420571eb8 | ||
|
|
827119a18d | ||
|
|
9be6504658 | ||
|
|
07cbe65875 | ||
|
|
180120e787 | ||
|
|
f4ce81fa94 | ||
|
|
176bf4ef7c | ||
|
|
d22cd7512d | ||
|
|
83f17704cb | ||
|
|
92f6751ccb | ||
|
|
abe80533eb | ||
|
|
de51665c71 | ||
|
|
c30e5f5a67 | ||
|
|
2eab2a152c | ||
|
|
5e93d97be3 | ||
|
|
4c8fcd6714 | ||
|
|
ee593bc66e | ||
|
|
dbed773e54 | ||
|
|
ac4a86d312 | ||
|
|
021831073e | ||
|
|
6a5acba0b0 | ||
|
|
9123cef810 | ||
|
|
6f6e7c0bcc | ||
|
|
47ff081525 | ||
|
|
6d53baafe2 | ||
|
|
317b672add | ||
|
|
9964c200ff | ||
|
|
75405b4944 | ||
|
|
e74b9c77af | ||
|
|
c2158947c8 | ||
|
|
e39d5025d5 | ||
|
|
efdb9fcfb5 | ||
|
|
a20b1ed2a8 | ||
|
|
ca507126d6 | ||
|
|
0f79b5ff2e | ||
|
|
946ab633d4 | ||
|
|
4108c9287f | ||
|
|
d9cb75e95d | ||
|
|
2cecd2ed08 | ||
|
|
05f40085b2 | ||
|
|
c7346d621d | ||
|
|
0342b39465 | ||
|
|
fa6d8564b9 | ||
|
|
e09712c793 | ||
|
|
0cbb3bb9fa | ||
|
|
d7a6641ffa | ||
|
|
6fe89241c4 | ||
|
|
c5262d075b | ||
|
|
683d24f4eb | ||
|
|
921e2954c1 | ||
|
|
3f784289d8 | ||
|
|
04d790767d | ||
|
|
97efdf4a45 | ||
|
|
ee2f6b75dc | ||
|
|
a26f0ea034 | ||
|
|
a68f1e5c26 | ||
|
|
93e3c66a15 | ||
|
|
44eaaa6b9c | ||
|
|
b83d4960e7 | ||
|
|
75fe7b4e05 | ||
|
|
742136b22d | ||
|
|
513e99dd57 | ||
|
|
1bd6775c35 | ||
|
|
aaade1737f | ||
|
|
fe185c190e | ||
|
|
1bc0165368 | ||
|
|
0f841ffbc4 | ||
|
|
b2942035a3 | ||
|
|
215b64f253 | ||
|
|
79e68b29c2 | ||
|
|
8265515641 | ||
|
|
749799845d | ||
|
|
eaea35003e | ||
|
|
eefb38ce83 | ||
|
|
0730eb7223 | ||
|
|
5b61702205 | ||
|
|
0ad0ca3b9a | ||
|
|
2843cdcf41 | ||
|
|
c7bc78ebe7 | ||
|
|
a6d9edcb0b | ||
|
|
bc64205a85 | ||
|
|
34dc803856 | ||
|
|
f7ebb02799 | ||
|
|
0ba33a5bc4 | ||
|
|
13752ddcd5 | ||
|
|
487a8e56c4 | ||
|
|
ef56ee4602 | ||
|
|
ae381a38e5 | ||
|
|
446ec99bf4 | ||
|
|
80c6f10533 | ||
|
|
ff984c60e4 | ||
|
|
c9bc079dc9 | ||
|
|
65d2c04478 | ||
|
|
2d57bf4453 | ||
|
|
79b6d31dfe | ||
|
|
7de88a3980 | ||
|
|
6f8d0df116 | ||
|
|
b4a382d22e | ||
|
|
d236f4667e | ||
|
|
42813ddbb4 | ||
|
|
8f19690943 | ||
|
|
5047805385 | ||
|
|
915b326da7 | ||
|
|
e7b8379a97 | ||
|
|
26435130d7 |
7
.bumpversion.cfg
Normal file
7
.bumpversion.cfg
Normal file
@@ -0,0 +1,7 @@
|
||||
[bumpversion]
|
||||
commit = True
|
||||
tag = True
|
||||
current_version = 0.9.6
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=fixme, invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking
|
||||
disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return
|
||||
|
||||
[SIMILARITIES]
|
||||
min-similarity-lines=5
|
||||
|
||||
21
.travis.yml
21
.travis.yml
@@ -4,16 +4,25 @@ python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
|
||||
before_install:
|
||||
- pip install -U pip wheel
|
||||
- pip install -U setuptools
|
||||
- pip install -U pylint coverage pycodestyle pydocstyle
|
||||
|
||||
install:
|
||||
- pip install ecdsa ed25519 semver # test without trezorlib for now
|
||||
- pip install -U pylint coverage pep8 pydocstyle # use latest tools
|
||||
- pip install -U -e .
|
||||
|
||||
script:
|
||||
- pep8 trezor_agent
|
||||
- pylint --reports=no --rcfile .pylintrc trezor_agent
|
||||
- pydocstyle trezor_agent
|
||||
- coverage run --source trezor_agent/ -m py.test -v
|
||||
- pycodestyle libagent
|
||||
- pylint --reports=no --rcfile .pylintrc libagent
|
||||
- pydocstyle libagent
|
||||
- coverage run --source libagent/ -m py.test -v
|
||||
|
||||
after_success:
|
||||
- coverage report
|
||||
|
||||
104
README-GPG.md
104
README-GPG.md
@@ -1,104 +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+ [installed](https://gist.github.com/vt0r/a2f8c0bcb1400131ff51):
|
||||
|
||||
```
|
||||
$ gpg2 --version | head -n1
|
||||
gpg (GnuPG) 2.1.11
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Define your GPG user ID as an environment variable:
|
||||
```
|
||||
$ export TREZOR_GPG_USER_ID="John Doe <john@doe.bit>"
|
||||
```
|
||||
|
||||
There are two ways to generate TREZOR-based GPG public keys, as described below.
|
||||
|
||||
## 1. generate a new GPG identity:
|
||||
|
||||
```
|
||||
$ trezor-gpg create | gpg2 --import # use the TREZOR to confirm signing the primary key
|
||||
gpg: key 5E4D684D: public key "John Doe <john@doe.bit>" imported
|
||||
gpg: Total number processed: 1
|
||||
gpg: imported: 1
|
||||
|
||||
$ gpg2 --edit "${TREZOR_GPG_USER_ID}" trust # set this key to ultimate trust (option #5)
|
||||
|
||||
$ gpg2 -k
|
||||
/home/roman/.gnupg/pubring.kbx
|
||||
------------------------------
|
||||
pub nistp256/5E4D684D 2016-06-17 [SC]
|
||||
uid [ultimate] John Doe <john@doe.bit>
|
||||
sub nistp256/A31D9E25 2016-06-17 [E]
|
||||
```
|
||||
|
||||
## 2. generate a new subkey for an existing GPG identity:
|
||||
|
||||
```
|
||||
$ gpg2 -k # suppose there is already a GPG primary key
|
||||
/home/roman/.gnupg/pubring.kbx
|
||||
------------------------------
|
||||
pub rsa2048/87BB07B4 2016-06-17 [SC]
|
||||
uid [ultimate] John Doe <john@doe.bit>
|
||||
sub rsa2048/7176D31F 2016-06-17 [E]
|
||||
|
||||
$ trezor-gpg create --subkey | gpg2 --import # use the TREZOR to confirm signing the subkey
|
||||
gpg: key 87BB07B4: "John Doe <john@doe.bit>" 2 new signatures
|
||||
gpg: key 87BB07B4: "John Doe <john@doe.bit>" 2 new subkeys
|
||||
gpg: Total number processed: 1
|
||||
gpg: new subkeys: 2
|
||||
gpg: new signatures: 2
|
||||
|
||||
$ gpg2 -k
|
||||
/home/roman/.gnupg/pubring.kbx
|
||||
------------------------------
|
||||
pub rsa2048/87BB07B4 2016-06-17 [SC]
|
||||
uid [ultimate] John Doe <john@doe.bit>
|
||||
sub rsa2048/7176D31F 2016-06-17 [E]
|
||||
sub nistp256/DDE80B36 2016-06-17 [S]
|
||||
sub nistp256/E3D0BA19 2016-06-17 [E]
|
||||
```
|
||||
|
||||
# Usage examples:
|
||||
|
||||
## Start the TREZOR-based gpg-agent:
|
||||
```
|
||||
$ trezor-gpg agent &
|
||||
```
|
||||
Note: this agent intercepts all GPG requests, so make sure to close it (e.g. by using `killall trezor-gpg`),
|
||||
when you are done with the TREZOR-based GPG operations.
|
||||
|
||||
## Sign and verify GPG messages:
|
||||
```
|
||||
$ echo "Hello World!" | gpg2 --sign | gpg2 --verify
|
||||
gpg: Signature made Fri 17 Jun 2016 08:55:13 PM IDT using ECDSA key ID 5E4D684D
|
||||
gpg: Good signature from "Roman Zeyde <roman.zeyde@gmail.com>" [ultimate]
|
||||
```
|
||||
## Encrypt and decrypt GPG messages:
|
||||
```
|
||||
$ date | gpg2 --encrypt -r "${TREZOR_GPG_USER_ID}" | gpg2 --decrypt
|
||||
gpg: encrypted with 256-bit ECDH key, ID A31D9E25, created 2016-06-17
|
||||
"Roman Zeyde <roman.zeyde@gmail.com>"
|
||||
Fri Jun 17 20:55:31 IDT 2016
|
||||
```
|
||||
|
||||
## 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 gpg2
|
||||
$ git commit --gpg-sign # create GPG-signed commit
|
||||
$ git log --show-signature -1 # verify commit signature
|
||||
$ git tag --sign "TAG" # create GPG-signed tag
|
||||
$ git verify-tag "TAG" # verify tag signature
|
||||
```
|
||||
64
README.md
64
README.md
@@ -1,71 +1,25 @@
|
||||
# Using TREZOR as a hardware SSH/GPG agent
|
||||
|
||||
[](https://travis-ci.org/romanz/trezor-agent)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://pypi.python.org/pypi/trezor_agent/)
|
||||
[](https://gitter.im/romanz/trezor-agent)
|
||||
|
||||
See SatoshiLabs' blog posts about this feature:
|
||||
|
||||
- [TREZOR Firmware 1.3.4 enables SSH login](https://medium.com/@satoshilabs/trezor-firmware-1-3-4-enables-ssh-login-86a622d7e609)
|
||||
- [TREZOR Firmware 1.3.6 — GPG Signing, SSH Login Updates and Advanced Transaction Features for Segwit](https://medium.com/@satoshilabs/trezor-firmware-1-3-6-20a7df6e692)
|
||||
- [TREZOR Firmware 1.4.0 — GPG decryption support](https://www.reddit.com/r/TREZOR/comments/50h8r9/new_trezor_firmware_fidou2f_and_initial_ethereum/d7420q7/)
|
||||
|
||||
## Installation
|
||||
|
||||
First, make sure that the latest [trezorlib](https://pypi.python.org/pypi/trezor) Python package
|
||||
is installed correctly (at least v0.6.6):
|
||||
|
||||
$ apt-get install python-dev libusb-1.0-0-dev libudev-dev
|
||||
$ pip install Cython trezor
|
||||
$ pip install -U setuptools
|
||||
|
||||
Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
||||
|
||||
$ pip install trezor_agent
|
||||
|
||||
Finally, verify that you are running the latest [TREZOR firmware](https://wallet.mytrezor.com/data/firmware/releases.json) version (at least v1.4.0):
|
||||
|
||||
$ trezorctl get_features | head
|
||||
vendor: "bitcointrezor.com"
|
||||
major_version: 1
|
||||
minor_version: 4
|
||||
patch_version: 0
|
||||
...
|
||||
|
||||
If you have an error regarding `protobuf` imports (after installing it), please see [this issue](https://github.com/romanz/trezor-agent/issues/28).
|
||||
See the [following instructions](doc/INSTALL.md) for the
|
||||
[TREZOR](https://trezor.io/), [Keepkey](https://www.keepkey.com/) and
|
||||
[Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s) devices.
|
||||
|
||||
## Usage
|
||||
|
||||
For SSH, see the [following instructions](README-SSH.md).
|
||||
For SSH, see the [following instructions](doc/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).
|
||||
For GPG, see the [following instructions](doc/README-GPG.md).
|
||||
|
||||
### Entering PIN
|
||||
|
||||
Look at the digits shown on the TREZOR display and enter their positions using this regular numeric keyboard mapping:
|
||||
|
||||
```
|
||||
|7|8|9|
|
||||
|4|5|6|
|
||||
|1|2|3|
|
||||
```
|
||||
|
||||
For example, if your PIN is `1234` and your TREZOR is displaying the following:
|
||||
|
||||
```
|
||||
|3|1|2|
|
||||
|7|5|8|
|
||||
|6|4|9|
|
||||
```
|
||||
|
||||
You have to enter `8972`.
|
||||
|
||||
## 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)
|
||||
See [here](https://github.com/romanz/python-trezor#pin-entering) for PIN entering instructions.
|
||||
|
||||
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',
|
||||
]},
|
||||
)
|
||||
5
agents/keepkey/keepkey_agent.py
Normal file
5
agents/keepkey/keepkey_agent.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device import keepkey
|
||||
|
||||
ssh_agent = lambda: libagent.ssh.main(keepkey.KeepKey)
|
||||
38
agents/keepkey/setup.py
Normal file
38
agents/keepkey/setup.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='keepkey_agent',
|
||||
version='0.9.0',
|
||||
description='Using KeepKey as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['keepkey_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
'keepkey>=0.7.3'
|
||||
],
|
||||
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': [
|
||||
'keepkey-agent = keepkey_agent:ssh_agent',
|
||||
]},
|
||||
)
|
||||
7
agents/ledger/ledger_agent.py
Normal file
7
agents/ledger/ledger_agent.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device.ledger import LedgerNanoS 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/ledger/setup.py
Normal file
40
agents/ledger/setup.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='ledger_agent',
|
||||
version='0.9.0',
|
||||
description='Using Ledger as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['ledger_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
'ledgerblue>=0.1.8'
|
||||
],
|
||||
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': [
|
||||
'ledger-agent = ledger_agent:ssh_agent',
|
||||
'ledger-gpg = ledger_agent:gpg_tool',
|
||||
'ledger-gpg-agent = ledger_agent:gpg_agent',
|
||||
]},
|
||||
)
|
||||
40
agents/trezor/setup.py
Normal file
40
agents/trezor/setup.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='trezor_agent',
|
||||
version='0.9.0',
|
||||
description='Using Trezor as hardware SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
scripts=['trezor_agent.py'],
|
||||
install_requires=[
|
||||
'libagent>=0.9.0',
|
||||
'trezor>=0.7.6'
|
||||
],
|
||||
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': [
|
||||
'trezor-agent = trezor_agent:ssh_agent',
|
||||
'trezor-gpg = trezor_agent:gpg_tool',
|
||||
'trezor-gpg-agent = trezor_agent:gpg_agent',
|
||||
]},
|
||||
)
|
||||
7
agents/trezor/trezor_agent.py
Normal file
7
agents/trezor/trezor_agent.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import libagent.gpg
|
||||
import libagent.ssh
|
||||
from libagent.device.trezor import Trezor as DeviceType
|
||||
|
||||
ssh_agent = lambda: libagent.ssh.main(DeviceType)
|
||||
gpg_tool = lambda: libagent.gpg.main(DeviceType)
|
||||
gpg_agent = lambda: libagent.gpg.run_agent(DeviceType)
|
||||
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, expect in this it relies on [SLIP-0017 : ECDH using deterministic hierarchy][3] for the mapping to an ECDH key and it maps these to the normal GPG child key infrastructure.
|
||||
|
||||
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
|
||||
88
doc/INSTALL.md
Normal file
88
doc/INSTALL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Installation
|
||||
|
||||
Install the following packages (depending on your distribution):
|
||||
|
||||
## Install dependencies
|
||||
|
||||
### Debian
|
||||
|
||||
$ apt update && apt upgrade
|
||||
$ apt install python-pip python-dev libusb-1.0-0-dev libudev-dev
|
||||
|
||||
### Fedora/RedHat
|
||||
|
||||
$ yum update
|
||||
$ yum install python-pip python-devel libusb-devel libudev-devel \
|
||||
gcc redhat-rpm-config
|
||||
|
||||
### OpenSUSE
|
||||
|
||||
$ zypper install python-pip python-devel 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 libusb-1_0-devel libudev-devel
|
||||
|
||||
## Update setuptools and pip
|
||||
|
||||
Also, update Python packages before starting the installation:
|
||||
|
||||
$ pip install -U setuptools pip
|
||||
|
||||
## Check device's firmware version
|
||||
|
||||
Make sure you are running the latest firmware version on your hardware device.
|
||||
Currently the following firmware versions are supported:
|
||||
|
||||
* [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+` (install [SSH/PGP Agent](https://www.ledgerwallet.com/images/apps/chrome-mngr-apps.png) app)
|
||||
|
||||
## TREZOR
|
||||
|
||||
Make sure that your `udev` rules are configured [correctly](https://doc.satoshilabs.com/trezor-user/settingupchromeonlinux.html#manual-configuration-of-udev-rules).
|
||||
Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package:
|
||||
|
||||
$ pip install trezor_agent
|
||||
|
||||
Or, directly from the latest source code:
|
||||
|
||||
$ git clone https://github.com/romanz/trezor-agent
|
||||
$ pip install --user -e trezor-agent/agents/trezor
|
||||
|
||||
## 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
|
||||
|
||||
## 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).
|
||||
175
doc/README-GPG.md
Normal file
175
doc/README-GPG.md
Normal file
@@ -0,0 +1,175 @@
|
||||
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`
|
||||
|
||||
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 device firmware to the latest version and install your specific `agent` package:
|
||||
|
||||
```
|
||||
$ pip install --user (trezor|keepkey|ledger)_agent
|
||||
```
|
||||
|
||||
# Quickstart
|
||||
|
||||
## Identity creation
|
||||
[](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8)
|
||||
|
||||
In order to use specific device type for GPG indentity creation, use either command:
|
||||
```
|
||||
$ trezor-gpg init "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||
$ ledger-gpg init "Roman Zeyde <roman.zeyde@gmail.com>"
|
||||
```
|
||||
|
||||
## Sample usage (signature and decryption)
|
||||
[](https://asciinema.org/a/120441)
|
||||
|
||||
In order to use specific device type for GPG operations, set the following environment variable to either:
|
||||
```
|
||||
$ export GNUPGHOME=~/.gnupg/{trezor,ledger}
|
||||
```
|
||||
|
||||
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
|
||||
$ GNUPGHOME=~/.gnupg/trezor 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 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
|
||||
```
|
||||
|
||||
## Password manager
|
||||
|
||||
First install `pass` from [passwordstore.org](https://www.passwordstore.org/) and initialize it to use your TREZOR-based GPG identity:
|
||||
```
|
||||
$ export GNUPGHOME=~/.gnupg/trezor
|
||||
$ 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
|
||||
$ GNUPGHOME=~/.gnupg/trezor qtpass
|
||||
```
|
||||
|
||||
## Re-generation of an existing 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
|
||||
```
|
||||
|
||||
## Adding new user IDs
|
||||
|
||||
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]
|
||||
```
|
||||
|
||||
## GnuPG subkey generation
|
||||
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
|
||||
```
|
||||
@@ -9,17 +9,20 @@
|
||||
## 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 ssh.hostname.com -v > hostname.pub
|
||||
2015-09-02 15:03:18,929 INFO getting "ssh://ssh.hostname.com" public key from Trezor...
|
||||
/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://ssh.hostname.com
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGSevcDwmT+QaZPUEWUUjTeZRBICChxMKuJ7dRpBSF8+qt+8S1GBK5Zj8Xicc8SHG/SE/EXKUL2UU3kcUzE7ADQ= ssh://user@ssh.hostname.com
|
||||
|
||||
Append `hostname.pub` contents to `~/.ssh/authorized_keys`
|
||||
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.
|
||||
|
||||
@@ -27,9 +30,9 @@ would allow you to login using the corresponding private key signature.
|
||||
|
||||
Run:
|
||||
|
||||
/tmp $ trezor-agent ssh.hostname.com -v -c
|
||||
2015-09-02 15:09:39,782 INFO getting "ssh://ssh.hostname.com" public key from Trezor...
|
||||
2015-09-02 15:09:44,430 INFO please confirm user "roman" login to "ssh://ssh.hostname.com" using Trezor...
|
||||
/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
|
||||
|
||||
@@ -44,7 +47,7 @@ Run:
|
||||
|
||||
Make sure to confirm SSH signature on the Trezor device when requested.
|
||||
|
||||
## Accessing remote Git repositories
|
||||
## 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/)):
|
||||
|
||||
@@ -57,3 +60,28 @@ Use the following Bash alias for convinient Git operations:
|
||||
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
|
||||
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:
|
||||

|
||||
3
libagent/device/__init__.py
Normal file
3
libagent/device/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Cryptographic hardware device management."""
|
||||
|
||||
from . import interface
|
||||
69
libagent/device/fake_device.py
Normal file
69
libagent/device/fake_device.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""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."""
|
||||
|
||||
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 close(self):
|
||||
"""Close connection."""
|
||||
self.conn = None
|
||||
|
||||
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()
|
||||
143
libagent/device/interface.py
Normal file
143
libagent/device/interface.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Device abstraction layer."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
|
||||
import unidecode
|
||||
|
||||
from .. import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_identity_regexp = re.compile(''.join([
|
||||
'^'
|
||||
r'(?:(?P<proto>.*)://)?',
|
||||
r'(?:(?P<user>.*)@)?',
|
||||
r'(?P<host>.*?)',
|
||||
r'(?::(?P<port>\w*))?',
|
||||
r'(?P<path>/.*)?',
|
||||
'$'
|
||||
]))
|
||||
|
||||
|
||||
def string_to_identity(identity_str):
|
||||
"""Parse string into Identity dictionary."""
|
||||
m = _identity_regexp.match(identity_str)
|
||||
result = m.groupdict()
|
||||
log.debug('parsed identity: %s', result)
|
||||
return {k: v for k, v in result.items() if v}
|
||||
|
||||
|
||||
def identity_to_string(identity_dict):
|
||||
"""Dump Identity dictionary into its string representation."""
|
||||
result = []
|
||||
if identity_dict.get('proto'):
|
||||
result.append(identity_dict['proto'] + '://')
|
||||
if identity_dict.get('user'):
|
||||
result.append(identity_dict['user'] + '@')
|
||||
result.append(identity_dict['host'])
|
||||
if identity_dict.get('port'):
|
||||
result.append(':' + identity_dict['port'])
|
||||
if identity_dict.get('path'):
|
||||
result.append(identity_dict['path'])
|
||||
log.debug('identity parts: %s', result)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Device-related error."""
|
||||
|
||||
|
||||
class NotFoundError(Error):
|
||||
"""Device could not be found."""
|
||||
|
||||
|
||||
class DeviceError(Error):
|
||||
"""Error during device operation."""
|
||||
|
||||
|
||||
class Identity(object):
|
||||
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
||||
|
||||
def __init__(self, identity_str, curve_name):
|
||||
"""Configure for specific identity and elliptic curve usage."""
|
||||
self.identity_dict = string_to_identity(identity_str)
|
||||
self.curve_name = curve_name
|
||||
|
||||
def items(self):
|
||||
"""Return a copy of identity_dict items."""
|
||||
return [(k, unidecode.unidecode(v))
|
||||
for k, v in self.identity_dict.items()]
|
||||
|
||||
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 u'<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
|
||||
|
||||
def get_bip32_address(self, ecdh=False):
|
||||
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
||||
index = struct.pack('<L', self.identity_dict.get('index', 0))
|
||||
addr = index + self.to_bytes()
|
||||
log.debug('bip32 address string: %r', addr)
|
||||
digest = hashlib.sha256(addr).digest()
|
||||
s = io.BytesIO(bytearray(digest))
|
||||
|
||||
hardened = 0x80000000
|
||||
addr_0 = 17 if bool(ecdh) else 13
|
||||
address_n = [addr_0] + list(util.recv(s, '<LLLL'))
|
||||
return [(hardened | value) for value in address_n]
|
||||
|
||||
def get_curve_name(self, ecdh=False):
|
||||
"""Return correct curve name for device operations."""
|
||||
if ecdh:
|
||||
return formats.get_ecdh_curve_name(self.curve_name)
|
||||
else:
|
||||
return self.curve_name
|
||||
|
||||
|
||||
class Device(object):
|
||||
"""Abstract cryptographic hardware device interface."""
|
||||
|
||||
def __init__(self):
|
||||
"""C-tor."""
|
||||
self.conn = None
|
||||
|
||||
def connect(self):
|
||||
"""Connect to device, otherwise raise NotFoundError."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow usage as context manager."""
|
||||
self.conn = self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close and mark as disconnected."""
|
||||
try:
|
||||
self.conn.close()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('close failed: %s', e)
|
||||
self.conn = None
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get public key (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __str__(self):
|
||||
"""Human-readable representation."""
|
||||
return '{}'.format(self.__class__.__name__)
|
||||
37
libagent/device/keepkey.py
Normal file
37
libagent/device/keepkey.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""KeepKey-related code (see https://www.keepkey.com/)."""
|
||||
|
||||
from . import trezor
|
||||
from .. import formats
|
||||
|
||||
|
||||
def _verify_support(identity, ecdh):
|
||||
"""Make sure the device supports given configuration."""
|
||||
protocol = identity.identity_dict['proto']
|
||||
if protocol not in {'ssh'}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported protocol: {}'.format(protocol))
|
||||
if ecdh:
|
||||
raise NotImplementedError('No support for ECDH')
|
||||
if identity.curve_name not in {formats.CURVE_NIST256}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported elliptic curve: {}'.format(identity.curve_name))
|
||||
|
||||
|
||||
class KeepKey(trezor.Trezor):
|
||||
"""Connection to KeepKey device."""
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import keepkey_defs
|
||||
return keepkey_defs
|
||||
|
||||
required_version = '>=1.0.4'
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
_verify_support(identity, ecdh)
|
||||
return trezor.Trezor.pubkey(self, identity=identity, ecdh=ecdh)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""No support for ECDH in KeepKey firmware."""
|
||||
_verify_support(identity, ecdh=True)
|
||||
9
libagent/device/keepkey_defs.py
Normal file
9
libagent/device/keepkey_defs.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""KeepKey-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
|
||||
from keepkeylib.client import CallException, PinException
|
||||
from keepkeylib.client import KeepKeyClient as Client
|
||||
from keepkeylib.messages_pb2 import PassphraseAck, PinMatrixAck
|
||||
from keepkeylib.transport_hid import HidTransport as Transport
|
||||
from keepkeylib.types_pb2 import IdentityType
|
||||
117
libagent/device/ledger.py
Normal file
117
libagent/device/ledger.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Ledger-related code (see https://www.ledgerwallet.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from ledgerblue import comm # pylint: disable=import-error
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand_path(path):
|
||||
"""Convert BIP32 path into bytes."""
|
||||
return b''.join((struct.pack('>I', e) for e in path))
|
||||
|
||||
|
||||
def _convert_public_key(ecdsa_curve_name, result):
|
||||
"""Convert Ledger reply into PublicKey object."""
|
||||
if ecdsa_curve_name == 'nist256p1':
|
||||
if (result[64] & 1) != 0:
|
||||
result = bytearray([0x03]) + result[1:33]
|
||||
else:
|
||||
result = bytearray([0x02]) + result[1:33]
|
||||
else:
|
||||
result = result[1:]
|
||||
keyX = bytearray(result[0:32])
|
||||
keyY = bytearray(result[32:][::-1])
|
||||
if (keyX[31] & 1) != 0:
|
||||
keyY[31] |= 0x80
|
||||
result = b'\x00' + bytes(keyY)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
class LedgerNanoS(interface.Device):
|
||||
"""Connection to Ledger Nano S device."""
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
try:
|
||||
return comm.getDongle()
|
||||
except comm.CommException as e:
|
||||
raise interface.NotFoundError(
|
||||
'{} not connected: "{}"'.format(self, e))
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
|
||||
curve_name = identity.get_curve_name(ecdh)
|
||||
path = _expand_path(identity.get_bip32_address(ecdh))
|
||||
if curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800200' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(path) + 1, len(path) // 4])
|
||||
apdu += path
|
||||
log.debug('apdu: %r', apdu)
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
log.debug('result: %r', result)
|
||||
return _convert_public_key(curve_name, result[1:])
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
path = _expand_path(identity.get_bip32_address(ecdh=False))
|
||||
if identity.identity_dict['proto'] == 'ssh':
|
||||
ins = '04'
|
||||
p1 = '00'
|
||||
else:
|
||||
ins = '08'
|
||||
p1 = '00'
|
||||
if identity.curve_name == 'nist256p1':
|
||||
p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01'
|
||||
else:
|
||||
p2 = '82' if identity.identity_dict['proto'] == 'ssh' else '02'
|
||||
apdu = '80' + ins + p1 + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(blob) + len(path) + 1])
|
||||
apdu += bytearray([len(path) // 4]) + path
|
||||
apdu += blob
|
||||
log.debug('apdu: %r', apdu)
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
log.debug('result: %r', result)
|
||||
if identity.curve_name == 'nist256p1':
|
||||
offset = 3
|
||||
length = result[offset]
|
||||
r = result[offset+1:offset+1+length]
|
||||
if r[0] == 0:
|
||||
r = r[1:]
|
||||
offset = offset + 1 + length + 1
|
||||
length = result[offset]
|
||||
s = result[offset+1:offset+1+length]
|
||||
if s[0] == 0:
|
||||
s = s[1:]
|
||||
offset = offset + 1 + length
|
||||
return bytes(r) + bytes(s)
|
||||
else:
|
||||
return bytes(result[:64])
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
path = _expand_path(identity.get_bip32_address(ecdh=True))
|
||||
if identity.curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800a00' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(pubkey) + len(path) + 1])
|
||||
apdu += bytearray([len(path) // 4]) + path
|
||||
apdu += pubkey
|
||||
log.debug('apdu: %r', apdu)
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
log.debug('result: %r', result)
|
||||
assert result[0] == 0x04
|
||||
return bytes(result)
|
||||
181
libagent/device/trezor.py
Normal file
181
libagent/device/trezor.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""TREZOR-related code (see http://bitcointrezor.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import semver
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _message_box(label, sp=subprocess):
|
||||
"""Launch an external process for PIN/passphrase entry GUI."""
|
||||
cmd = ('import sys, pymsgbox; '
|
||||
'sys.stdout.write(pymsgbox.password(sys.stdin.read()))')
|
||||
args = [sys.executable, '-c', cmd]
|
||||
p = sp.Popen(args=args, stdin=sp.PIPE, stdout=sp.PIPE, stderr=sp.PIPE)
|
||||
out, err = p.communicate(label.encode('ascii'))
|
||||
exitcode = p.wait()
|
||||
if exitcode == 0:
|
||||
return out.decode('ascii')
|
||||
else:
|
||||
log.error('UI failed: %r', err)
|
||||
raise sp.CalledProcessError(exitcode, args)
|
||||
|
||||
|
||||
def _is_open_tty(stream):
|
||||
return not stream.closed and os.isatty(stream.fileno())
|
||||
|
||||
|
||||
class Trezor(interface.Device):
|
||||
"""Connection to TREZOR device."""
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
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
|
||||
|
||||
required_version = '>=1.4.0'
|
||||
|
||||
def _override_pin_handler(self, conn):
|
||||
cli_handler = conn.callback_PinMatrixRequest
|
||||
|
||||
def new_handler(msg):
|
||||
if _is_open_tty(sys.stdin):
|
||||
result = cli_handler(msg) # CLI-based PIN handler
|
||||
else:
|
||||
scrambled_pin = _message_box(
|
||||
'Use the numeric keypad to describe number positions.\n'
|
||||
'The layout is:\n'
|
||||
' 7 8 9\n'
|
||||
' 4 5 6\n'
|
||||
' 1 2 3\n'
|
||||
'Please enter PIN:')
|
||||
result = self._defs.PinMatrixAck(pin=scrambled_pin)
|
||||
if not set(result.pin).issubset('123456789'):
|
||||
raise self._defs.PinException(
|
||||
None, 'Invalid scrambled PIN: {!r}'.format(result.pin))
|
||||
return result
|
||||
|
||||
conn.callback_PinMatrixRequest = new_handler
|
||||
|
||||
def _override_passphrase_handler(self, conn):
|
||||
cli_handler = conn.callback_PassphraseRequest
|
||||
|
||||
def new_handler(msg):
|
||||
if _is_open_tty(sys.stdin):
|
||||
return cli_handler(msg) # CLI-based PIN handler
|
||||
|
||||
passphrase = _message_box('Please enter passphrase:')
|
||||
return self._defs.PassphraseAck(passphrase=passphrase)
|
||||
|
||||
conn.callback_PassphraseRequest = new_handler
|
||||
|
||||
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):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
for d in self._defs.Transport.enumerate():
|
||||
log.debug('endpoint: %s', d)
|
||||
transport = self._defs.Transport(d)
|
||||
for _ in range(5):
|
||||
connection = self._defs.Client(transport)
|
||||
self._override_pin_handler(connection)
|
||||
self._override_passphrase_handler(connection)
|
||||
self._verify_version(connection)
|
||||
|
||||
try:
|
||||
connection.ping(msg='', pin_protection=True) # unlock PIN
|
||||
return connection
|
||||
except (self._defs.PinException, ValueError) as e:
|
||||
log.error('Invalid PIN: %s, retrying...', e)
|
||||
continue
|
||||
except Exception as e:
|
||||
log.exception('ping failed: %s', e)
|
||||
connection.close() # so the next HID open() will succeed
|
||||
raise
|
||||
|
||||
raise interface.NotFoundError('{} not connected'.format(self))
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn.close()
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
curve_name = identity.get_curve_name(ecdh=ecdh)
|
||||
log.debug('"%s" getting public key (%s) from %s',
|
||||
identity.to_string(), curve_name, self)
|
||||
addr = identity.get_bip32_address(ecdh=ecdh)
|
||||
result = self.conn.get_public_node(
|
||||
n=addr, ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
return result.node.public_key
|
||||
|
||||
def _identity_proto(self, identity):
|
||||
result = self._defs.IdentityType()
|
||||
for name, value in identity.items():
|
||||
setattr(result, name, value)
|
||||
return result
|
||||
|
||||
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)
|
||||
try:
|
||||
result = self.conn.sign_identity(
|
||||
identity=self._identity_proto(identity),
|
||||
challenge_hidden=blob,
|
||||
challenge_visual='',
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == b'\x00'
|
||||
return result.signature[1:]
|
||||
except self._defs.CallException as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
|
||||
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)
|
||||
try:
|
||||
result = self.conn.get_ecdh_session_key(
|
||||
identity=self._identity_proto(identity),
|
||||
peer_public_key=pubkey,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
||||
assert result.session_key[:1] == b'\x04'
|
||||
return result.session_key
|
||||
except self._defs.CallException as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
10
libagent/device/trezor_defs.py
Normal file
10
libagent/device/trezor_defs.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""TREZOR-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
|
||||
from trezorlib.client import CallException, PinException
|
||||
from trezorlib.client import TrezorClient as Client
|
||||
from trezorlib.messages_pb2 import PassphraseAck, PinMatrixAck
|
||||
from trezorlib.transport_bridge import BridgeTransport
|
||||
from trezorlib.transport_hid import HidTransport
|
||||
from trezorlib.types_pb2 import IdentityType
|
||||
@@ -11,11 +11,15 @@ from . import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Supported ECDSA curves
|
||||
# Supported ECDSA curves (for SSH and GPG)
|
||||
CURVE_NIST256 = 'nist256p1'
|
||||
CURVE_ED25519 = 'ed25519'
|
||||
SUPPORTED_CURVES = {CURVE_NIST256, CURVE_ED25519}
|
||||
|
||||
# Supported ECDH curves (for GPG)
|
||||
ECDH_NIST256 = 'nist256p1'
|
||||
ECDH_CURVE25519 = 'curve25519'
|
||||
|
||||
# SSH key types
|
||||
SSH_NIST256_DER_OCTET = b'\x04'
|
||||
SSH_NIST256_KEY_PREFIX = b'ecdsa-sha2-'
|
||||
@@ -134,7 +138,8 @@ def decompress_pubkey(pubkey, curve_name):
|
||||
if len(pubkey) == 33:
|
||||
decompress = {
|
||||
CURVE_NIST256: _decompress_nist256,
|
||||
CURVE_ED25519: _decompress_ed25519
|
||||
CURVE_ED25519: _decompress_ed25519,
|
||||
ECDH_CURVE25519: _decompress_ed25519,
|
||||
}[curve_name]
|
||||
vk = decompress(pubkey)
|
||||
|
||||
@@ -179,7 +184,7 @@ def export_public_key(vk, label):
|
||||
key_type, blob = serialize_verifying_key(vk)
|
||||
log.debug('fingerprint: %s', fingerprint(blob))
|
||||
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):
|
||||
@@ -188,7 +193,16 @@ def import_public_key(line):
|
||||
file_type, base64blob, name = line.split()
|
||||
blob = base64.b64decode(base64blob)
|
||||
result = parse_pubkey(blob)
|
||||
result['name'] = name.encode('ascii')
|
||||
result['name'] = name.encode('utf-8')
|
||||
assert result['type'] == file_type.encode('ascii')
|
||||
log.debug('loaded %s public key: %s', file_type, result['fingerprint'])
|
||||
return result
|
||||
|
||||
|
||||
def get_ecdh_curve_name(signature_curve_name):
|
||||
"""Return appropriate curve for ECDH for specified signing curve."""
|
||||
return {
|
||||
CURVE_NIST256: ECDH_NIST256,
|
||||
CURVE_ED25519: ECDH_CURVE25519,
|
||||
ECDH_CURVE25519: ECDH_CURVE25519,
|
||||
}[signature_curve_name]
|
||||
255
libagent/gpg/__init__.py
Normal file
255
libagent/gpg/__init__.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
TREZOR support for ECDSA GPG signatures.
|
||||
|
||||
See these links for more details:
|
||||
- https://www.gnupg.org/faq/whats-new-in-2.1.html
|
||||
- https://tools.ietf.org/html/rfc4880
|
||||
- https://tools.ietf.org/html/rfc6637
|
||||
- https://tools.ietf.org/html/draft-irtf-cfrg-eddsa-05
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import semver
|
||||
|
||||
|
||||
from . import agent, client, encode, keyring, protocol
|
||||
from .. import device, formats, server, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def export_public_key(device_type, args):
|
||||
"""Generate a new pubkey for a new/existing GPG identity."""
|
||||
log.warning('NOTE: in order to re-generate the exact same GPG key later, '
|
||||
'run this command with "--time=%d" commandline flag (to set '
|
||||
'the timestamp of the GPG key manually).', args.time)
|
||||
c = client.Client(device=device_type())
|
||||
identity = client.create_identity(user_id=args.user_id,
|
||||
curve_name=args.ecdsa_curve)
|
||||
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
|
||||
log.info('adding %s GPG subkey for "%s" to existing key',
|
||||
args.ecdsa_curve, args.user_id)
|
||||
# subkey for signing
|
||||
signing_key = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
encryption_key = protocol.PublicKey(
|
||||
curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve),
|
||||
created=args.time, verifying_key=decryption_key, ecdh=True)
|
||||
primary_bytes = keyring.export_public_key(args.user_id)
|
||||
result = encode.create_subkey(primary_bytes=primary_bytes,
|
||||
subkey=signing_key,
|
||||
signer_func=signer_func)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
subkey=encryption_key,
|
||||
signer_func=signer_func)
|
||||
else: # add as primary
|
||||
log.info('creating new %s GPG primary key for "%s"',
|
||||
args.ecdsa_curve, args.user_id)
|
||||
# primary key for signing
|
||||
primary = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
subkey = protocol.PublicKey(
|
||||
curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve),
|
||||
created=args.time, verifying_key=decryption_key, ecdh=True)
|
||||
|
||||
result = encode.create_primary(user_id=args.user_id,
|
||||
pubkey=primary,
|
||||
signer_func=signer_func)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
subkey=subkey,
|
||||
signer_func=signer_func)
|
||||
|
||||
return protocol.armor(result, 'PUBLIC KEY BLOCK')
|
||||
|
||||
|
||||
def verify_gpg_version():
|
||||
"""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)
|
||||
assert semver.match(existing_gpg, required_gpg), 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)
|
||||
log.warning('This GPG tool is still in EXPERIMENTAL mode, '
|
||||
'so please note that the API and features may '
|
||||
'change without backwards compatibility!')
|
||||
|
||||
verify_gpg_version()
|
||||
|
||||
# Prepare new GPG home directory for hardware-based identity
|
||||
device_name = os.path.basename(sys.argv[0]).rsplit('-', 1)[0]
|
||||
log.info('device name: %s', device_name)
|
||||
homedir = os.path.expanduser('~/.gnupg/{}'.format(device_name))
|
||||
log.info('GPG home directory: %s', homedir)
|
||||
|
||||
check_call(['rm', '-rf', homedir])
|
||||
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("""#!/bin/sh
|
||||
export PATH={0}
|
||||
{1} $*
|
||||
""".format(os.environ['PATH'], agent_path))
|
||||
check_call(['chmod', 'u+x', 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 GPG agent configuration file
|
||||
with open(os.path.join(homedir, 'gpg-agent.conf'), 'w') as f:
|
||||
f.write("""# Hardware-based GPG agent emulator
|
||||
log-file {0}/gpg-agent.log
|
||||
verbosity 2
|
||||
""".format(homedir))
|
||||
|
||||
# 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', 'u+x', 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))
|
||||
gpg_binary = keyring.get_gnupg_binary()
|
||||
check_call([gpg_binary, '--homedir', homedir, '--quiet',
|
||||
'--import', pubkey.name])
|
||||
|
||||
# Make new GPG identity with "ultimate" trust (via its fingerprint)
|
||||
out = check_output([gpg_binary, '--homedir', homedir, '--list-public-keys',
|
||||
'--with-fingerprint', '--with-colons'])
|
||||
fpr = re.findall('fpr:::::::::([0-9A-F]+):', out)[0]
|
||||
f = write_file(os.path.join(homedir, 'ownertrust.txt'), fpr + ':6\n')
|
||||
check_call([gpg_binary, '--homedir', homedir,
|
||||
'--import-ownertrust', f.name])
|
||||
|
||||
# Load agent and make sure it responds with the new identity
|
||||
check_call([gpg_binary, '--list-secret-keys'], env={'GNUPGHOME': homedir})
|
||||
|
||||
|
||||
def run_unlock(device_type, args):
|
||||
"""Unlock hardware device (for future interaction)."""
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
with device_type() as d:
|
||||
log.info('unlocked %s device', d)
|
||||
|
||||
|
||||
def run_agent(device_type):
|
||||
"""Run a simple GPG-agent server."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--homedir', default=os.environ.get('GNUPGHOME'))
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
assert args.homedir
|
||||
config_file = os.path.join(args.homedir, 'gpg-agent.conf')
|
||||
|
||||
lines = (line.strip() for line in open(config_file))
|
||||
lines = (line for line in lines if line and not line.startswith('#'))
|
||||
config = dict(line.split(' ', 1) for line in lines)
|
||||
|
||||
util.setup_logging(verbosity=int(config['verbosity']),
|
||||
filename=config['log-file'])
|
||||
log.debug('sys.argv: %s', sys.argv)
|
||||
log.debug('os.environ: %s', os.environ)
|
||||
log.debug('pid: %d, parent pid: %d', os.getpid(), os.getppid())
|
||||
try:
|
||||
env = {'GNUPGHOME': args.homedir}
|
||||
sock_path = keyring.get_agent_sock_path(env=env)
|
||||
pubkey_bytes = keyring.export_public_keys(env=env)
|
||||
handler = agent.Handler(device=device_type(), pubkey_bytes=pubkey_bytes)
|
||||
with server.unix_domain_socket_server(sock_path) 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 Exception as e: # pylint: disable=broad-except
|
||||
log.exception('handler failed: %s', e)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('gpg-agent failed: %s', e)
|
||||
|
||||
|
||||
def main(device_type):
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
p = subparsers.add_parser('init',
|
||||
help='Initialize hardware-based GnuPG identity')
|
||||
p.add_argument('user_id')
|
||||
p.add_argument('-e', '--ecdsa-curve', default='nist256p1')
|
||||
p.add_argument('-t', '--time', type=int, default=int(time.time()))
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
p.add_argument('-s', '--subkey', default=False, action='store_true')
|
||||
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.set_defaults(func=run_unlock)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args.func(device_type=device_type, args=args)
|
||||
215
libagent/gpg/agent.py
Normal file
215
libagent/gpg/agent.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""GPG-agent utilities."""
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from . import client, decode, keyring, protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def yield_connections(sock):
|
||||
"""Run a server on the specified socket."""
|
||||
while True:
|
||||
log.debug('waiting for connection on %s', sock.getsockname())
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
conn.settimeout(None)
|
||||
log.debug('accepted connection on %s', sock.getsockname())
|
||||
yield conn
|
||||
|
||||
|
||||
def serialize(data):
|
||||
"""Serialize data according to ASSUAN protocol."""
|
||||
for c in [b'%', b'\n', b'\r']:
|
||||
escaped = '%{:02X}'.format(ord(c)).encode('ascii')
|
||||
data = data.replace(c, escaped)
|
||||
return data
|
||||
|
||||
|
||||
def sig_encode(r, s):
|
||||
"""Serialize ECDSA signature data into GPG S-expression."""
|
||||
r = serialize(util.num2bytes(r, 32))
|
||||
s = serialize(util.num2bytes(s, 32))
|
||||
return b'(7:sig-val(5:ecdsa(1:r32:' + r + b')(1:s32:' + s + b')))'
|
||||
|
||||
|
||||
def _serialize_point(data):
|
||||
prefix = '{}:'.format(len(data)).encode('ascii')
|
||||
# https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html
|
||||
return b'(5:value' + serialize(prefix + data) + b')'
|
||||
|
||||
|
||||
def parse_ecdh(line):
|
||||
"""Parse ECDH request and return remote public key."""
|
||||
prefix, line = line.split(b' ', 1)
|
||||
assert prefix == b'D'
|
||||
exp, leftover = keyring.parse(keyring.unescape(line))
|
||||
log.debug('ECDH s-exp: %r', exp)
|
||||
assert not leftover
|
||||
label, exp = exp
|
||||
assert label == b'enc-val'
|
||||
assert exp[0] == b'ecdh'
|
||||
items = exp[1:]
|
||||
log.debug('ECDH parameters: %r', items)
|
||||
return dict(items)[b'e']
|
||||
|
||||
|
||||
def _key_info(conn, args):
|
||||
"""
|
||||
Dummy reply (mainly for 'gpg --edit' to succeed).
|
||||
|
||||
For details, see GnuPG agent KEYINFO command help.
|
||||
https://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob;f=agent/command.c;h=c8b34e9882076b1b724346787781f657cac75499;hb=refs/heads/master#l1082
|
||||
"""
|
||||
fmt = 'S KEYINFO {0} X - - - - - - -'
|
||||
keygrip, = args
|
||||
keyring.sendline(conn, fmt.format(keygrip).encode('ascii'))
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
"""GnuPG agent-related error."""
|
||||
|
||||
|
||||
class AgentStop(Exception):
|
||||
"""Raised to close the agent."""
|
||||
|
||||
|
||||
class Handler(object):
|
||||
"""GPG agent requests' handler."""
|
||||
|
||||
def __init__(self, device, pubkey_bytes):
|
||||
"""C-tor."""
|
||||
self.client = client.Client(device=device)
|
||||
# Cache ASSUAN commands' arguments between commands
|
||||
self.keygrip = None
|
||||
self.digest = None
|
||||
self.algo = None
|
||||
# Cache public keys from GnuPG
|
||||
self.pubkey_bytes = pubkey_bytes
|
||||
# "Clone" existing GPG version
|
||||
self.version = keyring.gpg_version()
|
||||
|
||||
self.handlers = {
|
||||
b'RESET': None,
|
||||
b'OPTION': None,
|
||||
b'SETKEYDESC': None,
|
||||
b'NOP': None,
|
||||
b'GETINFO': lambda conn, _: keyring.sendline(conn, b'D ' + self.version),
|
||||
b'AGENT_ID': lambda conn, _: keyring.sendline(conn, b'D TREZOR'), # "Fake" agent ID
|
||||
b'SIGKEY': lambda _, args: self.set_key(*args),
|
||||
b'SETKEY': lambda _, args: self.set_key(*args),
|
||||
b'SETHASH': lambda _, args: self.set_hash(*args),
|
||||
b'PKSIGN': lambda conn, _: self.pksign(conn),
|
||||
b'PKDECRYPT': lambda conn, _: self.pkdecrypt(conn),
|
||||
b'HAVEKEY': lambda _, args: self.have_key(*args),
|
||||
b'KEYINFO': _key_info,
|
||||
b'SCD': self.handle_scd,
|
||||
}
|
||||
|
||||
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
|
||||
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))
|
||||
|
||||
@util.memoize
|
||||
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')
|
||||
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')
|
||||
48
libagent/gpg/client.py
Normal file
48
libagent/gpg/client.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Device abstraction layer for GPG operations."""
|
||||
|
||||
import logging
|
||||
|
||||
from .. import formats, util
|
||||
from ..device import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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(object):
|
||||
"""Sign messages and get public keys from a hardware device."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""C-tor."""
|
||||
self.device = device
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key as VerifyingKey object."""
|
||||
with self.device:
|
||||
pubkey = self.device.pubkey(ecdh=ecdh, identity=identity)
|
||||
return formats.decompress_pubkey(
|
||||
pubkey=pubkey, curve_name=identity.curve_name)
|
||||
|
||||
def sign(self, identity, digest):
|
||||
"""Sign the digest and return a serialized signature."""
|
||||
log.info('please confirm GPG signature on %s for "%s"...',
|
||||
self.device, identity.to_string())
|
||||
if identity.curve_name == formats.CURVE_NIST256:
|
||||
digest = digest[:32] # sign the first 256 bits
|
||||
log.debug('signing digest: %s', util.hexlify(digest))
|
||||
with self.device:
|
||||
sig = self.device.sign(blob=digest, identity=identity)
|
||||
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Derive shared secret using ECDH from remote public key."""
|
||||
log.info('please confirm GPG decryption on %s for "%s"...',
|
||||
self.device, identity.to_string())
|
||||
with self.device:
|
||||
return self.device.ecdh(pubkey=pubkey, identity=identity)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Decoders for GPG v2 data structures."""
|
||||
import base64
|
||||
import copy
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
@@ -53,39 +52,31 @@ def parse_mpis(s, n):
|
||||
return [parse_mpi(s) for _ in range(n)]
|
||||
|
||||
|
||||
def _parse_nist256p1_verifier(mpi):
|
||||
def _parse_nist256p1_pubkey(mpi):
|
||||
prefix, x, y = util.split_bits(mpi, 4, 256, 256)
|
||||
assert prefix == 4
|
||||
if prefix != 4:
|
||||
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
|
||||
point = ecdsa.ellipticcurve.Point(curve=ecdsa.NIST256p.curve,
|
||||
x=x, y=y)
|
||||
vk = ecdsa.VerifyingKey.from_public_point(
|
||||
return ecdsa.VerifyingKey.from_public_point(
|
||||
point=point, curve=ecdsa.curves.NIST256p,
|
||||
hashfunc=hashlib.sha256)
|
||||
|
||||
def _nist256p1_verify(signature, digest):
|
||||
result = vk.verify_digest(signature=signature,
|
||||
digest=digest,
|
||||
sigdecode=lambda rs, order: rs)
|
||||
log.debug('nist256p1 ECDSA signature is OK (%s)', result)
|
||||
return _nist256p1_verify, vk
|
||||
|
||||
|
||||
def _parse_ed25519_verifier(mpi):
|
||||
def _parse_ed25519_pubkey(mpi):
|
||||
prefix, value = util.split_bits(mpi, 8, 256)
|
||||
assert prefix == 0x40
|
||||
vk = ed25519.VerifyingKey(util.num2bytes(value, size=32))
|
||||
|
||||
def _ed25519_verify(signature, digest):
|
||||
sig = b''.join(util.num2bytes(val, size=32)
|
||||
for val in signature)
|
||||
result = vk.verify(sig, digest)
|
||||
log.debug('ed25519 ECDSA signature is OK (%s)', result)
|
||||
return _ed25519_verify, vk
|
||||
if prefix != 0x40:
|
||||
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
|
||||
return ed25519.VerifyingKey(util.num2bytes(value, size=32))
|
||||
|
||||
|
||||
SUPPORTED_CURVES = {
|
||||
b'\x2A\x86\x48\xCE\x3D\x03\x01\x07': _parse_nist256p1_verifier,
|
||||
b'\x2B\x06\x01\x04\x01\xDA\x47\x0F\x01': _parse_ed25519_verifier,
|
||||
b'\x2A\x86\x48\xCE\x3D\x03\x01\x07':
|
||||
(_parse_nist256p1_pubkey, protocol.keygrip_nist256),
|
||||
b'\x2B\x06\x01\x04\x01\xDA\x47\x0F\x01':
|
||||
(_parse_ed25519_pubkey, protocol.keygrip_ed25519),
|
||||
b'\x2B\x06\x01\x04\x01\x97\x55\x01\x05\x01':
|
||||
(_parse_ed25519_pubkey, protocol.keygrip_curve25519),
|
||||
}
|
||||
|
||||
RSA_ALGO_IDS = {1, 2, 3}
|
||||
@@ -94,18 +85,6 @@ DSA_ALGO_ID = 17
|
||||
ECDSA_ALGO_IDS = {18, 19, 22} # {ecdsa, nist256, ed25519}
|
||||
|
||||
|
||||
def _parse_literal(stream):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-5.9 for details."""
|
||||
p = {'type': 'literal'}
|
||||
p['format'] = stream.readfmt('c')
|
||||
filename_len = stream.readfmt('B')
|
||||
p['filename'] = stream.read(filename_len)
|
||||
p['date'] = stream.readfmt('>L')
|
||||
p['content'] = stream.read()
|
||||
p['_to_hash'] = p['content']
|
||||
return p
|
||||
|
||||
|
||||
def _parse_embedded_signatures(subpackets):
|
||||
for packet in subpackets:
|
||||
data = bytearray(packet)
|
||||
@@ -115,6 +94,12 @@ def _parse_embedded_signatures(subpackets):
|
||||
yield _parse_signature(util.Reader(stream))
|
||||
|
||||
|
||||
def has_custom_subpacket(signature_packet):
|
||||
"""Detect our custom public keys by matching subpacket data."""
|
||||
return any(protocol.CUSTOM_KEY_LABEL == subpacket[1:]
|
||||
for subpacket in signature_packet['unhashed_subpackets'])
|
||||
|
||||
|
||||
def _parse_signature(stream):
|
||||
"""See https://tools.ietf.org/html/rfc4880#section-5.2 for details."""
|
||||
p = {'type': 'signature'}
|
||||
@@ -138,8 +123,6 @@ def _parse_signature(stream):
|
||||
log.debug('embedded sigs: %s', embedded)
|
||||
p['embedded'] = embedded
|
||||
|
||||
p['_is_custom'] = (protocol.CUSTOM_SUBPACKET in p['unhashed_subpackets'])
|
||||
|
||||
p['hash_prefix'] = stream.readfmt('2s')
|
||||
if p['pubkey_alg'] in ECDSA_ALGO_IDS:
|
||||
p['sig'] = (parse_mpi(stream), parse_mpi(stream))
|
||||
@@ -168,11 +151,10 @@ def _parse_pubkey(stream, packet_type='pubkey'):
|
||||
oid_size = stream.readfmt('B')
|
||||
oid = stream.read(oid_size)
|
||||
assert oid in SUPPORTED_CURVES, util.hexlify(oid)
|
||||
parser = SUPPORTED_CURVES[oid]
|
||||
p['curve_oid'] = oid
|
||||
|
||||
mpi = parse_mpi(stream)
|
||||
log.debug('mpi: %x (%d bits)', mpi, mpi.bit_length())
|
||||
p['verifier'], p['verifying_key'] = parser(mpi)
|
||||
leftover = stream.read()
|
||||
if leftover:
|
||||
leftover = io.BytesIO(leftover)
|
||||
@@ -180,16 +162,19 @@ def _parse_pubkey(stream, packet_type='pubkey'):
|
||||
# should be b'\x03\x01\x08\x07': SHA256 + AES128
|
||||
size, = util.readfmt(leftover, 'B')
|
||||
p['kdf'] = leftover.read(size)
|
||||
assert not leftover.read()
|
||||
p['secret'] = leftover.read()
|
||||
|
||||
parse_func, keygrip_func = SUPPORTED_CURVES[oid]
|
||||
keygrip = keygrip_func(parse_func(mpi))
|
||||
log.debug('keygrip: %s', util.hexlify(keygrip))
|
||||
p['keygrip'] = keygrip
|
||||
|
||||
elif p['algo'] == DSA_ALGO_ID:
|
||||
log.warning('DSA signatures are not verified')
|
||||
parse_mpis(stream, n=4)
|
||||
parse_mpis(stream, n=4) # DSA keys are not supported
|
||||
elif p['algo'] == ELGAMAL_ALGO_ID:
|
||||
log.warning('ElGamal signatures are not verified')
|
||||
parse_mpis(stream, n=3)
|
||||
parse_mpis(stream, n=3) # ElGamal keys are not supported
|
||||
else: # assume RSA
|
||||
log.warning('RSA signatures are not verified')
|
||||
parse_mpis(stream, n=2)
|
||||
parse_mpis(stream, n=2) # RSA keys are not supported
|
||||
assert not stream.read()
|
||||
|
||||
# https://tools.ietf.org/html/rfc4880#section-12.2
|
||||
@@ -201,6 +186,7 @@ def _parse_pubkey(stream, packet_type='pubkey'):
|
||||
log.debug('key ID: %s', util.hexlify(p['key_id']))
|
||||
return p
|
||||
|
||||
|
||||
_parse_subkey = functools.partial(_parse_pubkey, packet_type='subkey')
|
||||
|
||||
|
||||
@@ -210,14 +196,16 @@ def _parse_user_id(stream, packet_type='user_id'):
|
||||
to_hash = b'\xb4' + util.prefix_len('>L', value)
|
||||
return {'type': packet_type, 'value': value, '_to_hash': to_hash}
|
||||
|
||||
|
||||
# User attribute is handled as an opaque user ID
|
||||
_parse_attribute = functools.partial(_parse_user_id,
|
||||
packet_type='user_attribute')
|
||||
|
||||
PACKET_TYPES = {
|
||||
2: _parse_signature,
|
||||
5: _parse_pubkey,
|
||||
6: _parse_pubkey,
|
||||
11: _parse_literal,
|
||||
7: _parse_subkey,
|
||||
13: _parse_user_id,
|
||||
14: _parse_subkey,
|
||||
17: _parse_attribute,
|
||||
@@ -261,11 +249,13 @@ def parse_packets(stream):
|
||||
packet_data = reader.read(packet_size)
|
||||
packet_type = PACKET_TYPES.get(tag)
|
||||
|
||||
p = {'type': 'unknown', 'tag': tag, 'raw': packet_data}
|
||||
if packet_type is not None:
|
||||
p = packet_type(util.Reader(io.BytesIO(packet_data)))
|
||||
p['tag'] = tag
|
||||
else:
|
||||
p = {'type': 'unknown', 'tag': tag, 'raw': packet_data}
|
||||
try:
|
||||
p = packet_type(util.Reader(io.BytesIO(packet_data)))
|
||||
p['tag'] = tag
|
||||
except ValueError:
|
||||
log.exception('Skipping packet: %s', util.hexlify(packet_data))
|
||||
|
||||
log.debug('packet "%s": %s', p['type'], p)
|
||||
yield p
|
||||
@@ -280,28 +270,6 @@ def digest_packets(packets, hasher):
|
||||
return hasher.digest()
|
||||
|
||||
|
||||
def collect_packets(packets, types_to_collect):
|
||||
"""Collect specified packet types into their leading packet."""
|
||||
packet = None
|
||||
result = []
|
||||
for p in packets:
|
||||
if p['type'] in types_to_collect:
|
||||
packet.setdefault(p['type'], []).append(p)
|
||||
else:
|
||||
packet = copy.deepcopy(p)
|
||||
result.append(packet)
|
||||
return result
|
||||
|
||||
|
||||
def parse_public_keys(stream):
|
||||
"""Parse GPG public key into hierarchy of packets."""
|
||||
packets = list(parse_packets(stream))
|
||||
packets = collect_packets(packets, {'signature'})
|
||||
packets = collect_packets(packets, {'user_id', 'user_attribute'})
|
||||
packets = collect_packets(packets, {'subkey'})
|
||||
return packets
|
||||
|
||||
|
||||
HASH_ALGORITHMS = {
|
||||
1: 'md5',
|
||||
2: 'sha1',
|
||||
@@ -313,42 +281,23 @@ HASH_ALGORITHMS = {
|
||||
}
|
||||
|
||||
|
||||
def load_public_key(pubkey_bytes, use_custom=False, ecdh=False):
|
||||
"""Parse and validate GPG public key from an input stream."""
|
||||
def load_by_keygrip(pubkey_bytes, keygrip):
|
||||
"""Return public key and first user ID for specified keygrip."""
|
||||
stream = io.BytesIO(pubkey_bytes)
|
||||
packets = list(parse_packets(stream))
|
||||
pubkey, userid, signature = packets[:3]
|
||||
packets = packets[3:]
|
||||
packets_per_pubkey = []
|
||||
for p in packets:
|
||||
if p['type'] == 'pubkey':
|
||||
# Add a new packet list for each pubkey.
|
||||
packets_per_pubkey.append([])
|
||||
packets_per_pubkey[-1].append(p)
|
||||
|
||||
hash_alg = HASH_ALGORITHMS.get(signature['hash_alg'])
|
||||
if hash_alg is not None:
|
||||
digest = digest_packets(packets=[pubkey, userid, signature],
|
||||
hasher=hashlib.new(hash_alg))
|
||||
assert signature['hash_prefix'] == digest[:2]
|
||||
|
||||
log.debug('loaded public key "%s"', userid['value'])
|
||||
if hash_alg is not None and pubkey.get('verifier'):
|
||||
verify_digest(pubkey=pubkey, digest=digest,
|
||||
signature=signature['sig'], label='GPG public key')
|
||||
else:
|
||||
log.warning('public key %s is not verified!',
|
||||
util.hexlify(pubkey['key_id']))
|
||||
|
||||
packet = pubkey
|
||||
while use_custom:
|
||||
if packet['type'] in ('pubkey', 'subkey') and signature['_is_custom']:
|
||||
if ecdh == (packet['algo'] == protocol.ECDH_ALGO_ID):
|
||||
log.debug('found custom %s', packet['type'])
|
||||
break
|
||||
|
||||
while packets[1]['type'] != 'signature':
|
||||
packets = packets[1:]
|
||||
packet, signature = packets[:2]
|
||||
packets = packets[2:]
|
||||
|
||||
packet['user_id'] = userid['value']
|
||||
packet['_is_custom'] = signature['_is_custom']
|
||||
return packet
|
||||
for packets in packets_per_pubkey:
|
||||
user_ids = [p for p in packets if p['type'] == 'user_id']
|
||||
for p in packets:
|
||||
if p.get('keygrip') == keygrip:
|
||||
return p, user_ids
|
||||
raise KeyError('{} keygrip not found'.format(util.hexlify(keygrip)))
|
||||
|
||||
|
||||
def load_signature(stream, original_data):
|
||||
@@ -361,17 +310,6 @@ def load_signature(stream, original_data):
|
||||
return signature, digest
|
||||
|
||||
|
||||
def verify_digest(pubkey, digest, signature, label):
|
||||
"""Verify a digest signature from a specified public key."""
|
||||
verifier = pubkey['verifier']
|
||||
try:
|
||||
verifier(signature, digest)
|
||||
log.debug('%s is OK', label)
|
||||
except ecdsa.keys.BadSignatureError:
|
||||
log.error('Bad %s!', label)
|
||||
raise ValueError('Invalid ECDSA signature for {}'.format(label))
|
||||
|
||||
|
||||
def remove_armor(armored_data):
|
||||
"""Decode armored data into its binary form."""
|
||||
stream = io.BytesIO(armored_data)
|
||||
@@ -380,11 +318,3 @@ def remove_armor(armored_data):
|
||||
payload, checksum = data[:-3], data[-3:]
|
||||
assert util.crc24(payload) == checksum
|
||||
return payload
|
||||
|
||||
|
||||
def verify(pubkey, signature, original_data):
|
||||
"""Verify correctness of public key and signature."""
|
||||
stream = io.BytesIO(remove_armor(signature))
|
||||
signature, digest = load_signature(stream, original_data)
|
||||
verify_digest(pubkey=pubkey, digest=digest,
|
||||
signature=signature['sig'], label='GPG signature')
|
||||
103
libagent/gpg/encode.py
Normal file
103
libagent/gpg/encode.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Create GPG ECDSA signatures and public keys using TREZOR device."""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import decode, keyring, protocol
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_primary(user_id, pubkey, signer_func, secret_bytes=b''):
|
||||
"""Export new primary GPG public key, ready for "gpg2 --import"."""
|
||||
pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6),
|
||||
blob=(pubkey.data() + secret_bytes))
|
||||
user_id_bytes = user_id.encode('utf-8')
|
||||
user_id_packet = protocol.packet(tag=13, blob=user_id_bytes)
|
||||
data_to_sign = (pubkey.data_to_hash() + user_id_packet[:1] +
|
||||
util.prefix_len('>L', user_id_bytes))
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(pubkey.created), # signature time
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.7
|
||||
protocol.subpacket_byte(0x0B, 9), # preferred symmetric algo (AES-256)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.4
|
||||
protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
protocol.subpacket_bytes(0x15, [8, 9, 10]), # preferred hash
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
|
||||
protocol.subpacket_bytes(0x16, [2, 3, 1]), # preferred compression
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
|
||||
protocol.subpacket_byte(0x17, 0x80), # key server prefs (no-modify)
|
||||
# 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 = [
|
||||
protocol.subpacket(16, pubkey.key_id()), # issuer key id
|
||||
protocol.CUSTOM_SUBPACKET]
|
||||
|
||||
signature = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
public_algo=pubkey.algo_id,
|
||||
data_to_sign=data_to_sign,
|
||||
sig_type=0x13, # user id & public key
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
|
||||
sign_packet = protocol.packet(tag=2, blob=signature)
|
||||
return pubkey_packet + user_id_packet + sign_packet
|
||||
|
||||
|
||||
def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''):
|
||||
"""Export new subkey to GPG primary key."""
|
||||
subkey_packet = protocol.packet(tag=(7 if secret_bytes else 14),
|
||||
blob=(subkey.data() + secret_bytes))
|
||||
packets = list(decode.parse_packets(io.BytesIO(primary_bytes)))
|
||||
primary, user_id, signature = packets[:3]
|
||||
|
||||
data_to_sign = primary['_to_hash'] + subkey.data_to_hash()
|
||||
|
||||
if subkey.ecdh:
|
||||
embedded_sig = None
|
||||
else:
|
||||
# Primary Key Binding Signature
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(subkey.created)] # signature time
|
||||
unhashed_subpackets = [
|
||||
protocol.subpacket(16, subkey.key_id())] # issuer key id
|
||||
embedded_sig = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=data_to_sign,
|
||||
public_algo=subkey.algo_id,
|
||||
sig_type=0x19,
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
|
||||
# Subkey Binding Signature
|
||||
|
||||
# Key flags: https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
# (certify & sign) (encrypt)
|
||||
flags = (2) if (not subkey.ecdh) else (4 | 8)
|
||||
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(subkey.created), # signature time
|
||||
protocol.subpacket_byte(0x1B, flags)]
|
||||
|
||||
unhashed_subpackets = []
|
||||
unhashed_subpackets.append(protocol.subpacket(16, primary['key_id']))
|
||||
if embedded_sig is not None:
|
||||
unhashed_subpackets.append(protocol.subpacket(32, embedded_sig))
|
||||
unhashed_subpackets.append(protocol.CUSTOM_SUBPACKET)
|
||||
|
||||
if not decode.has_custom_subpacket(signature):
|
||||
signer_func = keyring.create_agent_signer(user_id['value'])
|
||||
|
||||
signature = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=data_to_sign,
|
||||
public_algo=primary['algo'],
|
||||
sig_type=0x18,
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
sign_packet = protocol.packet(tag=2, blob=signature)
|
||||
return primary_bytes + subkey_packet + sign_packet
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Tools for doing signature using gpg-agent."""
|
||||
from __future__ import unicode_literals, absolute_import, print_function
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import binascii
|
||||
import io
|
||||
@@ -14,17 +14,20 @@ from .. import util
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_agent_sock_path(sp=subprocess):
|
||||
def get_agent_sock_path(env=None, sp=subprocess):
|
||||
"""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 = sp.check_output(args=args, env=env)
|
||||
lines = output.strip().split(b'\n')
|
||||
dirs = dict(line.split(b':', 1) for line in lines)
|
||||
log.debug('%s: %s', args, dirs)
|
||||
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."""
|
||||
sock_path = get_agent_sock_path(sp=sp)
|
||||
sp.check_call(['gpg-connect-agent', '/bye'])
|
||||
sock_path = get_agent_sock_path(sp=sp, env=env)
|
||||
sp.check_call(['gpg-connect-agent', '/bye']) # Make sure it's running
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(sock_path)
|
||||
return sock
|
||||
@@ -110,6 +113,7 @@ def _parse_ecdsa_sig(args):
|
||||
return (util.bytes2num(sig_r),
|
||||
util.bytes2num(sig_s))
|
||||
|
||||
|
||||
# DSA and EDDSA happen to have the same structure as ECDSA signatures
|
||||
_parse_dsa_sig = _parse_ecdsa_sig
|
||||
_parse_eddsa_sig = _parse_ecdsa_sig
|
||||
@@ -158,7 +162,12 @@ def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
|
||||
assert communicate(sock, 'SETKEYDESC '
|
||||
'Sign+a+new+TREZOR-based+subkey') == b'OK'
|
||||
assert communicate(sock, 'PKSIGN') == b'OK'
|
||||
line = recvline(sock).strip()
|
||||
while True:
|
||||
line = recvline(sock).strip()
|
||||
if line.startswith(b'S PROGRESS'):
|
||||
continue
|
||||
else:
|
||||
break
|
||||
line = unescape(line)
|
||||
log.debug('unescaped: %r', line)
|
||||
prefix, sig = line.split(b' ', 1)
|
||||
@@ -170,11 +179,25 @@ def sign_digest(sock, keygrip, digest, sp=subprocess, environ=None):
|
||||
return parse_sig(sig)
|
||||
|
||||
|
||||
def get_gnupg_components(sp=subprocess):
|
||||
"""Parse GnuPG components' paths."""
|
||||
output = sp.check_output([util.which('gpgconf'), '--list-components'])
|
||||
components = dict(re.findall('(.*):.*:(.*)', output.decode('ascii')))
|
||||
log.debug('gpgconf --list-components: %s', components)
|
||||
return components
|
||||
|
||||
|
||||
@util.memoize
|
||||
def get_gnupg_binary(sp=subprocess):
|
||||
"""Starting GnuPG 2.2.x, the default installation uses `gpg`."""
|
||||
return get_gnupg_components(sp=sp)['gpg']
|
||||
|
||||
|
||||
def gpg_command(args, env=None):
|
||||
"""Prepare common GPG command line arguments."""
|
||||
if env is None:
|
||||
env = os.environ
|
||||
cmd = ['gpg2']
|
||||
cmd = [get_gnupg_binary()]
|
||||
homedir = env.get('GNUPGHOME')
|
||||
if homedir:
|
||||
cmd.extend(['--homedir', homedir])
|
||||
@@ -184,7 +207,7 @@ def gpg_command(args, env=None):
|
||||
def get_keygrip(user_id, sp=subprocess):
|
||||
"""Get a keygrip of the primary GPG key of the specified user."""
|
||||
args = gpg_command(['--list-keys', '--with-keygrip', user_id])
|
||||
output = sp.check_output(args)
|
||||
output = sp.check_output(args).decode('ascii')
|
||||
return re.findall(r'Keygrip = (\w+)', output)[0]
|
||||
|
||||
|
||||
@@ -196,11 +219,32 @@ def gpg_version(sp=subprocess):
|
||||
return line.split(b' ')[-1] # b'2.1.11'
|
||||
|
||||
|
||||
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`."""
|
||||
args = gpg_command(['--export', user_id])
|
||||
result = sp.check_output(args=args)
|
||||
result = sp.check_output(args=args, env=env)
|
||||
if not result:
|
||||
log.error('could not find public key %r in local GPG keyring', user_id)
|
||||
raise KeyError(user_id)
|
||||
return result
|
||||
|
||||
|
||||
def export_public_keys(env=None, sp=subprocess):
|
||||
"""Export all GPG public keys."""
|
||||
args = gpg_command(['--export'])
|
||||
result = sp.check_output(args=args, env=env)
|
||||
if not result:
|
||||
raise KeyError('No GPG public keys found at env: {!r}'.format(env))
|
||||
return result
|
||||
|
||||
|
||||
def create_agent_signer(user_id):
|
||||
"""Sign digest with existing GPG keys using gpg-agent tool."""
|
||||
sock = connect_to_agent(env=os.environ)
|
||||
keygrip = get_keygrip(user_id)
|
||||
|
||||
def sign(digest):
|
||||
"""Sign the digest and return an ECDSA/RSA/DSA signature."""
|
||||
return sign_digest(sock=sock, keygrip=keygrip, digest=digest)
|
||||
|
||||
return sign
|
||||
@@ -47,6 +47,11 @@ def subpacket_byte(subpacket_type, 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):
|
||||
"""Prefix subpacket length according to RFC 4880 section-5.2.3.1."""
|
||||
n = len(item)
|
||||
@@ -99,7 +104,8 @@ def _compute_keygrip(params):
|
||||
return hashlib.sha1(b''.join(parts)).digest()
|
||||
|
||||
|
||||
def _keygrip_nist256(vk):
|
||||
def keygrip_nist256(vk):
|
||||
"""Compute keygrip for NIST256 curve public keys."""
|
||||
curve = vk.curve.curve
|
||||
gen = vk.curve.generator
|
||||
g = (4 << 512) | (gen.x() << 256) | gen.y()
|
||||
@@ -116,7 +122,8 @@ def _keygrip_nist256(vk):
|
||||
])
|
||||
|
||||
|
||||
def _keygrip_ed25519(vk):
|
||||
def keygrip_ed25519(vk):
|
||||
"""Compute keygrip for Ed25519 public keys."""
|
||||
# pylint: disable=line-too-long
|
||||
return _compute_keygrip([
|
||||
['p', util.num2bytes(0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED, size=32)], # nopep8
|
||||
@@ -128,35 +135,54 @@ def _keygrip_ed25519(vk):
|
||||
])
|
||||
|
||||
|
||||
def keygrip_curve25519(vk):
|
||||
"""Compute keygrip for Curve25519 public keys."""
|
||||
# pylint: disable=line-too-long
|
||||
return _compute_keygrip([
|
||||
['p', util.num2bytes(0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED, size=32)], # nopep8
|
||||
['a', b'\x01\xDB\x41'],
|
||||
['b', b'\x01'],
|
||||
['g', util.num2bytes(0x04000000000000000000000000000000000000000000000000000000000000000920ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9, size=65)], # nopep8
|
||||
['n', util.num2bytes(0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED, size=32)], # nopep8
|
||||
['q', vk.to_bytes()],
|
||||
])
|
||||
|
||||
|
||||
SUPPORTED_CURVES = {
|
||||
formats.CURVE_NIST256: {
|
||||
# https://tools.ietf.org/html/rfc6637#section-11
|
||||
'oid': b'\x2A\x86\x48\xCE\x3D\x03\x01\x07',
|
||||
'algo_id': 19,
|
||||
'serialize': _serialize_nist256,
|
||||
'keygrip': _keygrip_nist256,
|
||||
'keygrip': keygrip_nist256,
|
||||
},
|
||||
formats.CURVE_ED25519: {
|
||||
'oid': b'\x2B\x06\x01\x04\x01\xDA\x47\x0F\x01',
|
||||
'algo_id': 22,
|
||||
'serialize': _serialize_ed25519,
|
||||
'keygrip': _keygrip_ed25519,
|
||||
}
|
||||
'keygrip': keygrip_ed25519,
|
||||
},
|
||||
formats.ECDH_CURVE25519: {
|
||||
'oid': b'\x2B\x06\x01\x04\x01\x97\x55\x01\x05\x01',
|
||||
'algo_id': 18,
|
||||
'serialize': _serialize_ed25519,
|
||||
'keygrip': keygrip_curve25519,
|
||||
},
|
||||
}
|
||||
|
||||
ECDH_ALGO_ID = 18
|
||||
|
||||
CUSTOM_SUBPACKET = subpacket(100, b'TREZOR-GPG') # marks "our" pubkey
|
||||
CUSTOM_KEY_LABEL = b'TREZOR-GPG' # marks "our" pubkey
|
||||
CUSTOM_SUBPACKET_ID = 26 # use "policy URL" subpacket
|
||||
CUSTOM_SUBPACKET = subpacket(CUSTOM_SUBPACKET_ID, CUSTOM_KEY_LABEL)
|
||||
|
||||
|
||||
def find_curve_by_algo_id(algo_id):
|
||||
"""Find curve name that matches a public key algorith ID."""
|
||||
if algo_id == ECDH_ALGO_ID:
|
||||
return formats.CURVE_NIST256
|
||||
|
||||
curve_name, = [name for name, info in SUPPORTED_CURVES.items()
|
||||
if info['algo_id'] == algo_id]
|
||||
return curve_name
|
||||
def get_curve_name_by_oid(oid):
|
||||
"""Return curve name matching specified OID, or raise KeyError."""
|
||||
for curve_name, info in SUPPORTED_CURVES.items():
|
||||
if info['oid'] == oid:
|
||||
return curve_name
|
||||
raise KeyError('Unknown OID: {!r}'.format(oid))
|
||||
|
||||
|
||||
class PublicKey(object):
|
||||
@@ -164,10 +190,11 @@ class PublicKey(object):
|
||||
|
||||
def __init__(self, curve_name, created, verifying_key, ecdh=False):
|
||||
"""Contruct using a ECDSA VerifyingKey object."""
|
||||
self.curve_name = curve_name
|
||||
self.curve_info = SUPPORTED_CURVES[curve_name]
|
||||
self.created = int(created) # time since Epoch
|
||||
self.verifying_key = verifying_key
|
||||
self.ecdh = ecdh
|
||||
self.ecdh = bool(ecdh)
|
||||
if ecdh:
|
||||
self.algo_id = ECDH_ALGO_ID
|
||||
self.ecdh_packet = b'\x03\x01\x08\x07'
|
||||
@@ -175,12 +202,8 @@ class PublicKey(object):
|
||||
self.algo_id = self.curve_info['algo_id']
|
||||
self.ecdh_packet = b''
|
||||
|
||||
hex_key_id = util.hexlify(self.key_id())[-8:]
|
||||
self.desc = 'GPG public key {}/{}'.format(curve_name, hex_key_id)
|
||||
|
||||
@property
|
||||
def keygrip(self):
|
||||
"""Compute GPG2 keygrip."""
|
||||
"""Compute GPG keygrip of the verifying key."""
|
||||
return self.curve_info['keygrip'](self.verifying_key)
|
||||
|
||||
def data(self):
|
||||
@@ -206,7 +229,8 @@ class PublicKey(object):
|
||||
|
||||
def __repr__(self):
|
||||
"""Short (8 hexadecimal digits) GPG key ID."""
|
||||
return self.desc
|
||||
hex_key_id = util.hexlify(self.key_id())[-8:]
|
||||
return 'GPG public key {}/{}'.format(self.curve_name, hex_key_id)
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
62
libagent/gpg/tests/test_decode.py
Normal file
62
libagent/gpg/tests/test_decode.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import decode, protocol
|
||||
from ... import util
|
||||
|
||||
|
||||
def test_subpackets():
|
||||
s = io.BytesIO(b'\x00\x05\x02\xAB\xCD\x01\xEF')
|
||||
assert decode.parse_subpackets(util.Reader(s)) == [b'\xAB\xCD', b'\xEF']
|
||||
|
||||
|
||||
def test_subpackets_prefix():
|
||||
for n in [0, 1, 2, 4, 5, 10, 191, 192, 193,
|
||||
255, 256, 257, 8383, 8384, 65530]:
|
||||
item = b'?' * n # create dummy subpacket
|
||||
prefixed = protocol.subpackets(item)
|
||||
result = decode.parse_subpackets(util.Reader(io.BytesIO(prefixed)))
|
||||
assert [item] == result
|
||||
|
||||
|
||||
def test_mpi():
|
||||
s = io.BytesIO(b'\x00\x09\x01\x23')
|
||||
assert decode.parse_mpi(util.Reader(s)) == 0x123
|
||||
|
||||
s = io.BytesIO(b'\x00\x09\x01\x23\x00\x03\x05')
|
||||
assert decode.parse_mpis(util.Reader(s), n=2) == [0x123, 5]
|
||||
|
||||
|
||||
cwd = os.path.join(os.path.dirname(__file__))
|
||||
input_files = glob.glob(os.path.join(cwd, '*.gpg'))
|
||||
|
||||
|
||||
@pytest.fixture(params=input_files)
|
||||
def public_key_path(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def test_gpg_files(public_key_path): # pylint: disable=redefined-outer-name
|
||||
with open(public_key_path, 'rb') as f:
|
||||
assert list(decode.parse_packets(f))
|
||||
|
||||
|
||||
def test_has_custom_subpacket():
|
||||
sig = {'unhashed_subpackets': []}
|
||||
assert not decode.has_custom_subpacket(sig)
|
||||
|
||||
custom_markers = [
|
||||
protocol.CUSTOM_SUBPACKET,
|
||||
protocol.subpacket(10, protocol.CUSTOM_KEY_LABEL),
|
||||
]
|
||||
for marker in custom_markers:
|
||||
sig = {'unhashed_subpackets': [marker]}
|
||||
assert decode.has_custom_subpacket(sig)
|
||||
|
||||
|
||||
def test_load_by_keygrip_missing():
|
||||
with pytest.raises(KeyError):
|
||||
decode.load_by_keygrip(pubkey_bytes=b'', keygrip=b'')
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
|
||||
from .. import keyring
|
||||
@@ -79,4 +80,22 @@ PKSIGN
|
||||
def test_iterlines():
|
||||
sock = FakeSocket()
|
||||
sock.rx.write(b'foo\nbar\nxyz')
|
||||
assert list(keyring.iterlines(sock)) == []
|
||||
sock.rx.seek(0)
|
||||
assert list(keyring.iterlines(sock)) == [b'foo', b'bar']
|
||||
|
||||
|
||||
def test_get_agent_sock_path():
|
||||
sp = mock.Mock(spec=['check_output'])
|
||||
sp.check_output.return_value = b'''sysconfdir:/usr/local/etc/gnupg
|
||||
bindir:/usr/local/bin
|
||||
libexecdir:/usr/local/libexec
|
||||
libdir:/usr/local/lib/gnupg
|
||||
datadir:/usr/local/share/gnupg
|
||||
localedir:/usr/local/share/locale
|
||||
dirmngr-socket:/run/user/1000/gnupg/S.dirmngr
|
||||
agent-ssh-socket:/run/user/1000/gnupg/S.gpg-agent.ssh
|
||||
agent-socket:/run/user/1000/gnupg/S.gpg-agent
|
||||
homedir:/home/roman/.gnupg
|
||||
'''
|
||||
expected = b'/run/user/1000/gnupg/S.gpg-agent'
|
||||
assert keyring.get_agent_sock_path(sp=sp) == expected
|
||||
@@ -1,5 +1,6 @@
|
||||
import ecdsa
|
||||
import ed25519
|
||||
import pytest
|
||||
|
||||
from .. import protocol
|
||||
from ... import formats
|
||||
@@ -30,11 +31,6 @@ def test_mpi():
|
||||
assert protocol.mpi(0x123) == b'\x00\x09\x01\x23'
|
||||
|
||||
|
||||
def test_find():
|
||||
assert protocol.find_curve_by_algo_id(19) == formats.CURVE_NIST256
|
||||
assert protocol.find_curve_by_algo_id(22) == formats.CURVE_ED25519
|
||||
|
||||
|
||||
def test_armor():
|
||||
data = bytearray(range(256))
|
||||
assert protocol.armor(data, 'TEST') == '''-----BEGIN PGP TEST-----
|
||||
@@ -74,7 +70,7 @@ def test_nist256p1():
|
||||
pk = protocol.PublicKey(curve_name=formats.CURVE_NIST256,
|
||||
created=42, verifying_key=vk)
|
||||
assert repr(pk) == 'GPG public key nist256p1/F82361D9'
|
||||
assert pk.keygrip == b'\x95\x85.\x91\x7f\xe2\xc3\x91R\xba\x99\x81\x92\xb5y\x1d\xb1\\\xdc\xf0'
|
||||
assert pk.keygrip() == b'\x95\x85.\x91\x7f\xe2\xc3\x91R\xba\x99\x81\x92\xb5y\x1d\xb1\\\xdc\xf0'
|
||||
|
||||
|
||||
def test_nist256p1_ecdh():
|
||||
@@ -83,7 +79,7 @@ def test_nist256p1_ecdh():
|
||||
pk = protocol.PublicKey(curve_name=formats.CURVE_NIST256,
|
||||
created=42, verifying_key=vk, ecdh=True)
|
||||
assert repr(pk) == 'GPG public key nist256p1/5811DF46'
|
||||
assert pk.keygrip == b'\x95\x85.\x91\x7f\xe2\xc3\x91R\xba\x99\x81\x92\xb5y\x1d\xb1\\\xdc\xf0'
|
||||
assert pk.keygrip() == b'\x95\x85.\x91\x7f\xe2\xc3\x91R\xba\x99\x81\x92\xb5y\x1d\xb1\\\xdc\xf0'
|
||||
|
||||
|
||||
def test_ed25519():
|
||||
@@ -92,4 +88,20 @@ def test_ed25519():
|
||||
pk = protocol.PublicKey(curve_name=formats.CURVE_ED25519,
|
||||
created=42, verifying_key=vk)
|
||||
assert repr(pk) == 'GPG public key ed25519/36B40FE6'
|
||||
assert pk.keygrip == b'\xbf\x01\x90l\x17\xb64\xa3-\xf4\xc0gr\x99\x18<\xddBQ?'
|
||||
assert pk.keygrip() == b'\xbf\x01\x90l\x17\xb64\xa3-\xf4\xc0gr\x99\x18<\xddBQ?'
|
||||
|
||||
|
||||
def test_curve25519():
|
||||
sk = ed25519.SigningKey(b'\x00' * 32)
|
||||
vk = sk.get_verifying_key()
|
||||
pk = protocol.PublicKey(curve_name=formats.ECDH_CURVE25519,
|
||||
created=42, verifying_key=vk)
|
||||
assert repr(pk) == 'GPG public key curve25519/69460384'
|
||||
assert pk.keygrip() == b'x\xd6\x86\xe4\xa6\xfc;\x0fY\xe1}Lw\xc4\x9ed\xf1Q\x8a\x00'
|
||||
|
||||
|
||||
def test_get_curve_name_by_oid():
|
||||
for name, info in protocol.SUPPORTED_CURVES.items():
|
||||
assert protocol.get_curve_name_by_oid(info['oid']) == name
|
||||
with pytest.raises(KeyError):
|
||||
protocol.get_curve_name_by_oid('BAD_OID')
|
||||
@@ -4,15 +4,12 @@ import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from . import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UNIX_SOCKET_TIMEOUT = 0.1
|
||||
|
||||
|
||||
def remove_file(path, remove=os.remove, exists=os.path.exists):
|
||||
"""Remove file, and raise OSError if still exists."""
|
||||
@@ -30,7 +27,7 @@ def unix_domain_socket_server(sock_path):
|
||||
|
||||
Listen on it, and delete it after the generated context is over.
|
||||
"""
|
||||
log.debug('serving on SSH_AUTH_SOCK=%s', sock_path)
|
||||
log.debug('serving on %s', sock_path)
|
||||
remove_file(sock_path)
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
@@ -42,19 +39,24 @@ def unix_domain_socket_server(sock_path):
|
||||
remove_file(sock_path)
|
||||
|
||||
|
||||
def handle_connection(conn, handler):
|
||||
def handle_connection(conn, handler, mutex):
|
||||
"""
|
||||
Handle a single connection using the specified protocol handler in a loop.
|
||||
|
||||
Since this function may be called concurrently from server_thread,
|
||||
the specified mutex is used to synchronize the device handling.
|
||||
|
||||
Exit when EOFError is raised.
|
||||
All other exceptions are logged as warnings.
|
||||
"""
|
||||
try:
|
||||
log.debug('welcome agent')
|
||||
while True:
|
||||
msg = util.read_frame(conn)
|
||||
reply = handler.handle(msg=msg)
|
||||
util.send(conn, reply)
|
||||
with contextlib.closing(conn):
|
||||
while True:
|
||||
msg = util.read_frame(conn)
|
||||
with mutex:
|
||||
reply = handler.handle(msg=msg)
|
||||
util.send(conn, reply)
|
||||
except EOFError:
|
||||
log.debug('goodbye agent')
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
@@ -77,7 +79,7 @@ def retry(func, exception_type, quit_event):
|
||||
pass
|
||||
|
||||
|
||||
def server_thread(sock, handler, quit_event):
|
||||
def server_thread(sock, handle_conn, quit_event):
|
||||
"""Run a server on the specified socket."""
|
||||
log.debug('server thread started')
|
||||
|
||||
@@ -93,8 +95,9 @@ def server_thread(sock, handler, quit_event):
|
||||
except StopIteration:
|
||||
log.debug('server stopped')
|
||||
break
|
||||
with contextlib.closing(conn):
|
||||
handle_connection(conn, handler)
|
||||
# Handle connections from SSH concurrently.
|
||||
threading.Thread(target=handle_conn,
|
||||
kwargs=dict(conn=conn)).start()
|
||||
log.debug('server thread stopped')
|
||||
|
||||
|
||||
@@ -107,30 +110,6 @@ def spawn(func, kwargs):
|
||||
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.
|
||||
"""
|
||||
if sock_path is None:
|
||||
sock_path = tempfile.mktemp(prefix='ssh-agent-')
|
||||
|
||||
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
|
||||
with unix_domain_socket_server(sock_path) as sock:
|
||||
sock.settimeout(timeout)
|
||||
quit_event = threading.Event()
|
||||
kwargs = dict(sock=sock, handler=handler, 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):
|
||||
"""
|
||||
Run the specified process and wait until it finishes.
|
||||
230
libagent/ssh/__init__.py
Normal file
230
libagent/ssh/__init__.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""SSH-agent implementation using hardware authentication devices."""
|
||||
import contextlib
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import configargparse
|
||||
|
||||
from .. import device, formats, server, util
|
||||
from . import client, protocol
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UNIX_SOCKET_TIMEOUT = 0.1
|
||||
|
||||
|
||||
def ssh_args(label):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = device.interface.string_to_identity(label)
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
if 'user' in identity:
|
||||
args += ['-l', identity['user']]
|
||||
|
||||
return args + [identity['host']]
|
||||
|
||||
|
||||
def mosh_args(label):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = device.interface.string_to_identity(label)
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
if 'user' in identity:
|
||||
args += [identity['user']+'@'+identity['host']]
|
||||
else:
|
||||
args += [identity['host']]
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def _to_unicode(s):
|
||||
try:
|
||||
return unicode(s, 'utf-8')
|
||||
except NameError:
|
||||
return s
|
||||
|
||||
|
||||
def create_agent_parser():
|
||||
"""Create an ArgumentParser for this tool."""
|
||||
p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'])
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
curve_names = [name for name in formats.SUPPORTED_CURVES]
|
||||
curve_names = ', '.join(sorted(curve_names))
|
||||
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
|
||||
default=formats.CURVE_NIST256,
|
||||
help='specify ECDSA curve name: ' + curve_names)
|
||||
p.add_argument('--timeout',
|
||||
default=UNIX_SOCKET_TIMEOUT, type=float,
|
||||
help='Timeout for accepting SSH client connections')
|
||||
p.add_argument('--debug', default=False, action='store_true',
|
||||
help='Log SSH protocol messages for debugging.')
|
||||
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument('-s', '--shell', default=False, action='store_true',
|
||||
help='run ${SHELL} as subprocess under SSH agent')
|
||||
g.add_argument('-c', '--connect', default=False, action='store_true',
|
||||
help='connect to specified host via SSH')
|
||||
g.add_argument('--mosh', default=False, action='store_true',
|
||||
help='connect to specified host via using Mosh')
|
||||
|
||||
p.add_argument('identity', type=_to_unicode, default=None,
|
||||
help='proto://[user@]host[:port][/path]')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
@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 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 run_server(conn, command, debug, timeout):
|
||||
"""Common code for run_agent and run_git below."""
|
||||
try:
|
||||
handler = protocol.Handler(conn=conn, debug=debug)
|
||||
with serve(handler=handler, timeout=timeout) as env:
|
||||
return server.run_process(command=command, environ=env)
|
||||
except KeyboardInterrupt:
|
||||
log.info('server stopped')
|
||||
|
||||
|
||||
def handle_connection_error(func):
|
||||
"""Fail with non-zero exit code."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except device.interface.NotFoundError as e:
|
||||
log.error('Connection error (try unplugging and replugging your device): %s', e)
|
||||
return 1
|
||||
return wrapper
|
||||
|
||||
|
||||
def parse_config(contents):
|
||||
"""Parse config file into a list of Identity objects."""
|
||||
for identity_str, curve_name in re.findall(r'\<(.*?)\|(.*?)\>', contents):
|
||||
yield device.interface.Identity(identity_str=identity_str,
|
||||
curve_name=curve_name)
|
||||
|
||||
|
||||
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(object):
|
||||
"""Connect to the device just before the needed operation."""
|
||||
|
||||
def __init__(self, conn_factory, identities, public_keys=None):
|
||||
"""Create a JIT connection object."""
|
||||
self.conn_factory = conn_factory
|
||||
self.identities = identities
|
||||
self.public_keys_cache = public_keys
|
||||
|
||||
def public_keys(self):
|
||||
"""Return a list of SSH public keys (in textual format)."""
|
||||
if not self.public_keys_cache:
|
||||
conn = self.conn_factory()
|
||||
self.public_keys_cache = conn.export_public_keys(self.identities)
|
||||
return self.public_keys_cache
|
||||
|
||||
def parse_public_keys(self):
|
||||
"""Parse SSH public keys into dictionaries."""
|
||||
public_keys = [formats.import_public_key(pk)
|
||||
for pk in self.public_keys()]
|
||||
for pk, identity in zip(public_keys, self.identities):
|
||||
pk['identity'] = identity
|
||||
return public_keys
|
||||
|
||||
def sign(self, blob, identity):
|
||||
"""Sign a given blob using the specified identity on the device."""
|
||||
conn = self.conn_factory()
|
||||
return conn.sign_ssh_challenge(blob=blob, identity=identity)
|
||||
|
||||
|
||||
@handle_connection_error
|
||||
def main(device_type):
|
||||
"""Run ssh-agent using given hardware client factory."""
|
||||
args = create_agent_parser().parse_args()
|
||||
util.setup_logging(verbosity=args.verbose)
|
||||
|
||||
public_keys = None
|
||||
if args.identity.startswith('/'):
|
||||
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:
|
||||
identities = [device.interface.Identity(
|
||||
identity_str=args.identity, curve_name=args.ecdsa_curve_name)]
|
||||
for index, identity in enumerate(identities):
|
||||
identity.identity_dict['proto'] = u'ssh'
|
||||
log.info('identity #%d: %s', index, identity.to_string())
|
||||
|
||||
if args.connect:
|
||||
command = ['ssh'] + ssh_args(args.identity) + args.command
|
||||
elif args.mosh:
|
||||
command = ['mosh'] + mosh_args(args.identity) + args.command
|
||||
else:
|
||||
command = args.command
|
||||
|
||||
use_shell = bool(args.shell)
|
||||
if use_shell:
|
||||
command = os.environ['SHELL']
|
||||
sys.stdin.close()
|
||||
|
||||
conn = JustInTimeConnection(
|
||||
conn_factory=lambda: client.Client(device_type()),
|
||||
identities=identities, public_keys=public_keys)
|
||||
if command:
|
||||
return run_server(conn=conn, command=command, debug=args.debug,
|
||||
timeout=args.timeout)
|
||||
else:
|
||||
for pk in conn.public_keys():
|
||||
sys.stdout.write(pk)
|
||||
65
libagent/ssh/client.py
Normal file
65
libagent/ssh/client.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Connection to hardware authentication device.
|
||||
|
||||
It is used for getting SSH public keys and ECDSA signing of server requests.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Client wrapper for SSH authentication device."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Connect to hardware device."""
|
||||
self.device = device
|
||||
|
||||
def export_public_keys(self, identities):
|
||||
"""Export SSH public keys from the device."""
|
||||
public_keys = []
|
||||
with self.device:
|
||||
for i in identities:
|
||||
pubkey = self.device.pubkey(identity=i)
|
||||
vk = formats.decompress_pubkey(pubkey=pubkey,
|
||||
curve_name=i.curve_name)
|
||||
public_key = formats.export_public_key(vk=vk,
|
||||
label=i.to_string())
|
||||
public_keys.append(public_key)
|
||||
return public_keys
|
||||
|
||||
def sign_ssh_challenge(self, blob, identity):
|
||||
"""Sign given blob using a private key on the device."""
|
||||
msg = _parse_ssh_blob(blob)
|
||||
log.debug('%s: user %r via %r (%r)',
|
||||
msg['conn'], msg['user'], msg['auth'], msg['key_type'])
|
||||
log.debug('nonce: %r', msg['nonce'])
|
||||
fp = msg['public_key']['fingerprint']
|
||||
log.debug('fingerprint: %s', fp)
|
||||
log.debug('hidden challenge size: %d bytes', len(blob))
|
||||
|
||||
log.info('please confirm user "%s" login to "%s" using %s...',
|
||||
msg['user'].decode('ascii'), identity.to_string(),
|
||||
self.device)
|
||||
|
||||
with self.device:
|
||||
return self.device.sign(blob=blob, identity=identity)
|
||||
|
||||
|
||||
def _parse_ssh_blob(data):
|
||||
res = {}
|
||||
i = io.BytesIO(data)
|
||||
res['nonce'] = util.read_frame(i)
|
||||
i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108)
|
||||
res['user'] = util.read_frame(i)
|
||||
res['conn'] = util.read_frame(i)
|
||||
res['auth'] = util.read_frame(i)
|
||||
i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056)
|
||||
res['key_type'] = util.read_frame(i)
|
||||
public_key = util.read_frame(i)
|
||||
res['public_key'] = formats.parse_pubkey(public_key)
|
||||
assert not i.read()
|
||||
return res
|
||||
@@ -7,7 +7,6 @@ for more details.
|
||||
The server's source code can be found here:
|
||||
https://github.com/openssh/openssh-portable/blob/master/authfd.c
|
||||
"""
|
||||
import binascii
|
||||
import io
|
||||
import logging
|
||||
|
||||
@@ -63,7 +62,9 @@ def failure():
|
||||
|
||||
def _legacy_pubs(buf):
|
||||
"""SSH v1 public keys are not supported."""
|
||||
assert not buf.read()
|
||||
leftover = buf.read()
|
||||
if leftover:
|
||||
log.warning('skipping leftover: %r', leftover)
|
||||
code = util.pack('B', msg_code('SSH_AGENT_RSA_IDENTITIES_ANSWER'))
|
||||
num = util.pack('L', 0) # no SSH v1 keys
|
||||
return util.frame(code, num)
|
||||
@@ -72,14 +73,13 @@ def _legacy_pubs(buf):
|
||||
class Handler(object):
|
||||
"""ssh-agent protocol handler."""
|
||||
|
||||
def __init__(self, keys, signer, debug=False):
|
||||
def __init__(self, conn, debug=False):
|
||||
"""
|
||||
Create a protocol handler with specified public keys.
|
||||
|
||||
Use specified signer function to sign SSH authentication requests.
|
||||
"""
|
||||
self.public_keys = keys
|
||||
self.signer = signer
|
||||
self.conn = conn
|
||||
self.debug = debug
|
||||
|
||||
self.methods = {
|
||||
@@ -108,7 +108,7 @@ class Handler(object):
|
||||
def list_pubs(self, buf):
|
||||
"""SSH v2 public keys are serialized and returned."""
|
||||
assert not buf.read()
|
||||
keys = self.public_keys
|
||||
keys = self.conn.parse_public_keys()
|
||||
code = util.pack('B', msg_code('SSH2_AGENT_IDENTITIES_ANSWER'))
|
||||
num = util.pack('L', len(keys))
|
||||
log.debug('available keys: %s', [k['name'] for k in keys])
|
||||
@@ -130,7 +130,7 @@ class Handler(object):
|
||||
assert util.read_frame(buf) == b''
|
||||
assert not buf.read()
|
||||
|
||||
for k in self.public_keys:
|
||||
for k in self.conn.parse_public_keys():
|
||||
if (k['fingerprint']) == (key['fingerprint']):
|
||||
log.debug('using key %r (%s)', k['name'], k['fingerprint'])
|
||||
key = k
|
||||
@@ -138,13 +138,13 @@ class Handler(object):
|
||||
else:
|
||||
raise KeyError('key not found')
|
||||
|
||||
log.debug('signing %d-byte blob', len(blob))
|
||||
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)
|
||||
try:
|
||||
signature = self.signer(label=label, blob=blob)
|
||||
signature = self.conn.sign(blob=blob, identity=key['identity'])
|
||||
except IOError:
|
||||
return failure()
|
||||
log.debug('signature: %s', binascii.hexlify(signature))
|
||||
log.debug('signature: %r', signature)
|
||||
|
||||
try:
|
||||
sig_bytes = key['verifier'](sig=signature, msg=blob)
|
||||
72
libagent/ssh/tests/test_client.py
Normal file
72
libagent/ssh/tests/test_client.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import client, device, formats, util
|
||||
|
||||
ADDR = [2147483661, 2810943954, 3938368396, 3454558782, 3848009040]
|
||||
CURVE = 'nist256p1'
|
||||
|
||||
PUBKEY = (b'\x03\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis')
|
||||
PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
|
||||
'HAyNTYAAABBBNgotaZgvnQwlaw6Wztd3Cy93D/XwOzdvCv6cn6dQWlzNMEQeW'
|
||||
'VUfhvrGljR2Z/CMRONY6ejB+9PnpUOPuzYqi8= <localhost:22|nist256p1>\n')
|
||||
|
||||
|
||||
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
|
||||
|
||||
def connect(self): # pylint: disable=no-self-use
|
||||
return mock.Mock()
|
||||
|
||||
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
|
||||
assert self.conn
|
||||
return PUBKEY
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
assert self.conn
|
||||
assert blob == BLOB
|
||||
return SIG
|
||||
|
||||
|
||||
BLOB = (b'\x00\x00\x00 \xce\xe0\xc9\xd5\xceu/\xe8\xc5\xf2\xbfR+x\xa1\xcf\xb0'
|
||||
b'\x8e;R\xd3)m\x96\x1b\xb4\xd8s\xf1\x99\x16\xaa2\x00\x00\x00\x05roman'
|
||||
b'\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey'
|
||||
b'\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00'
|
||||
b'\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A'
|
||||
b'\x04\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis4\xc1\x10yeT~\x1b\xeb\x1aX\xd1\xd9\x9f\xc21'
|
||||
b'\x13\x8dc\xa7\xa3\x07\xefO\x9e\x95\x0e>\xec\xd8\xaa/')
|
||||
|
||||
SIG = (b'R\x19T\xf2\x84$\xef#\x0e\xee\x04X\xc6\xc3\x99T`\xd1\xd8\xf7!'
|
||||
b'\x862@cx\xb8\xb9i@1\x1b3#\x938\x86]\x97*Y\xb2\x02Xa\xdf@\xecK'
|
||||
b'\xdc\xf0H\xab\xa8\xac\xa7? \x8f=C\x88N\xe2')
|
||||
|
||||
|
||||
def test_ssh_agent():
|
||||
identity = device.interface.Identity(identity_str='localhost:22',
|
||||
curve_name=CURVE)
|
||||
c = client.Client(device=MockDevice())
|
||||
assert c.export_public_keys([identity]) == [PUBKEY_TEXT]
|
||||
signature = c.sign_ssh_challenge(blob=BLOB, identity=identity)
|
||||
|
||||
key = formats.import_public_key(PUBKEY_TEXT)
|
||||
serialized_sig = key['verifier'](sig=signature, msg=BLOB)
|
||||
|
||||
stream = io.BytesIO(serialized_sig)
|
||||
r = util.read_frame(stream)
|
||||
s = util.read_frame(stream)
|
||||
assert not stream.read()
|
||||
assert r[:1] == b'\x00'
|
||||
assert s[:1] == b'\x00'
|
||||
assert r[1:] + s[1:] == SIG
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def cancel_sign(identity, blob):
|
||||
raise IOError(42, 'ERROR')
|
||||
|
||||
c.device.sign = cancel_sign
|
||||
with pytest.raises(IOError):
|
||||
c.sign_ssh_challenge(blob=BLOB, identity=identity)
|
||||
@@ -1,6 +1,7 @@
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import formats, protocol
|
||||
from .. import device, formats, protocol
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
@@ -15,59 +16,74 @@ NIST256_SIGN_MSG = b'\r\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\
|
||||
NIST256_SIGN_REPLY = b'\x00\x00\x00j\x0e\x00\x00\x00e\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00J\x00\x00\x00!\x00\x88G!\x0c\n\x16:\xbeF\xbe\xb9\xd2\xa9&e\x89\xad\xc4}\x10\xf8\xbc\xdc\xef\x0e\x8d_\x8a6.\xb6\x1f\x00\x00\x00!\x00q\xf0\x16>,\x9a\xde\xe7(\xd6\xd7\x93\x1f\xed\xf9\x94ddw\xfe\xbdq\x13\xbb\xfc\xa9K\xea\x9dC\xa1\xe9' # nopep8
|
||||
|
||||
|
||||
def fake_connection(keys, signer):
|
||||
c = mock.Mock()
|
||||
c.parse_public_keys.return_value = keys
|
||||
c.sign = signer
|
||||
return c
|
||||
|
||||
|
||||
def test_list():
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
h = protocol.Handler(keys=[key], signer=None)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=None))
|
||||
reply = h.handle(LIST_MSG)
|
||||
assert reply == LIST_NIST256_REPLY
|
||||
|
||||
|
||||
def test_list_legacy_pubs_with_suffix():
|
||||
h = protocol.Handler(fake_connection(keys=[], signer=None))
|
||||
suffix = b'\x00\x00\x00\x06foobar'
|
||||
reply = h.handle(b'\x01' + suffix)
|
||||
assert reply == b'\x00\x00\x00\x05\x02\x00\x00\x00\x00' # no legacy keys
|
||||
|
||||
|
||||
def test_unsupported():
|
||||
h = protocol.Handler(keys=[], signer=None)
|
||||
h = protocol.Handler(fake_connection(keys=[], signer=None))
|
||||
reply = h.handle(b'\x09')
|
||||
assert reply == b'\x00\x00\x00\x01\x05'
|
||||
|
||||
|
||||
def ecdsa_signer(label, blob):
|
||||
assert label == 'ssh://localhost'
|
||||
def ecdsa_signer(identity, blob):
|
||||
assert identity.to_string() == '<ssh://localhost|nist256p1>'
|
||||
assert blob == NIST256_BLOB
|
||||
return NIST256_SIG
|
||||
|
||||
|
||||
def test_ecdsa_sign():
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
h = protocol.Handler(keys=[key], signer=ecdsa_signer)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=ecdsa_signer))
|
||||
reply = h.handle(NIST256_SIGN_MSG)
|
||||
assert reply == NIST256_SIGN_REPLY
|
||||
|
||||
|
||||
def test_sign_missing():
|
||||
h = protocol.Handler(keys=[], signer=ecdsa_signer)
|
||||
|
||||
h = protocol.Handler(fake_connection(keys=[], signer=ecdsa_signer))
|
||||
with pytest.raises(KeyError):
|
||||
h.handle(NIST256_SIGN_MSG)
|
||||
|
||||
|
||||
def test_sign_wrong():
|
||||
def wrong_signature(label, blob):
|
||||
assert label == 'ssh://localhost'
|
||||
def wrong_signature(identity, blob):
|
||||
assert identity.to_string() == '<ssh://localhost|nist256p1>'
|
||||
assert blob == NIST256_BLOB
|
||||
return b'\x00' * 64
|
||||
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
h = protocol.Handler(keys=[key], signer=wrong_signature)
|
||||
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=wrong_signature))
|
||||
with pytest.raises(ValueError):
|
||||
h.handle(NIST256_SIGN_MSG)
|
||||
|
||||
|
||||
def test_sign_cancel():
|
||||
def cancel_signature(label, blob): # pylint: disable=unused-argument
|
||||
def cancel_signature(identity, blob): # pylint: disable=unused-argument
|
||||
raise IOError()
|
||||
|
||||
key = formats.import_public_key(NIST256_KEY)
|
||||
h = protocol.Handler(keys=[key], signer=cancel_signature)
|
||||
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'nist256p1')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=cancel_signature))
|
||||
assert h.handle(NIST256_SIGN_MSG) == protocol.failure()
|
||||
|
||||
|
||||
@@ -79,14 +95,15 @@ ED25519_BLOB = b'''\x00\x00\x00 i3\xae}yk\\\xa1L\xb9\xe1\xbf\xbc\x8e\x87\r\x0e\x
|
||||
ED25519_SIG = b'''\x8eb)\xa6\xe9P\x83VE\xfbq\xc6\xbf\x1dV3\xe3<O\x11\xc0\xfa\xe4\xed\xb8\x81.\x81\xc8\xa6\xba\x10RA'a\xbc\xa9\xd3\xdb\x98\x07\xf0\x1a\x9c4\x84<\xaf\x99\xb7\xe5G\xeb\xf7$\xc1\r\x86f\x16\x8e\x08\x05''' # nopep8
|
||||
|
||||
|
||||
def ed25519_signer(label, blob):
|
||||
assert label == 'ssh://localhost'
|
||||
def ed25519_signer(identity, blob):
|
||||
assert identity.to_string() == '<ssh://localhost|ed25519>'
|
||||
assert blob == ED25519_BLOB
|
||||
return ED25519_SIG
|
||||
|
||||
|
||||
def test_ed25519_sign():
|
||||
key = formats.import_public_key(ED25519_KEY)
|
||||
h = protocol.Handler(keys=[key], signer=ed25519_signer)
|
||||
key['identity'] = device.interface.Identity('ssh://localhost', 'ed25519')
|
||||
h = protocol.Handler(fake_connection(keys=[key], signer=ed25519_signer))
|
||||
reply = h.handle(ED25519_SIGN_MSG)
|
||||
assert reply == ED25519_SIGN_REPLY
|
||||
1
libagent/tests/__init__.py
Normal file
1
libagent/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit-tests for this package."""
|
||||
@@ -93,3 +93,11 @@ def test_curve_mismatch():
|
||||
def test_serialize_error():
|
||||
with pytest.raises(TypeError):
|
||||
formats.serialize_verifying_key(None)
|
||||
|
||||
|
||||
def test_get_ecdh_curve_name():
|
||||
for c in [formats.CURVE_NIST256, formats.ECDH_CURVE25519]:
|
||||
assert c == formats.get_ecdh_curve_name(c)
|
||||
|
||||
assert (formats.ECDH_CURVE25519 ==
|
||||
formats.get_ecdh_curve_name(formats.CURVE_ED25519))
|
||||
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')]
|
||||
@@ -7,7 +7,8 @@ import threading
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import protocol, server, util
|
||||
from .. import server, util
|
||||
from ..ssh import protocol
|
||||
|
||||
|
||||
def test_socket():
|
||||
@@ -36,51 +37,63 @@ class FakeSocket(object):
|
||||
pass
|
||||
|
||||
|
||||
def empty_device():
|
||||
c = mock.Mock(spec=['parse_public_keys'])
|
||||
c.parse_public_keys.return_value = []
|
||||
return c
|
||||
|
||||
|
||||
def test_handle():
|
||||
handler = protocol.Handler(keys=[], signer=None)
|
||||
mutex = threading.Lock()
|
||||
|
||||
handler = protocol.Handler(conn=empty_device())
|
||||
conn = FakeSocket()
|
||||
server.handle_connection(conn, handler)
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
|
||||
msg = bytearray([protocol.msg_code('SSH_AGENTC_REQUEST_RSA_IDENTITIES')])
|
||||
conn = FakeSocket(util.frame(msg))
|
||||
server.handle_connection(conn, handler)
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
assert conn.tx.getvalue() == b'\x00\x00\x00\x05\x02\x00\x00\x00\x00'
|
||||
|
||||
msg = bytearray([protocol.msg_code('SSH2_AGENTC_REQUEST_IDENTITIES')])
|
||||
conn = FakeSocket(util.frame(msg))
|
||||
server.handle_connection(conn, handler)
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
assert conn.tx.getvalue() == b'\x00\x00\x00\x05\x0C\x00\x00\x00\x00'
|
||||
|
||||
msg = bytearray([protocol.msg_code('SSH2_AGENTC_ADD_IDENTITY')])
|
||||
conn = FakeSocket(util.frame(msg))
|
||||
server.handle_connection(conn, handler)
|
||||
server.handle_connection(conn, handler, mutex)
|
||||
conn.tx.seek(0)
|
||||
reply = util.read_frame(conn.tx)
|
||||
assert reply == util.pack('B', protocol.msg_code('SSH_AGENT_FAILURE'))
|
||||
|
||||
conn_mock = mock.Mock(spec=FakeSocket)
|
||||
conn_mock.recv.side_effect = [Exception, EOFError]
|
||||
server.handle_connection(conn=conn_mock, handler=None)
|
||||
server.handle_connection(conn=conn_mock, handler=None, mutex=mutex)
|
||||
|
||||
|
||||
def test_server_thread():
|
||||
|
||||
connections = [FakeSocket()]
|
||||
sock = FakeSocket()
|
||||
connections = [sock]
|
||||
quit_event = threading.Event()
|
||||
|
||||
class FakeServer(object):
|
||||
def accept(self): # pylint: disable=no-self-use
|
||||
if connections:
|
||||
return connections.pop(), 'address'
|
||||
quit_event.set()
|
||||
raise socket.timeout()
|
||||
|
||||
def getsockname(self): # pylint: disable=no-self-use
|
||||
return 'fake_server'
|
||||
|
||||
def handle_conn(conn):
|
||||
assert conn is sock
|
||||
quit_event.set()
|
||||
|
||||
server.server_thread(sock=FakeServer(),
|
||||
handler=protocol.Handler(keys=[], signer=None),
|
||||
handle_conn=handle_conn,
|
||||
quit_event=quit_event)
|
||||
quit_event.wait()
|
||||
|
||||
|
||||
def test_spawn():
|
||||
@@ -105,12 +118,6 @@ def test_run():
|
||||
server.run_process([''], environ={})
|
||||
|
||||
|
||||
def test_serve_main():
|
||||
handler = protocol.Handler(keys=[], signer=None)
|
||||
with server.serve(handler=handler, sock_path=None):
|
||||
pass
|
||||
|
||||
|
||||
def test_remove():
|
||||
path = 'foo.bar'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import util
|
||||
@@ -97,3 +98,20 @@ def test_reader():
|
||||
|
||||
with pytest.raises(EOFError):
|
||||
r.read(1)
|
||||
|
||||
|
||||
def test_setup_logging():
|
||||
util.setup_logging(verbosity=10, filename='/dev/null')
|
||||
|
||||
|
||||
def test_memoize():
|
||||
f = mock.Mock(side_effect=lambda x: x)
|
||||
|
||||
def func(x):
|
||||
# mock.Mock doesn't work with functools.wraps()
|
||||
return f(x)
|
||||
|
||||
g = util.memoize(func)
|
||||
assert g(1) == g(1)
|
||||
assert g(1) != g(2)
|
||||
assert f.mock_calls == [mock.call(1), mock.call(2)]
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Various I/O and serialization utilities."""
|
||||
import binascii
|
||||
import contextlib
|
||||
import hashlib
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -180,50 +179,53 @@ class Reader(object):
|
||||
self._captured = None
|
||||
|
||||
|
||||
_identity_regexp = re.compile(''.join([
|
||||
'^'
|
||||
r'(?:(?P<proto>.*)://)?',
|
||||
r'(?:(?P<user>.*)@)?',
|
||||
r'(?P<host>.*?)',
|
||||
r'(?::(?P<port>\w*))?',
|
||||
r'(?P<path>/.*)?',
|
||||
'$'
|
||||
]))
|
||||
def setup_logging(verbosity, filename=None):
|
||||
"""Configure logging for this tool."""
|
||||
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||
level = levels[min(verbosity, len(levels) - 1)]
|
||||
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 string_to_identity(s, identity_type):
|
||||
"""Parse string into Identity protobuf."""
|
||||
m = _identity_regexp.match(s)
|
||||
result = m.groupdict()
|
||||
log.debug('parsed identity: %s', result)
|
||||
kwargs = {k: v for k, v in result.items() if v}
|
||||
return identity_type(**kwargs)
|
||||
def memoize(func):
|
||||
"""Simple caching decorator."""
|
||||
cache = {}
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
"""Caching wrapper."""
|
||||
key = (args, tuple(sorted(kwargs.items())))
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
else:
|
||||
result = func(*args, **kwargs)
|
||||
cache[key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def identity_to_string(identity):
|
||||
"""Dump Identity protobuf into its string representation."""
|
||||
result = []
|
||||
if identity.proto:
|
||||
result.append(identity.proto + '://')
|
||||
if identity.user:
|
||||
result.append(identity.user + '@')
|
||||
result.append(identity.host)
|
||||
if identity.port:
|
||||
result.append(':' + identity.port)
|
||||
if identity.path:
|
||||
result.append(identity.path)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def get_bip32_address(identity, ecdh=False):
|
||||
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
||||
index = struct.pack('<L', identity.index)
|
||||
addr = index + identity_to_string(identity).encode('ascii')
|
||||
log.debug('address string: %r', addr)
|
||||
digest = hashlib.sha256(addr).digest()
|
||||
s = io.BytesIO(bytearray(digest))
|
||||
|
||||
hardened = 0x80000000
|
||||
addr_0 = [13, 17][bool(ecdh)]
|
||||
address_n = [addr_0] + list(recv(s, '<LLLL'))
|
||||
return [(hardened | value) for value in address_n]
|
||||
@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 # pylint: disable=relative-import
|
||||
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
|
||||
34
setup.py
Normal file → Executable file
34
setup.py
Normal file → Executable file
@@ -2,14 +2,27 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='trezor_agent',
|
||||
version='0.7.1',
|
||||
description='Using Trezor as hardware SSH agent',
|
||||
name='libagent',
|
||||
version='0.9.6',
|
||||
description='Using hardware wallets as SSH/GPG agent',
|
||||
author='Roman Zeyde',
|
||||
author_email='roman.zeyde@gmail.com',
|
||||
url='http://github.com/romanz/trezor-agent',
|
||||
packages=['trezor_agent', 'trezor_agent.gpg'],
|
||||
install_requires=['ecdsa>=0.13', 'ed25519>=1.4', 'Cython>=0.23.4', 'protobuf>=3.0.0', 'trezor>=0.7.4', 'semver>=2.2'],
|
||||
packages=[
|
||||
'libagent',
|
||||
'libagent.device',
|
||||
'libagent.gpg',
|
||||
'libagent.ssh'
|
||||
],
|
||||
install_requires=[
|
||||
'backports.shutil_which>=3.5.1',
|
||||
'ConfigArgParse>=0.12.0',
|
||||
'ecdsa>=0.13',
|
||||
'ed25519>=1.4',
|
||||
'pymsgbox>=1.0.6',
|
||||
'semver>=2.2',
|
||||
'unidecode>=0.4.20',
|
||||
],
|
||||
platforms=['POSIX'],
|
||||
classifiers=[
|
||||
'Environment :: Console',
|
||||
@@ -21,19 +34,12 @@ setup(
|
||||
'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',
|
||||
],
|
||||
extras_require={
|
||||
'trezorlib': ['python-trezor>=0.7.4'],
|
||||
'keepkeylib': ['keepkey>=0.7.3'],
|
||||
},
|
||||
entry_points={'console_scripts': [
|
||||
'trezor-agent = trezor_agent.__main__:run_agent',
|
||||
'trezor-git = trezor_agent.__main__:run_git',
|
||||
'trezor-gpg = trezor_agent.gpg.__main__:main',
|
||||
]},
|
||||
)
|
||||
|
||||
16
tox.ini
16
tox.ini
@@ -1,20 +1,24 @@
|
||||
[tox]
|
||||
envlist = py27,py3
|
||||
[pep8]
|
||||
[pycodestyle]
|
||||
max-line-length = 100
|
||||
[pep257]
|
||||
add-ignore = D401
|
||||
[testenv]
|
||||
deps=
|
||||
pytest
|
||||
mock
|
||||
pep8
|
||||
pycodestyle
|
||||
coverage
|
||||
pylint
|
||||
semver
|
||||
pydocstyle
|
||||
isort
|
||||
commands=
|
||||
pep8 trezor_agent
|
||||
pylint --reports=no --rcfile .pylintrc trezor_agent
|
||||
pydocstyle trezor_agent
|
||||
coverage run --omit='trezor_agent/__main__.py' --source trezor_agent -m py.test -v trezor_agent
|
||||
pycodestyle libagent
|
||||
# isort --skip-glob .tox -c -r libagent
|
||||
pylint --reports=no --rcfile .pylintrc libagent
|
||||
pydocstyle libagent
|
||||
coverage run --source libagent -m py.test -v libagent
|
||||
coverage report
|
||||
coverage html
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
"""SSH-agent implementation using hardware authentication devices."""
|
||||
import argparse
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from . import client, formats, protocol, server, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ssh_args(label):
|
||||
"""Create SSH command for connecting specified server."""
|
||||
identity = util.string_to_identity(label, identity_type=dict)
|
||||
|
||||
args = []
|
||||
if 'port' in identity:
|
||||
args += ['-p', identity['port']]
|
||||
if 'user' in identity:
|
||||
args += ['-l', identity['user']]
|
||||
|
||||
return ['ssh'] + args + [identity['host']]
|
||||
|
||||
|
||||
def create_parser():
|
||||
"""Create argparse.ArgumentParser for this tool."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-v', '--verbose', default=0, action='count')
|
||||
|
||||
curve_names = [name for name in formats.SUPPORTED_CURVES]
|
||||
curve_names = ', '.join(sorted(curve_names))
|
||||
p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE',
|
||||
default=formats.CURVE_NIST256,
|
||||
help='specify ECDSA curve name: ' + curve_names)
|
||||
p.add_argument('--timeout',
|
||||
default=server.UNIX_SOCKET_TIMEOUT, type=float,
|
||||
help='Timeout for accepting SSH client connections')
|
||||
p.add_argument('--debug', default=False, action='store_true',
|
||||
help='Log SSH protocol messages for debugging.')
|
||||
return p
|
||||
|
||||
|
||||
def create_agent_parser():
|
||||
"""Specific parser for SSH connection."""
|
||||
p = create_parser()
|
||||
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument('-s', '--shell', default=False, action='store_true',
|
||||
help='run ${SHELL} as subprocess under SSH agent')
|
||||
g.add_argument('-c', '--connect', default=False, action='store_true',
|
||||
help='connect to specified host via SSH')
|
||||
|
||||
p.add_argument('identity', type=str, default=None,
|
||||
help='proto://[user@]host[:port][/path]')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
def create_git_parser():
|
||||
"""Specific parser for git commands."""
|
||||
p = create_parser()
|
||||
|
||||
p.add_argument('-r', '--remote', default='origin',
|
||||
help='use this git remote URL to generate SSH identity')
|
||||
p.add_argument('-t', '--test', action='store_true',
|
||||
help='test connection using `ssh -T user@host` command')
|
||||
p.add_argument('command', type=str, nargs='*', metavar='ARGUMENT',
|
||||
help='Git command to run under the SSH agent')
|
||||
return p
|
||||
|
||||
|
||||
def setup_logging(verbosity):
|
||||
"""Configure logging for this tool."""
|
||||
fmt = ('%(asctime)s %(levelname)-12s %(message)-100s '
|
||||
'[%(filename)s:%(lineno)d]')
|
||||
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
||||
level = levels[min(verbosity, len(levels) - 1)]
|
||||
logging.basicConfig(format=fmt, level=level)
|
||||
|
||||
|
||||
def git_host(remote_name, attributes):
|
||||
"""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, public_key, command, debug, timeout):
|
||||
"""Common code for run_agent and run_git below."""
|
||||
try:
|
||||
signer = conn.sign_ssh_challenge
|
||||
public_key = formats.import_public_key(public_key)
|
||||
log.info('using SSH public key: %s', public_key['fingerprint'])
|
||||
handler = protocol.Handler(keys=[public_key], signer=signer,
|
||||
debug=debug)
|
||||
with server.serve(handler=handler, timeout=timeout) as env:
|
||||
return server.run_process(command=command, environ=env)
|
||||
except KeyboardInterrupt:
|
||||
log.info('server stopped')
|
||||
|
||||
|
||||
def handle_connection_error(func):
|
||||
"""Fail with non-zero exit code."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except IOError as e:
|
||||
log.error('Connection error: %s', e)
|
||||
return 1
|
||||
return wrapper
|
||||
|
||||
|
||||
@handle_connection_error
|
||||
def run_agent(client_factory=client.Client):
|
||||
"""Run ssh-agent using given hardware client factory."""
|
||||
args = create_agent_parser().parse_args()
|
||||
setup_logging(verbosity=args.verbose)
|
||||
|
||||
with client_factory(curve=args.ecdsa_curve_name) as conn:
|
||||
label = args.identity
|
||||
command = args.command
|
||||
|
||||
public_key = conn.get_public_key(label=label)
|
||||
|
||||
if args.connect:
|
||||
command = ssh_args(label) + args.command
|
||||
log.debug('SSH connect: %r', command)
|
||||
|
||||
use_shell = bool(args.shell)
|
||||
if use_shell:
|
||||
command = os.environ['SHELL']
|
||||
log.debug('using shell: %r', command)
|
||||
|
||||
if not command:
|
||||
sys.stdout.write(public_key)
|
||||
return
|
||||
|
||||
return run_server(conn=conn, public_key=public_key, command=command,
|
||||
debug=args.debug, timeout=args.timeout)
|
||||
|
||||
|
||||
@handle_connection_error
|
||||
def run_git(client_factory=client.Client):
|
||||
"""Run git under ssh-agent using given hardware client factory."""
|
||||
args = create_git_parser().parse_args()
|
||||
setup_logging(verbosity=args.verbose)
|
||||
|
||||
with client_factory(curve=args.ecdsa_curve_name) as conn:
|
||||
label = git_host(args.remote, ['pushurl', 'url'])
|
||||
if not label:
|
||||
log.error('Could not find "%s" SSH remote in .git/config',
|
||||
args.remote)
|
||||
return
|
||||
|
||||
public_key = conn.get_public_key(label=label)
|
||||
|
||||
if not args.test:
|
||||
if args.command:
|
||||
command = ['git'] + args.command
|
||||
else:
|
||||
sys.stdout.write(public_key)
|
||||
return
|
||||
else:
|
||||
command = ['ssh', '-T', label]
|
||||
|
||||
return run_server(conn=conn, public_key=public_key, command=command,
|
||||
debug=args.debug, timeout=args.timeout)
|
||||
@@ -1,106 +0,0 @@
|
||||
"""
|
||||
Connection to hardware authentication device.
|
||||
|
||||
It is used for getting SSH public keys and ECDSA signing of server requests.
|
||||
"""
|
||||
import binascii
|
||||
import io
|
||||
import logging
|
||||
|
||||
from . import factory, formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Client wrapper for SSH authentication device."""
|
||||
|
||||
def __init__(self, loader=factory.load, curve=formats.CURVE_NIST256):
|
||||
"""Connect to hardware device."""
|
||||
client_wrapper = loader()
|
||||
self.client = client_wrapper.connection
|
||||
self.identity_type = client_wrapper.identity_type
|
||||
self.device_name = client_wrapper.device_name
|
||||
self.call_exception = client_wrapper.call_exception
|
||||
self.curve = curve
|
||||
|
||||
def __enter__(self):
|
||||
"""Start a session, and test connection."""
|
||||
msg = 'Hello World!'
|
||||
assert self.client.ping(msg) == msg
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Keep the session open (doesn't forget PIN)."""
|
||||
log.info('disconnected from %s', self.device_name)
|
||||
self.client.close()
|
||||
|
||||
def get_identity(self, label, index=0):
|
||||
"""Parse label string into Identity protobuf."""
|
||||
identity = util.string_to_identity(label, self.identity_type)
|
||||
identity.proto = 'ssh'
|
||||
identity.index = index
|
||||
return identity
|
||||
|
||||
def get_public_key(self, label):
|
||||
"""Get SSH public key corresponding to specified by label."""
|
||||
identity = self.get_identity(label=label)
|
||||
label = util.identity_to_string(identity) # canonize key label
|
||||
log.info('getting "%s" public key (%s) from %s...',
|
||||
label, self.curve, self.device_name)
|
||||
addr = util.get_bip32_address(identity)
|
||||
node = self.client.get_public_node(n=addr,
|
||||
ecdsa_curve_name=self.curve)
|
||||
|
||||
pubkey = node.node.public_key
|
||||
vk = formats.decompress_pubkey(pubkey=pubkey, curve_name=self.curve)
|
||||
return formats.export_public_key(vk=vk, label=label)
|
||||
|
||||
def sign_ssh_challenge(self, label, blob):
|
||||
"""Sign given blob using a private key, specified by the label."""
|
||||
identity = self.get_identity(label=label)
|
||||
msg = _parse_ssh_blob(blob)
|
||||
log.debug('%s: user %r via %r (%r)',
|
||||
msg['conn'], msg['user'], msg['auth'], msg['key_type'])
|
||||
log.debug('nonce: %s', binascii.hexlify(msg['nonce']))
|
||||
log.debug('fingerprint: %s', msg['public_key']['fingerprint'])
|
||||
log.debug('hidden challenge size: %d bytes', len(blob))
|
||||
|
||||
log.info('please confirm user "%s" login to "%s" using %s...',
|
||||
msg['user'], label, self.device_name)
|
||||
|
||||
try:
|
||||
result = self.client.sign_identity(identity=identity,
|
||||
challenge_hidden=blob,
|
||||
challenge_visual='',
|
||||
ecdsa_curve_name=self.curve)
|
||||
except self.call_exception as e:
|
||||
code, msg = e.args
|
||||
log.warning('%s error #%s: %s', self.device_name, code, msg)
|
||||
raise IOError(msg) # close current connection, keep server open
|
||||
|
||||
verifying_key = formats.decompress_pubkey(pubkey=result.public_key,
|
||||
curve_name=self.curve)
|
||||
key_type, blob = formats.serialize_verifying_key(verifying_key)
|
||||
assert blob == msg['public_key']['blob']
|
||||
assert key_type == msg['key_type']
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == bytearray([0])
|
||||
|
||||
return result.signature[1:]
|
||||
|
||||
|
||||
def _parse_ssh_blob(data):
|
||||
res = {}
|
||||
i = io.BytesIO(data)
|
||||
res['nonce'] = util.read_frame(i)
|
||||
i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108)
|
||||
res['user'] = util.read_frame(i)
|
||||
res['conn'] = util.read_frame(i)
|
||||
res['auth'] = util.read_frame(i)
|
||||
i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056)
|
||||
res['key_type'] = util.read_frame(i)
|
||||
public_key = util.read_frame(i)
|
||||
res['public_key'] = formats.parse_pubkey(public_key)
|
||||
assert not i.read()
|
||||
return res
|
||||
@@ -1,249 +0,0 @@
|
||||
"""Thin wrapper around trezor/keepkey libraries."""
|
||||
from __future__ import absolute_import
|
||||
import binascii
|
||||
import collections
|
||||
import logging
|
||||
|
||||
import semver
|
||||
|
||||
from . import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ClientWrapper = collections.namedtuple(
|
||||
'ClientWrapper',
|
||||
['connection', 'identity_type', 'device_name', 'call_exception'])
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def _load_client(name, client_type, hid_transport,
|
||||
passphrase_ack, identity_type,
|
||||
required_version, call_exception):
|
||||
|
||||
def empty_passphrase_handler(_):
|
||||
return passphrase_ack(passphrase='')
|
||||
|
||||
for d in hid_transport.enumerate():
|
||||
connection = client_type(hid_transport(d))
|
||||
connection.callback_PassphraseRequest = empty_passphrase_handler
|
||||
f = connection.features
|
||||
log.debug('connected to %s %s', name, 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, required_version):
|
||||
fmt = 'Please upgrade your {} firmware to {} version (current: {})'
|
||||
raise ValueError(fmt.format(name,
|
||||
required_version,
|
||||
current_version))
|
||||
yield ClientWrapper(connection=connection,
|
||||
identity_type=identity_type,
|
||||
device_name=name,
|
||||
call_exception=call_exception)
|
||||
|
||||
|
||||
def _load_trezor():
|
||||
try:
|
||||
from trezorlib.client import TrezorClient, CallException
|
||||
from trezorlib.transport_hid import HidTransport
|
||||
from trezorlib.messages_pb2 import PassphraseAck
|
||||
from trezorlib.types_pb2 import IdentityType
|
||||
return _load_client(name='Trezor',
|
||||
client_type=TrezorClient,
|
||||
hid_transport=HidTransport,
|
||||
passphrase_ack=PassphraseAck,
|
||||
identity_type=IdentityType,
|
||||
required_version='>=1.4.0',
|
||||
call_exception=CallException)
|
||||
except ImportError:
|
||||
log.exception('Missing module: install via "pip install trezor"')
|
||||
|
||||
|
||||
def _load_keepkey():
|
||||
try:
|
||||
from keepkeylib.client import KeepKeyClient, CallException
|
||||
from keepkeylib.transport_hid import HidTransport
|
||||
from keepkeylib.messages_pb2 import PassphraseAck
|
||||
from keepkeylib.types_pb2 import IdentityType
|
||||
return _load_client(name='KeepKey',
|
||||
client_type=KeepKeyClient,
|
||||
hid_transport=HidTransport,
|
||||
passphrase_ack=PassphraseAck,
|
||||
identity_type=IdentityType,
|
||||
required_version='>=1.0.4',
|
||||
call_exception=CallException)
|
||||
except ImportError:
|
||||
log.exception('Missing module: install via "pip install keepkey"')
|
||||
|
||||
|
||||
def _load_ledger():
|
||||
import struct
|
||||
|
||||
class LedgerClientConnection(object):
|
||||
def __init__(self, dongle):
|
||||
self.dongle = dongle
|
||||
|
||||
@staticmethod
|
||||
def expand_path(path):
|
||||
result = ""
|
||||
for pathElement in path:
|
||||
result = result + struct.pack(">I", pathElement)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def convert_public_key(ecdsa_curve_name, result):
|
||||
from trezorlib.messages_pb2 import PublicKey # pylint: disable=import-error
|
||||
if ecdsa_curve_name == "nist256p1":
|
||||
if (result[64] & 1) != 0:
|
||||
result = bytearray([0x03]) + result[1:33]
|
||||
else:
|
||||
result = bytearray([0x02]) + result[1:33]
|
||||
else:
|
||||
result = result[1:]
|
||||
keyX = bytearray(result[0:32])
|
||||
keyY = bytearray(result[32:][::-1])
|
||||
if (keyX[31] & 1) != 0:
|
||||
keyY[31] |= 0x80
|
||||
result = chr(0) + str(keyY)
|
||||
publicKey = PublicKey()
|
||||
publicKey.node.public_key = str(result)
|
||||
return publicKey
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_public_node(self, n, ecdsa_curve_name="secp256k1", show_display=False):
|
||||
donglePath = LedgerClientConnection.expand_path(n)
|
||||
if ecdsa_curve_name == "nist256p1":
|
||||
p2 = "01"
|
||||
else:
|
||||
p2 = "02"
|
||||
apdu = "800200" + p2
|
||||
apdu = apdu.decode('hex')
|
||||
apdu += chr(len(donglePath) + 1) + chr(len(donglePath) / 4)
|
||||
apdu += donglePath
|
||||
result = bytearray(self.dongle.exchange(bytes(apdu)))[1:]
|
||||
return LedgerClientConnection.convert_public_key(ecdsa_curve_name, result)
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def sign_identity(self, identity, challenge_hidden, challenge_visual,
|
||||
ecdsa_curve_name="secp256k1"):
|
||||
from trezorlib.messages_pb2 import SignedIdentity # pylint: disable=import-error
|
||||
n = util.get_bip32_address(identity)
|
||||
donglePath = LedgerClientConnection.expand_path(n)
|
||||
if identity.proto == 'ssh':
|
||||
ins = "04"
|
||||
p1 = "00"
|
||||
else:
|
||||
ins = "08"
|
||||
p1 = "00"
|
||||
if ecdsa_curve_name == "nist256p1":
|
||||
p2 = "81" if identity.proto == 'ssh' else "01"
|
||||
else:
|
||||
p2 = "82" if identity.proto == 'ssh' else "02"
|
||||
apdu = "80" + ins + p1 + p2
|
||||
apdu = apdu.decode('hex')
|
||||
apdu += chr(len(challenge_hidden) + len(donglePath) + 1)
|
||||
apdu += chr(len(donglePath) / 4) + donglePath
|
||||
apdu += challenge_hidden
|
||||
result = bytearray(self.dongle.exchange(bytes(apdu)))
|
||||
if ecdsa_curve_name == "nist256p1":
|
||||
offset = 3
|
||||
length = result[offset]
|
||||
r = result[offset+1:offset+1+length]
|
||||
if r[0] == 0:
|
||||
r = r[1:]
|
||||
offset = offset + 1 + length + 1
|
||||
length = result[offset]
|
||||
s = result[offset+1:offset+1+length]
|
||||
if s[0] == 0:
|
||||
s = s[1:]
|
||||
offset = offset + 1 + length
|
||||
signature = SignedIdentity()
|
||||
signature.signature = chr(0) + str(r) + str(s)
|
||||
if identity.proto == 'ssh':
|
||||
keyData = result[offset:]
|
||||
pk = LedgerClientConnection.convert_public_key(ecdsa_curve_name, keyData)
|
||||
signature.public_key = pk.node.public_key
|
||||
return signature
|
||||
else:
|
||||
signature = SignedIdentity()
|
||||
signature.signature = chr(0) + str(result[0:64])
|
||||
if identity.proto == 'ssh':
|
||||
keyData = result[64:]
|
||||
pk = LedgerClientConnection.convert_public_key(ecdsa_curve_name, keyData)
|
||||
signature.public_key = pk.node.public_key
|
||||
return signature
|
||||
|
||||
def get_ecdh_session_key(self, identity, peer_public_key, ecdsa_curve_name="secp256k1"):
|
||||
from trezorlib.messages_pb2 import ECDHSessionKey # pylint: disable=import-error
|
||||
n = util.get_bip32_address(identity, True)
|
||||
donglePath = LedgerClientConnection.expand_path(n)
|
||||
if ecdsa_curve_name == "nist256p1":
|
||||
p2 = "01"
|
||||
else:
|
||||
p2 = "02"
|
||||
apdu = "800a00" + p2
|
||||
apdu = apdu.decode('hex')
|
||||
apdu += chr(len(peer_public_key) + len(donglePath) + 1)
|
||||
apdu += chr(len(donglePath) / 4) + donglePath
|
||||
apdu += peer_public_key
|
||||
result = bytearray(self.dongle.exchange(bytes(apdu)))
|
||||
sessionKey = ECDHSessionKey()
|
||||
sessionKey.session_key = str(result)
|
||||
return sessionKey
|
||||
|
||||
def clear_session(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.dongle.close()
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=no-self-use
|
||||
def ping(self, msg, button_protection=False, pin_protection=False,
|
||||
passphrase_protection=False):
|
||||
return msg
|
||||
|
||||
class CallException(Exception):
|
||||
def __init__(self, code, message):
|
||||
super(CallException, self).__init__()
|
||||
self.args = [code, message]
|
||||
try:
|
||||
from ledgerblue.comm import getDongle
|
||||
except ImportError:
|
||||
log.exception('Missing module: install via "pip install ledgerblue"')
|
||||
# pylint: disable=bare-except
|
||||
try:
|
||||
from trezorlib.types_pb2 import IdentityType # pylint: disable=import-error
|
||||
dongle = getDongle()
|
||||
except:
|
||||
return
|
||||
yield ClientWrapper(connection=LedgerClientConnection(dongle),
|
||||
identity_type=IdentityType,
|
||||
device_name="ledger",
|
||||
call_exception=CallException)
|
||||
|
||||
LOADERS = [
|
||||
_load_trezor,
|
||||
_load_keepkey,
|
||||
_load_ledger
|
||||
]
|
||||
|
||||
|
||||
def load(loaders=None):
|
||||
"""Load a single device, via specified loaders' list."""
|
||||
loaders = loaders if loaders is not None else LOADERS
|
||||
device_list = []
|
||||
for loader in loaders:
|
||||
device = loader()
|
||||
if device:
|
||||
device_list.extend(device)
|
||||
|
||||
if len(device_list) == 1:
|
||||
return device_list[0]
|
||||
|
||||
msg = '{:d} devices found'.format(len(device_list))
|
||||
raise IOError(msg)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
TREZOR support for ECDSA GPG signatures.
|
||||
|
||||
See these links for more details:
|
||||
- https://www.gnupg.org/faq/whats-new-in-2.1.html
|
||||
- https://tools.ietf.org/html/rfc4880
|
||||
- https://tools.ietf.org/html/rfc6637
|
||||
- https://tools.ietf.org/html/draft-irtf-cfrg-eddsa-05
|
||||
"""
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Create signatures and export public keys for GPG using TREZOR."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from . import agent, encode, keyring, protocol
|
||||
from .. import server
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_create(args):
|
||||
"""Generate a new pubkey for a new/existing GPG identity."""
|
||||
user_id = os.environ['TREZOR_GPG_USER_ID']
|
||||
log.warning('NOTE: in order to re-generate the exact same GPG key later, '
|
||||
'run this command with "--time=%d" commandline flag (to set '
|
||||
'the timestamp of the GPG key manually).', args.time)
|
||||
conn = encode.HardwareSigner(user_id=user_id,
|
||||
curve_name=args.ecdsa_curve)
|
||||
verifying_key = conn.pubkey(ecdh=False)
|
||||
decryption_key = conn.pubkey(ecdh=True)
|
||||
|
||||
if args.subkey:
|
||||
primary_bytes = keyring.export_public_key(user_id=user_id)
|
||||
# subkey for signing
|
||||
signing_key = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
encryption_key = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=decryption_key, ecdh=True)
|
||||
result = encode.create_subkey(primary_bytes=primary_bytes,
|
||||
pubkey=signing_key,
|
||||
signer_func=conn.sign)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
pubkey=encryption_key,
|
||||
signer_func=conn.sign)
|
||||
else:
|
||||
# primary key for signing
|
||||
primary = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=verifying_key, ecdh=False)
|
||||
# subkey for encryption
|
||||
subkey = protocol.PublicKey(
|
||||
curve_name=args.ecdsa_curve, created=args.time,
|
||||
verifying_key=decryption_key, ecdh=True)
|
||||
|
||||
result = encode.create_primary(user_id=user_id,
|
||||
pubkey=primary,
|
||||
signer_func=conn.sign)
|
||||
result = encode.create_subkey(primary_bytes=result,
|
||||
pubkey=subkey,
|
||||
signer_func=conn.sign)
|
||||
|
||||
sys.stdout.write(protocol.armor(result, 'PUBLIC KEY BLOCK'))
|
||||
|
||||
|
||||
def run_agent(args): # pylint: disable=unused-argument
|
||||
"""Run a simple GPG-agent server."""
|
||||
sock_path = keyring.get_agent_sock_path()
|
||||
with server.unix_domain_socket_server(sock_path) as sock:
|
||||
for conn in agent.yield_connections(sock):
|
||||
with contextlib.closing(conn):
|
||||
agent.handle_connection(conn)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('-v', '--verbose', action='store_true', default=False)
|
||||
subparsers = p.add_subparsers()
|
||||
subparsers.required = True
|
||||
subparsers.dest = 'command'
|
||||
|
||||
create_cmd = subparsers.add_parser('create')
|
||||
create_cmd.add_argument('-s', '--subkey', action='store_true', default=False)
|
||||
create_cmd.add_argument('-e', '--ecdsa-curve', default='nist256p1')
|
||||
create_cmd.add_argument('-t', '--time', type=int, default=int(time.time()))
|
||||
create_cmd.set_defaults(run=run_create)
|
||||
|
||||
agent_cmd = subparsers.add_parser('agent')
|
||||
agent_cmd.set_defaults(run=run_agent)
|
||||
|
||||
args = p.parse_args()
|
||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format='%(asctime)s %(levelname)-10s %(message)s')
|
||||
log.warning('This GPG tool is still in EXPERIMENTAL mode, '
|
||||
'so please note that the API and features may '
|
||||
'change without backwards compatibility!')
|
||||
args.run(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,142 +0,0 @@
|
||||
"""GPG-agent utilities."""
|
||||
import binascii
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from . import decode, encode, keyring
|
||||
from .. import util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def yield_connections(sock):
|
||||
"""Run a server on the specified socket."""
|
||||
while True:
|
||||
log.debug('waiting for connection on %s', sock.getsockname())
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
conn.settimeout(None)
|
||||
log.debug('accepted connection on %s', sock.getsockname())
|
||||
yield conn
|
||||
|
||||
|
||||
def serialize(data):
|
||||
"""Serialize data according to ASSUAN protocol."""
|
||||
for c in ['%', '\n', '\r']:
|
||||
data = data.replace(c, '%{:02X}'.format(ord(c)))
|
||||
return data
|
||||
|
||||
|
||||
def sig_encode(r, s):
|
||||
"""Serialize ECDSA signature data into GPG S-expression."""
|
||||
r = serialize(util.num2bytes(r, 32))
|
||||
s = serialize(util.num2bytes(s, 32))
|
||||
return '(7:sig-val(5:ecdsa(1:r32:{})(1:s32:{})))'.format(r, s)
|
||||
|
||||
|
||||
def pksign(keygrip, digest, algo):
|
||||
"""Sign a message digest using a private EC key."""
|
||||
assert algo == '8', 'Unsupported hash algorithm ID {}'.format(algo)
|
||||
user_id = os.environ['TREZOR_GPG_USER_ID']
|
||||
pubkey_dict = decode.load_public_key(
|
||||
pubkey_bytes=keyring.export_public_key(user_id=user_id),
|
||||
use_custom=True, ecdh=False)
|
||||
pubkey, conn = encode.load_from_public_key(pubkey_dict=pubkey_dict)
|
||||
with contextlib.closing(conn):
|
||||
assert pubkey.keygrip == binascii.unhexlify(keygrip)
|
||||
r, s = conn.sign(binascii.unhexlify(digest))
|
||||
result = sig_encode(r, s)
|
||||
log.debug('result: %r', result)
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_point(data):
|
||||
data = '{}:'.format(len(data)) + data
|
||||
# https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html
|
||||
for c in ['%', '\n', '\r']:
|
||||
data = data.replace(c, '%{:02X}'.format(ord(c)))
|
||||
return '(5:value' + data + ')'
|
||||
|
||||
|
||||
def parse_ecdh(line):
|
||||
"""Parse ECDH request and return remote public key."""
|
||||
prefix, line = line.split(' ', 1)
|
||||
assert prefix == 'D'
|
||||
exp, leftover = keyring.parse(keyring.unescape(line))
|
||||
log.debug('ECDH s-exp: %r', exp)
|
||||
assert not leftover
|
||||
label, exp = exp
|
||||
assert label == b'enc-val'
|
||||
assert exp[0] == b'ecdh'
|
||||
items = exp[1:]
|
||||
log.debug('ECDH parameters: %r', items)
|
||||
return dict(items)['e']
|
||||
|
||||
|
||||
def pkdecrypt(keygrip, 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)
|
||||
|
||||
user_id = os.environ['TREZOR_GPG_USER_ID']
|
||||
local_pubkey = decode.load_public_key(
|
||||
pubkey_bytes=keyring.export_public_key(user_id=user_id),
|
||||
use_custom=True, ecdh=True)
|
||||
pubkey, conn = encode.load_from_public_key(pubkey_dict=local_pubkey)
|
||||
with contextlib.closing(conn):
|
||||
assert pubkey.keygrip == binascii.unhexlify(keygrip)
|
||||
shared_secret = conn.ecdh(remote_pubkey)
|
||||
|
||||
assert len(shared_secret) == 65
|
||||
assert shared_secret[:1] == b'\x04'
|
||||
return _serialize_point(shared_secret)
|
||||
|
||||
|
||||
def handle_connection(conn):
|
||||
"""Handle connection from GPG binary using the ASSUAN protocol."""
|
||||
keygrip = None
|
||||
digest = None
|
||||
algo = None
|
||||
version = keyring.gpg_version()
|
||||
|
||||
keyring.sendline(conn, b'OK')
|
||||
for line in keyring.iterlines(conn):
|
||||
parts = line.split(' ')
|
||||
command = parts[0]
|
||||
args = parts[1:]
|
||||
if command in {'RESET', 'OPTION', 'HAVEKEY', 'SETKEYDESC'}:
|
||||
pass # reply with OK
|
||||
elif command == 'GETINFO':
|
||||
keyring.sendline(conn, b'D ' + version)
|
||||
elif command == 'AGENT_ID':
|
||||
keyring.sendline(conn, b'D TREZOR')
|
||||
elif command in {'SIGKEY', 'SETKEY'}:
|
||||
keygrip, = args
|
||||
elif command == 'SETHASH':
|
||||
algo, digest = args
|
||||
elif command == 'PKSIGN':
|
||||
sig = pksign(keygrip, digest, algo)
|
||||
keyring.sendline(conn, b'D ' + sig)
|
||||
elif command == 'PKDECRYPT':
|
||||
sec = pkdecrypt(keygrip, conn)
|
||||
keyring.sendline(conn, b'D ' + sec)
|
||||
elif command == 'KEYINFO':
|
||||
keygrip, = args
|
||||
# Dummy reply (mainly for 'gpg --edit' to succeed).
|
||||
# For details, see GnuPG agent KEYINFO command help.
|
||||
fmt = b'S KEYINFO {0} X - - - - - - -'
|
||||
keyring.sendline(conn, fmt.format(keygrip))
|
||||
elif command == 'BYE':
|
||||
return
|
||||
else:
|
||||
log.error('unknown request: %r', line)
|
||||
return
|
||||
|
||||
keyring.sendline(conn, b'OK')
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Create GPG ECDSA signatures and public keys using TREZOR device."""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from . import decode, keyring, protocol
|
||||
from .. import factory, formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HardwareSigner(object):
|
||||
"""Sign messages and get public keys from a hardware device."""
|
||||
|
||||
def __init__(self, user_id, curve_name):
|
||||
"""Connect to the device and retrieve required public key."""
|
||||
self.client_wrapper = factory.load()
|
||||
self.identity = self.client_wrapper.identity_type()
|
||||
self.identity.proto = 'gpg'
|
||||
self.identity.host = user_id
|
||||
self.curve_name = curve_name
|
||||
|
||||
def pubkey(self, ecdh=False):
|
||||
"""Return public key as VerifyingKey object."""
|
||||
addr = util.get_bip32_address(identity=self.identity, ecdh=ecdh)
|
||||
public_node = self.client_wrapper.connection.get_public_node(
|
||||
n=addr, ecdsa_curve_name=self.curve_name)
|
||||
|
||||
return formats.decompress_pubkey(
|
||||
pubkey=public_node.node.public_key,
|
||||
curve_name=self.curve_name)
|
||||
|
||||
def sign(self, digest):
|
||||
"""Sign the digest and return a serialized signature."""
|
||||
result = self.client_wrapper.connection.sign_identity(
|
||||
identity=self.identity,
|
||||
challenge_hidden=digest,
|
||||
challenge_visual='',
|
||||
ecdsa_curve_name=self.curve_name)
|
||||
assert result.signature[:1] == b'\x00'
|
||||
sig = result.signature[1:]
|
||||
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
|
||||
|
||||
def ecdh(self, pubkey):
|
||||
"""Derive shared secret using ECDH from remote public key."""
|
||||
result = self.client_wrapper.connection.get_ecdh_session_key(
|
||||
identity=self.identity,
|
||||
peer_public_key=pubkey,
|
||||
ecdsa_curve_name=self.curve_name)
|
||||
assert len(result.session_key) == 65
|
||||
assert result.session_key[:1] == b'\x04'
|
||||
return result.session_key
|
||||
|
||||
def close(self):
|
||||
"""Close the connection to the device."""
|
||||
self.client_wrapper.connection.close()
|
||||
|
||||
|
||||
class AgentSigner(object):
|
||||
"""Sign messages and get public keys using gpg-agent tool."""
|
||||
|
||||
def __init__(self, user_id):
|
||||
"""Connect to the agent and retrieve required public key."""
|
||||
self.sock = keyring.connect_to_agent()
|
||||
self.keygrip = keyring.get_keygrip(user_id)
|
||||
|
||||
def sign(self, digest):
|
||||
"""Sign the digest and return an ECDSA/RSA/DSA signature."""
|
||||
return keyring.sign_digest(sock=self.sock,
|
||||
keygrip=self.keygrip, digest=digest)
|
||||
|
||||
def close(self):
|
||||
"""Close the connection to gpg-agent."""
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def _time_format(t):
|
||||
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))
|
||||
|
||||
|
||||
def create_primary(user_id, pubkey, signer_func):
|
||||
"""Export new primary GPG public key, ready for "gpg2 --import"."""
|
||||
pubkey_packet = protocol.packet(tag=6, blob=pubkey.data())
|
||||
user_id_packet = protocol.packet(tag=13,
|
||||
blob=user_id.encode('ascii'))
|
||||
|
||||
data_to_sign = (pubkey.data_to_hash() +
|
||||
user_id_packet[:1] +
|
||||
util.prefix_len('>L', user_id.encode('ascii')))
|
||||
log.info('creating primary GPG key "%s"', user_id)
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(pubkey.created), # signature time
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.7
|
||||
protocol.subpacket_byte(0x0B, 9), # preferred symmetric algo (AES-256)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.4
|
||||
protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
protocol.subpacket_byte(0x15, 8), # preferred hash (SHA256)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
|
||||
protocol.subpacket_byte(0x16, 0), # preferred compression (none)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
|
||||
protocol.subpacket_byte(0x17, 0x80) # key server prefs (no-modify)
|
||||
# https://tools.ietf.org/html/rfc4880#section-5.2.3.17
|
||||
]
|
||||
unhashed_subpackets = [
|
||||
protocol.subpacket(16, pubkey.key_id()), # issuer key id
|
||||
protocol.CUSTOM_SUBPACKET]
|
||||
|
||||
log.info('confirm signing with primary key')
|
||||
signature = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
public_algo=pubkey.algo_id,
|
||||
data_to_sign=data_to_sign,
|
||||
sig_type=0x13, # user id & public key
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
|
||||
sign_packet = protocol.packet(tag=2, blob=signature)
|
||||
return pubkey_packet + user_id_packet + sign_packet
|
||||
|
||||
|
||||
def create_subkey(primary_bytes, pubkey, signer_func):
|
||||
"""Export new subkey to GPG primary key."""
|
||||
subkey_packet = protocol.packet(tag=14, blob=pubkey.data())
|
||||
primary = decode.load_public_key(primary_bytes)
|
||||
log.info('adding subkey to primary GPG key "%s"', primary['user_id'])
|
||||
data_to_sign = primary['_to_hash'] + pubkey.data_to_hash()
|
||||
|
||||
if pubkey.ecdh:
|
||||
embedded_sig = None
|
||||
else:
|
||||
# Primary Key Binding Signature
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(pubkey.created)] # signature time
|
||||
unhashed_subpackets = [
|
||||
protocol.subpacket(16, pubkey.key_id())] # issuer key id
|
||||
log.info('confirm signing with new subkey')
|
||||
embedded_sig = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=data_to_sign,
|
||||
public_algo=pubkey.algo_id,
|
||||
sig_type=0x19,
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
|
||||
# Subkey Binding Signature
|
||||
|
||||
# Key flags: https://tools.ietf.org/html/rfc4880#section-5.2.3.21
|
||||
# (certify & sign) (encrypt)
|
||||
flags = (2) if (not pubkey.ecdh) else (4 | 8)
|
||||
|
||||
hashed_subpackets = [
|
||||
protocol.subpacket_time(pubkey.created), # signature time
|
||||
protocol.subpacket_byte(0x1B, flags)]
|
||||
|
||||
unhashed_subpackets = []
|
||||
unhashed_subpackets.append(protocol.subpacket(16, primary['key_id']))
|
||||
if embedded_sig is not None:
|
||||
unhashed_subpackets.append(protocol.subpacket(32, embedded_sig))
|
||||
unhashed_subpackets.append(protocol.CUSTOM_SUBPACKET)
|
||||
|
||||
log.info('confirm signing with primary key')
|
||||
if not primary['_is_custom']:
|
||||
signer_func = AgentSigner(primary['user_id']).sign
|
||||
|
||||
signature = protocol.make_signature(
|
||||
signer_func=signer_func,
|
||||
data_to_sign=data_to_sign,
|
||||
public_algo=primary['algo'],
|
||||
sig_type=0x18,
|
||||
hashed_subpackets=hashed_subpackets,
|
||||
unhashed_subpackets=unhashed_subpackets)
|
||||
sign_packet = protocol.packet(tag=2, blob=signature)
|
||||
return primary_bytes + subkey_packet + sign_packet
|
||||
|
||||
|
||||
def load_from_public_key(pubkey_dict):
|
||||
"""Load correct public key from the device."""
|
||||
user_id = pubkey_dict['user_id']
|
||||
created = pubkey_dict['created']
|
||||
curve_name = protocol.find_curve_by_algo_id(pubkey_dict['algo'])
|
||||
assert curve_name in formats.SUPPORTED_CURVES
|
||||
ecdh = (pubkey_dict['algo'] == protocol.ECDH_ALGO_ID)
|
||||
|
||||
conn = HardwareSigner(user_id, curve_name=curve_name)
|
||||
pubkey = protocol.PublicKey(
|
||||
curve_name=curve_name, created=created,
|
||||
verifying_key=conn.pubkey(ecdh=ecdh), ecdh=ecdh)
|
||||
assert pubkey.key_id() == pubkey_dict['key_id']
|
||||
log.info('%s created at %s for "%s"',
|
||||
pubkey, _time_format(pubkey.created), user_id)
|
||||
|
||||
return pubkey, conn
|
||||
@@ -1,124 +0,0 @@
|
||||
import glob
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import decode, protocol
|
||||
from ... import util
|
||||
|
||||
|
||||
def test_subpackets():
|
||||
s = io.BytesIO(b'\x00\x05\x02\xAB\xCD\x01\xEF')
|
||||
assert decode.parse_subpackets(util.Reader(s)) == [b'\xAB\xCD', b'\xEF']
|
||||
|
||||
|
||||
def test_subpackets_prefix():
|
||||
for n in [0, 1, 2, 4, 5, 10, 191, 192, 193,
|
||||
255, 256, 257, 8383, 8384, 65530]:
|
||||
item = b'?' * n # create dummy subpacket
|
||||
prefixed = protocol.subpackets(item)
|
||||
result = decode.parse_subpackets(util.Reader(io.BytesIO(prefixed)))
|
||||
assert [item] == result
|
||||
|
||||
|
||||
def test_mpi():
|
||||
s = io.BytesIO(b'\x00\x09\x01\x23')
|
||||
assert decode.parse_mpi(util.Reader(s)) == 0x123
|
||||
|
||||
s = io.BytesIO(b'\x00\x09\x01\x23\x00\x03\x05')
|
||||
assert decode.parse_mpis(util.Reader(s), n=2) == [0x123, 5]
|
||||
|
||||
|
||||
def assert_subdict(d, s):
|
||||
for k, v in s.items():
|
||||
assert d[k] == v
|
||||
|
||||
|
||||
def test_primary_nist256p1():
|
||||
# pylint: disable=line-too-long
|
||||
data = b'''-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v2
|
||||
|
||||
mFIEV0hI1hMIKoZIzj0DAQcCAwTXY7aq01xMPSU7gTHU9B7Z2CFoCk1Y4WYb8Tiy
|
||||
hurvIZ5la6+UEgAKF9HXpQo0yE+HQOgufoLlCpdE7NoEUb+HtAd0ZXN0aW5niHYE
|
||||
ExMIABIFAldISNYCGwMCFQgCFgACF4AAFgkQTcCehfpEIPILZFRSRVpPUi1HUEeV
|
||||
3QEApHKmBkbLVZNpsB8q9mBzKytxnOHNB3QWDuoKJu/ERi4A/1wRGZ/B0BDazHck
|
||||
zpR9luXTKwMEl+mlZmwEFKZXBmir
|
||||
=oyj0
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
'''
|
||||
stream = io.BytesIO(decode.remove_armor(data))
|
||||
pubkey, user_id, signature = list(decode.parse_packets(stream))
|
||||
expected_pubkey = {
|
||||
'created': 1464355030, 'type': 'pubkey', 'tag': 6,
|
||||
'version': 4, 'algo': 19, 'key_id': b'M\xc0\x9e\x85\xfaD \xf2',
|
||||
'_to_hash': b'\x99\x00R\x04WHH\xd6\x13\x08*\x86H\xce=\x03\x01\x07\x02\x03\x04\xd7c\xb6\xaa\xd3\\L=%;\x811\xd4\xf4\x1e\xd9\xd8!h\nMX\xe1f\x1b\xf18\xb2\x86\xea\xef!\x9eek\xaf\x94\x12\x00\n\x17\xd1\xd7\xa5\n4\xc8O\x87@\xe8.~\x82\xe5\n\x97D\xec\xda\x04Q\xbf\x87' # nopep8
|
||||
}
|
||||
assert_subdict(pubkey, expected_pubkey)
|
||||
point = pubkey['verifying_key'].pubkey.point
|
||||
assert point.x(), point.y() == (
|
||||
97423441028100245505102979561460969898742433559010922791700160771755342491425,
|
||||
71644624850142103522769833619875243486871666152651730678601507641225861250951
|
||||
)
|
||||
assert_subdict(user_id, {
|
||||
'tag': 13, 'type': 'user_id', 'value': b'testing',
|
||||
'_to_hash': b'\xb4\x00\x00\x00\x07testing'
|
||||
})
|
||||
assert_subdict(signature, {
|
||||
'pubkey_alg': 19, '_is_custom': True, 'hash_alg': 8, 'tag': 2,
|
||||
'sig_type': 19, 'version': 4, 'type': 'signature', 'hash_prefix': b'\x95\xdd',
|
||||
'sig': (74381873592149178031432444136130575481350858387410643140628758456112511206958,
|
||||
41642995320462795718437755373080464775445470754419831653624197847615308982443),
|
||||
'hashed_subpackets': [b'\x02WHH\xd6', b'\x1b\x03', b'\x15\x08', b'\x16\x00', b'\x17\x80'],
|
||||
'unhashed_subpackets': [b'\x10M\xc0\x9e\x85\xfaD \xf2', b'dTREZOR-GPG'],
|
||||
'_to_hash': b'\x04\x13\x13\x08\x00\x12\x05\x02WHH\xd6\x02\x1b\x03\x02\x15\x08\x02\x16\x00\x02\x17\x80\x04\xff\x00\x00\x00\x18' # nopep8
|
||||
})
|
||||
|
||||
digest = decode.digest_packets(packets=[pubkey, user_id, signature],
|
||||
hasher=hashlib.sha256())
|
||||
decode.verify_digest(pubkey=pubkey, digest=digest,
|
||||
signature=signature['sig'],
|
||||
label='GPG primary public key')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
bad_digest = b'\x00' * len(digest)
|
||||
decode.verify_digest(pubkey=pubkey, digest=bad_digest,
|
||||
signature=signature['sig'],
|
||||
label='GPG primary public key')
|
||||
|
||||
message = b'Hello, World!\n'
|
||||
signature = b'''-----BEGIN PGP SIGNATURE-----
|
||||
Version: GnuPG v2
|
||||
|
||||
iF4EABMIAAYFAldIlfQACgkQTcCehfpEIPKOUgD9FjaeWla4wOuDZ7P6fhkT5nZp
|
||||
KDQU0N5KmNwLlt2kwo4A/jQkBII2cI8tTqOVTLNRXXqIOsMf/fG4jKM/VOFc/01c
|
||||
=dC+z
|
||||
-----END PGP SIGNATURE-----
|
||||
'''
|
||||
decode.verify(pubkey=pubkey, signature=signature, original_data=message)
|
||||
|
||||
pubkey = decode.load_public_key(pubkey_bytes=decode.remove_armor(data))
|
||||
assert_subdict(pubkey, expected_pubkey)
|
||||
assert_subdict(pubkey, {'user_id': b'testing'})
|
||||
|
||||
pubkey = decode.load_public_key(pubkey_bytes=decode.remove_armor(data),
|
||||
use_custom=True)
|
||||
assert_subdict(pubkey, expected_pubkey)
|
||||
assert_subdict(pubkey, {'user_id': b'testing'})
|
||||
|
||||
|
||||
cwd = os.path.join(os.path.dirname(__file__))
|
||||
input_files = glob.glob(os.path.join(cwd, '*.gpg'))
|
||||
|
||||
|
||||
@pytest.fixture(params=input_files)
|
||||
def public_key_path(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def test_gpg_files(public_key_path): # pylint: disable=redefined-outer-name
|
||||
with open(public_key_path, 'rb') as f:
|
||||
keys = decode.parse_public_keys(f)
|
||||
assert len(keys) > 0
|
||||
@@ -1,136 +0,0 @@
|
||||
import io
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import client, factory, formats, util
|
||||
|
||||
ADDR = [2147483661, 2810943954, 3938368396, 3454558782, 3848009040]
|
||||
CURVE = 'nist256p1'
|
||||
|
||||
PUBKEY = (b'\x03\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis')
|
||||
PUBKEY_TEXT = ('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd'
|
||||
'HAyNTYAAABBBNgotaZgvnQwlaw6Wztd3Cy93D/XwOzdvCv6cn6dQWlzNMEQeW'
|
||||
'VUfhvrGljR2Z/CMRONY6ejB+9PnpUOPuzYqi8= ssh://localhost:22\n')
|
||||
|
||||
|
||||
class FakeConnection(object):
|
||||
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def clear_session(self):
|
||||
self.closed = True
|
||||
|
||||
def get_public_node(self, n, ecdsa_curve_name=b'secp256k1'):
|
||||
assert not self.closed
|
||||
assert n == ADDR
|
||||
assert ecdsa_curve_name in {'secp256k1', 'nist256p1'}
|
||||
result = mock.Mock(spec=[])
|
||||
result.node = mock.Mock(spec=[])
|
||||
result.node.public_key = PUBKEY
|
||||
return result
|
||||
|
||||
def ping(self, msg):
|
||||
assert not self.closed
|
||||
return msg
|
||||
|
||||
|
||||
def identity_type(**kwargs):
|
||||
result = mock.Mock(spec=[])
|
||||
result.index = 0
|
||||
result.proto = result.user = result.host = result.port = None
|
||||
result.path = None
|
||||
for k, v in kwargs.items():
|
||||
setattr(result, k, v)
|
||||
return result
|
||||
|
||||
|
||||
def load_client():
|
||||
return factory.ClientWrapper(connection=FakeConnection(),
|
||||
identity_type=identity_type,
|
||||
device_name='DEVICE_NAME',
|
||||
call_exception=Exception)
|
||||
|
||||
|
||||
BLOB = (b'\x00\x00\x00 \xce\xe0\xc9\xd5\xceu/\xe8\xc5\xf2\xbfR+x\xa1\xcf\xb0'
|
||||
b'\x8e;R\xd3)m\x96\x1b\xb4\xd8s\xf1\x99\x16\xaa2\x00\x00\x00\x05roman'
|
||||
b'\x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey'
|
||||
b'\x01\x00\x00\x00\x13ecdsa-sha2-nistp256\x00\x00\x00h\x00\x00\x00'
|
||||
b'\x13ecdsa-sha2-nistp256\x00\x00\x00\x08nistp256\x00\x00\x00A'
|
||||
b'\x04\xd8(\xb5\xa6`\xbet0\x95\xac:[;]\xdc,\xbd\xdc?\xd7\xc0\xec'
|
||||
b'\xdd\xbc+\xfar~\x9dAis4\xc1\x10yeT~\x1b\xeb\x1aX\xd1\xd9\x9f\xc21'
|
||||
b'\x13\x8dc\xa7\xa3\x07\xefO\x9e\x95\x0e>\xec\xd8\xaa/')
|
||||
|
||||
SIG = (b'\x00R\x19T\xf2\x84$\xef#\x0e\xee\x04X\xc6\xc3\x99T`\xd1\xd8\xf7!'
|
||||
b'\x862@cx\xb8\xb9i@1\x1b3#\x938\x86]\x97*Y\xb2\x02Xa\xdf@\xecK'
|
||||
b'\xdc\xf0H\xab\xa8\xac\xa7? \x8f=C\x88N\xe2')
|
||||
|
||||
|
||||
def test_ssh_agent():
|
||||
label = 'localhost:22'
|
||||
c = client.Client(loader=load_client)
|
||||
ident = c.get_identity(label=label)
|
||||
assert ident.host == 'localhost'
|
||||
assert ident.proto == 'ssh'
|
||||
assert ident.port == '22'
|
||||
assert ident.user is None
|
||||
assert ident.path is None
|
||||
assert ident.index == 0
|
||||
|
||||
with c:
|
||||
assert c.get_public_key(label) == PUBKEY_TEXT
|
||||
|
||||
def ssh_sign_identity(identity, challenge_hidden,
|
||||
challenge_visual, ecdsa_curve_name):
|
||||
assert (util.identity_to_string(identity) ==
|
||||
util.identity_to_string(ident))
|
||||
assert challenge_hidden == BLOB
|
||||
assert challenge_visual == ''
|
||||
assert ecdsa_curve_name == 'nist256p1'
|
||||
|
||||
result = mock.Mock(spec=[])
|
||||
result.public_key = PUBKEY
|
||||
result.signature = SIG
|
||||
return result
|
||||
|
||||
c.client.sign_identity = ssh_sign_identity
|
||||
signature = c.sign_ssh_challenge(label=label, blob=BLOB)
|
||||
|
||||
key = formats.import_public_key(PUBKEY_TEXT)
|
||||
serialized_sig = key['verifier'](sig=signature, msg=BLOB)
|
||||
|
||||
stream = io.BytesIO(serialized_sig)
|
||||
r = util.read_frame(stream)
|
||||
s = util.read_frame(stream)
|
||||
assert not stream.read()
|
||||
assert r[:1] == b'\x00'
|
||||
assert s[:1] == b'\x00'
|
||||
assert r[1:] + s[1:] == SIG[1:]
|
||||
|
||||
c.client.call_exception = ValueError
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def cancel_sign_identity(identity, challenge_hidden,
|
||||
challenge_visual, ecdsa_curve_name):
|
||||
raise c.client.call_exception(42, 'ERROR')
|
||||
|
||||
c.client.sign_identity = cancel_sign_identity
|
||||
with pytest.raises(IOError):
|
||||
c.sign_ssh_challenge(label=label, blob=BLOB)
|
||||
|
||||
|
||||
def test_utils():
|
||||
identity = mock.Mock(spec=[])
|
||||
identity.proto = 'https'
|
||||
identity.user = 'user'
|
||||
identity.host = 'host'
|
||||
identity.port = '443'
|
||||
identity.path = '/path'
|
||||
|
||||
url = 'https://user@host:443/path'
|
||||
assert util.identity_to_string(identity) == url
|
||||
@@ -1,97 +0,0 @@
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from .. import factory
|
||||
|
||||
|
||||
def test_load():
|
||||
|
||||
def single():
|
||||
return [0]
|
||||
|
||||
def nothing():
|
||||
return []
|
||||
|
||||
def double():
|
||||
return [1, 2]
|
||||
|
||||
assert factory.load(loaders=[single]) == 0
|
||||
assert factory.load(loaders=[single, nothing]) == 0
|
||||
assert factory.load(loaders=[nothing, single]) == 0
|
||||
|
||||
with pytest.raises(IOError):
|
||||
factory.load(loaders=[])
|
||||
|
||||
with pytest.raises(IOError):
|
||||
factory.load(loaders=[single, single])
|
||||
|
||||
with pytest.raises(IOError):
|
||||
factory.load(loaders=[double])
|
||||
|
||||
|
||||
def factory_load_client(**kwargs):
|
||||
# pylint: disable=protected-access
|
||||
return list(factory._load_client(**kwargs))
|
||||
|
||||
|
||||
def test_load_nothing():
|
||||
hid_transport = mock.Mock(spec_set=['enumerate'])
|
||||
hid_transport.enumerate.return_value = []
|
||||
result = factory_load_client(
|
||||
name=None,
|
||||
client_type=None,
|
||||
hid_transport=hid_transport,
|
||||
passphrase_ack=None,
|
||||
identity_type=None,
|
||||
required_version=None,
|
||||
call_exception=None)
|
||||
assert result == []
|
||||
|
||||
|
||||
def create_client_type(version):
|
||||
conn = mock.Mock(spec=[])
|
||||
conn.features = mock.Mock(spec=[])
|
||||
major, minor, patch = version.split('.')
|
||||
conn.features.device_id = 'DEVICE_ID'
|
||||
conn.features.label = 'LABEL'
|
||||
conn.features.vendor = 'VENDOR'
|
||||
conn.features.major_version = major
|
||||
conn.features.minor_version = minor
|
||||
conn.features.patch_version = patch
|
||||
conn.features.revision = b'\x12\x34\x56\x78'
|
||||
return mock.Mock(spec_set=[], return_value=conn)
|
||||
|
||||
|
||||
def test_load_single():
|
||||
hid_transport = mock.Mock(spec_set=['enumerate'])
|
||||
hid_transport.enumerate.return_value = [0]
|
||||
for version in ('1.3.4', '1.3.5', '1.4.0', '2.0.0'):
|
||||
passphrase_ack = mock.Mock(spec_set=[])
|
||||
client_type = create_client_type(version)
|
||||
client_wrapper, = factory_load_client(
|
||||
name='DEVICE_NAME',
|
||||
client_type=client_type,
|
||||
hid_transport=hid_transport,
|
||||
passphrase_ack=passphrase_ack,
|
||||
identity_type=None,
|
||||
required_version='>=1.3.4',
|
||||
call_exception=None)
|
||||
assert client_wrapper.connection is client_type.return_value
|
||||
assert client_wrapper.device_name == 'DEVICE_NAME'
|
||||
client_wrapper.connection.callback_PassphraseRequest('MESSAGE')
|
||||
assert passphrase_ack.mock_calls == [mock.call(passphrase='')]
|
||||
|
||||
|
||||
def test_load_old():
|
||||
hid_transport = mock.Mock(spec_set=['enumerate'])
|
||||
hid_transport.enumerate.return_value = [0]
|
||||
for version in ('1.3.3', '1.2.5', '1.1.0', '0.9.9'):
|
||||
with pytest.raises(ValueError):
|
||||
factory_load_client(
|
||||
name='DEVICE_NAME',
|
||||
client_type=create_client_type(version),
|
||||
hid_transport=hid_transport,
|
||||
passphrase_ack=None,
|
||||
identity_type=None,
|
||||
required_version='>=1.3.4',
|
||||
call_exception=None)
|
||||
Reference in New Issue
Block a user