Compare commits

...

187 Commits

Author SHA1 Message Date
Roman Zeyde
cb14d1e00b Bump version: 0.9.5 → 0.9.6 2017-12-14 20:45:42 +02:00
Roman Zeyde
cc6ee31deb add .bumpversion.cfg 2017-12-14 20:36:00 +02:00
Roman Zeyde
b1f302151b tests: fix test_server.py 2017-12-13 21:12:29 +02:00
Roman Zeyde
fde50f04ab Merge branch 'config' 2017-12-08 21:50:22 +02:00
Roman Zeyde
7e42e455a1 gpg: add an example for adding new user ID 2017-12-06 20:31:10 +02:00
Roman Zeyde
13cd6be2d1 travis: pep8 -> pycodestyle 2017-12-03 21:53:42 +02:00
Roman Zeyde
40469c4100 tox: pep8 -> pycodestyle 2017-12-02 21:14:03 +02:00
Roman Zeyde
0d059587a7 ssh: allow configuration from a file 2017-12-02 21:04:57 +02:00
Roman Zeyde
283cb3d7e8 setup: add ConfigArgParse 2017-12-02 20:43:04 +02:00
Roman Zeyde
51cc716e3f Merge pull request #169 from dirkx/master
Some background and designn rationale for the use of derived keys
2017-11-25 09:20:31 +02:00
Roman Zeyde
8b4850b0ce Explain rationale better, several typos fixed, include warning about keepkey not yet supporting encryption/decryption. 2017-11-24 15:27:06 +01:00
Roman Zeyde
f22c07e970 trezor: retry in case of invalid PIN 2017-11-18 20:37:06 +02:00
Roman Zeyde
29c7234ef4 trezor: make sure scrambled PIN is valid 2017-11-18 17:06:23 +02:00
Roman Zeyde
1942e3999b ssh: fix exception type for missing device 2017-11-18 16:50:21 +02:00
Roman Zeyde
f2e52a88be ` -> ' 2017-11-16 23:04:12 +02:00
Roman Zeyde
b26a4cc7b0 A few small fixes 2017-11-16 23:01:30 +02:00
Roman Zeyde
c4dfca04f2 trezor: use UI-based passphrase entry
Now TREZOR_PASSPHRASE environment variable is ignored.
2017-11-16 22:36:19 +02:00
Roman Zeyde
a1ecbf447e gpg: return correct keygrip for KEYINFO assuan command 2017-11-16 22:36:17 +02:00
Roman Zeyde
1f9d457e92 gpg: no need to remove the UNIX socket
Our agent should be invoked and re-used when running 'gpg --import'.
2017-11-16 21:53:38 +02:00
Roman Zeyde
cb3477fc69 Merge branch 'which-fix' 2017-11-16 21:04:12 +02:00
Roman Zeyde
9bbc66cc16 util: add backport for shutil.which() 2017-11-16 20:59:58 +02:00
Roman Zeyde
06afc971db Merge pull request #166 from aitorpazos/master
doc: Include python3 support in OpenSUSE instructions
2017-11-15 13:11:29 -08:00
Dirk-Willem van Gulik
2b51a85c26 Rework order of paragraphs for clarity II 2017-11-15 19:57:09 +01:00
Dirk-Willem van Gulik
1906e6d9b0 Rework order of paragraphs for clarity. 2017-11-15 19:56:10 +01:00
Dirk-Willem van Gulik
b3f6e39b48 First cut at a design rationale 2017-11-15 19:53:23 +01:00
Aitor Pazos
8b03b649d5 doc: Include python3 support in OpenSUSE instructions 2017-11-12 20:16:34 +00:00
Roman Zeyde
90cbc41b17 gpg: refactor messagebox UI from PIN entry 2017-11-11 11:16:31 +02:00
Roman Zeyde
4926d4f4d3 gpg: set PATH explicitly for $DEVICE-gpg-agent 2017-11-09 22:13:28 +02:00
Roman Zeyde
d52f295326 gpg: use shutil.which() for Python 3 2017-11-04 17:48:00 +02:00
Max Pixel
47a8a53247 Add OpenSUSE-specific installation instructions
The packages required are slightly different on OpenSUSE.
2017-11-04 17:45:57 +02:00
Roman Zeyde
9530c4d7db gpg: use gpgconf for getting gpg binary path 2017-11-03 10:52:27 +02:00
Roman Zeyde
a2d0c1067d gpg: don't hardcode Python binary 2017-10-27 11:27:40 +03:00
Roman Zeyde
3d5717dca1 gpg: use a separate process for PIN entrering UI 2017-10-27 10:43:50 +03:00
Roman Zeyde
08fef24e39 gpg: use pymsgbox for PIN entrering UI 2017-10-21 21:18:00 +03:00
Roman Zeyde
bab46dae5c gpg-agent: use correct GNUPGHOME when getting public keys 2017-10-21 20:49:27 +03:00
Roman Zeyde
e2625cc521 gpg: fail if no public keys are found 2017-10-21 19:16:48 +03:00
Roman Zeyde
7ed76fe472 gpg: use correct GNUPGHOME for gpgconf 2017-10-21 18:46:04 +03:00
Roman Zeyde
a5929eed62 gpg: create config files first 2017-10-21 18:21:38 +03:00
Roman Zeyde
5f722f8ae1 logging: add more DEBUG information 2017-10-21 18:08:40 +03:00
Roman Zeyde
7212b2fa37 Merge pull request #156 from codeHatcher/feature/155
add documentation related to #155 to help other macOS/OSX users who are
2017-10-18 01:57:51 -07:00
Avishaan
55e1c614a7 add documentation related to #155 to help other macOS/OSX users who are
still using system python
2017-10-17 15:37:11 -04:00
Roman Zeyde
8cf1f0463a device: release HID handle before failing 2017-10-14 17:34:02 +03:00
Roman Zeyde
f177b0b55a bump version 2017-10-11 21:44:53 +03:00
Roman Zeyde
b2450d448c Merge branch 'gpg-init' 2017-10-11 21:43:30 +03:00
Roman Zeyde
93e5f0cd8b gpg: update README for latest CLI 2017-10-11 21:40:05 +03:00
Roman Zeyde
9998456fe0 ledger: add DEBUG logging 2017-10-11 21:15:44 +03:00
Roman Zeyde
0f85ae6e2c Rewrite gpg-init Bash script in Python 2017-10-11 20:26:06 +03:00
Roman Zeyde
44cdeed024 Merge branch 'fix-gpg-prefs' 2017-10-10 20:46:44 +03:00
Roman Zeyde
867e2cfd1b gpg: add MDC support 2017-10-10 20:43:45 +03:00
Roman Zeyde
df6ddab2cf gpg: add compression and stronger digests 2017-10-10 20:43:45 +03:00
Roman Zeyde
5b9f03d198 gpg: show warnings while importing new pubkey 2017-10-10 20:29:31 +03:00
Roman Zeyde
06ea890095 gpg: add note regarding Pinentry 2017-10-10 15:10:56 +03:00
Roman Zeyde
0999a85529 gpg: add documentation for subkey generation 2017-10-10 14:20:17 +03:00
Roman Zeyde
835f283ccf gpg: support multiple keygrips for HAVEKEY command 2017-10-10 13:40:59 +03:00
Roman Zeyde
f57dbb553f gpg: allow setting trezor-gpg arguments via gpg-init script 2017-10-10 13:20:25 +03:00
Roman Zeyde
a890dcc085 gpg: add MDC support 2017-10-10 11:56:25 +03:00
Roman Zeyde
c8ed4a223a gpg: add compression and stronger digests 2017-10-10 11:56:23 +03:00
Roman Zeyde
1ef96bed03 gpg: handle NOP assuan command 2017-10-10 10:04:43 +03:00
Roman Zeyde
e4fdca08e5 ssh: fix identity stringification 2017-10-09 16:24:11 +03:00
Roman Zeyde
51b297e93b doc: add Enigmail tutorial 2017-10-09 10:02:09 +03:00
Roman Zeyde
c22c959cf9 doc: move READMEs to separate directory 2017-10-09 09:37:22 +03:00
Roman Zeyde
3199cb964a Merge branch 'timestamplookup' 2017-10-09 09:34:59 +03:00
Roman Zeyde
c5f245957d README: add spaces around '|' operators 2017-10-09 09:33:09 +03:00
Chris Cowan
fbb3059a0b README: Add a note about how to fetch a key's timestamp. 2017-10-08 17:58:21 -07:00
Roman Zeyde
e922f45871 bump version 2017-10-08 13:49:44 +03:00
Roman Zeyde
377af1466c gpg: detect installed GnuPG binary 2017-10-07 20:46:33 +03:00
Roman Zeyde
b7743e12a5 gpg: support GnuPG 2.2.x default installation 2017-10-07 20:29:01 +03:00
Roman Zeyde
48d5630561 gpg: fix identity stringification 2017-10-07 19:18:58 +03:00
Roman Zeyde
b88dff8430 ssh: fix identity stringification 2017-10-05 18:16:15 +03:00
Roman Zeyde
2af1086ed8 bump version 2017-10-02 20:46:30 +03:00
Roman Zeyde
7e95179128 ssh: fix unicode identity handling for Python 2 2017-09-22 14:11:21 +03:00
Roman Zeyde
ac8898a434 gpg: allow using 'gpg' instead of 'gpg2' 2017-09-09 21:31:25 +03:00
Roman Zeyde
0b829636e1 ssh: close stdin when running subshell 2017-08-31 17:00:27 +03:00
Roman Zeyde
7598f6cdbf Merge branch 'travis' 2017-08-18 11:50:33 +03:00
Roman Zeyde
c8bf57cbcc travis: fix setuptools issue 2017-08-18 11:46:08 +03:00
Roman Zeyde
62af49236c Merge pull request #132 from romanzolotarev/patch-1
Fix link in README-GPG.md
2017-08-15 21:41:41 +03:00
Roman Zolotarev
af3f669780 Update README-GPG.md 2017-08-15 19:20:31 +03:00
Roman Zeyde
1520dbd8b9 gpg: add a screencast for identity re-generation 2017-08-07 21:15:36 +03:00
Roman Zeyde
4f05d51e9b bump version 2017-07-16 15:29:49 +03:00
Roman Zeyde
9d38c26a0f gpg: add 'fake' device for GPG-integration testing 2017-07-16 15:29:47 +03:00
Roman Zeyde
3a9330b995 gpg: return SCD version from agent 2017-07-16 15:29:21 +03:00
Roman Zeyde
f904aac92e Allow unicode in identity string for SSH and GPG 2017-06-24 14:26:51 +03:00
Roman Zeyde
ca67923fe8 INSTALL: add instructions for Fedora/RedHat 2017-06-15 21:13:42 +03:00
Roman Zeyde
ce90e61eb2 README: add troubleshooting section for GPG 2017-06-14 21:49:20 +03:00
Roman Zeyde
90dc124e8d install: note '~/.local/bin' issue with pip 2017-06-12 22:39:11 +03:00
Roman Zeyde
442bf725ef gpg: fail SCD-related requests 2017-06-07 23:11:57 +03:00
Roman Zeyde
5820480052 GPG: set default user ID for signing during gpg-init 2017-06-05 22:04:43 +03:00
Roman Zeyde
ae2a84e168 INSTALL: comment about Ledger's 'SSH/PGP Agent' app requirement 2017-05-26 09:59:23 +03:00
Roman Zeyde
f6911a0016 pin: use PyQt only when running with no TTY 2017-05-21 21:03:04 +03:00
Roman Zeyde
69c54eb425 device: allow Qt-based PIN entry for Trezor/Keepkey 2017-05-21 17:29:57 +03:00
Roman Zeyde
931573f32b gpg: echo during identity initialization 2017-05-20 12:22:10 +03:00
Roman Zeyde
1e8363d4fc gpg: refactor client usage at agent module
This allows caching public keys (as done in the SSH agent).
2017-05-20 12:22:08 +03:00
Roman Zeyde
b7113083b4 README: add 'git config' for enabling commit signing 2017-05-19 21:29:57 +03:00
Roman Zeyde
b5c4eca0d2 README: elaborate trezor/ledger usage for GPG 2017-05-17 22:01:59 +03:00
Roman Zeyde
8aa08d0862 INSTALL: remove duplicate line 2017-05-17 21:53:35 +03:00
Roman Zeyde
b452b49f4c README: note qtpass for password management 2017-05-15 20:13:13 +03:00
Roman Zeyde
639c4efb6d README: add links to products 2017-05-14 21:08:55 +03:00
Roman Zeyde
6f1686c614 README: remove extra parentheses 2017-05-14 14:06:19 +03:00
Roman Zeyde
300d9a7140 README: update GPG screencasts and other examples 2017-05-14 10:58:56 +03:00
Roman Zeyde
b143bafc70 README: remove PyPI badges 2017-05-14 10:51:07 +03:00
Roman Zeyde
f2c6b6b9c1 README: move installation to a separate file 2017-05-14 10:44:50 +03:00
Roman Zeyde
e0507b1508 tests: cover file-based logging case 2017-05-14 10:04:01 +03:00
Roman Zeyde
85274d8374 bump version 2017-05-13 13:18:25 +03:00
Roman Zeyde
f358ca29d4 Allow loading previously exported SSH public keys from a file 2017-05-13 12:47:48 +03:00
Roman Zeyde
53d43cba29 gpg: update README after re-packaging 2017-05-06 21:13:07 +03:00
Roman Zeyde
214b556f83 ssh: simplify argparse creation 2017-05-06 20:55:38 +03:00
Roman Zeyde
051a3fd4ab ssh: remove unused git-related code 2017-05-06 20:40:23 +03:00
Roman Zeyde
91050ee64a ssh: remove unused git wrapper 2017-05-06 15:02:46 +03:00
Roman Zeyde
257992d04c ssh: move related code to a separate subdirectory 2017-05-05 11:22:00 +03:00
Roman Zeyde
6c2273387d util: extend logging.basicConfig() 2017-05-03 21:30:33 +03:00
Roman Zeyde
b6ad8207ba setup: add Python 3.6 support 2017-05-01 21:37:32 +03:00
Roman Zeyde
3a93fc859d setup: fix indentation for lists 2017-05-01 21:35:05 +03:00
Roman Zeyde
7d9b3ff1d0 README: update device-related info 2017-04-29 20:51:18 +03:00
Roman Zeyde
4af881b3cb Split the package into a shared library and separate per-device packages 2017-04-29 18:34:46 +03:00
Roman Zeyde
eb525e1b62 gpg: simplify Python entry point and refactor Bash scripts a bit
Now there is a single 'trezor-gpg' tool, with various subcommands.
2017-04-26 23:12:09 +03:00
Roman Zeyde
02c8e729b7 ssh: retrieve all keys using a single device session 2017-04-25 20:43:19 +03:00
Roman Zeyde
12359938ad keepkey: fix transport import 2017-04-23 21:27:02 +03:00
Roman Zeyde
93cd3e688b travis: add Python 3.6 2017-04-22 20:19:06 +03:00
Tomás Rojas
26d7dd3124 Cache public keys for the duration of the agent
This saves a lot of time when connecting to multiple hosts
simultaneously (e.g., during a deploy) as every time we are asked to sign a
challenge, all public keys are iterated to find the correct one. This
can become especially slow when using the Bridge transport and/or many
identities are defined.
2017-04-22 14:47:30 +03:00
Tomás Rojas
0d5c3a9ca7 Allow using TREZOR bridge (instead of HID transport) 2017-04-22 11:16:30 +03:00
Roman Zeyde
97ec6b2719 travis: fix dependency 2017-04-22 10:53:39 +03:00
Roman Zeyde
8ba9be1780 fix pylint warnings 2017-04-21 22:56:50 +03:00
Roman Zeyde
b2bc87c0c7 fix pydocstyle warnings 2017-04-21 22:51:11 +03:00
Timothy Hobbs
d522d148ef Connection error is confusing (#105)
Hi,

I ran into this connection error:

````
> trezor-agent  timothy@localhost
2017-04-10 00:22:01,818 ERROR        Connection error: open failed                                                                        [__main__.py:130]
````

I didn't know what was going on, whether there was a problem connecting to localhost or what. I eventually logged into trezor wallet and found that there too, my device was not recognized (probably because I did not unplug it/replug it after updating the HID settings.) Unplugging it and replugging it fixed everything.
2017-04-10 10:40:27 +03:00
Roman Zeyde
c796a3b01d README: password manager usage example 2017-03-28 21:21:35 +03:00
Roman Zeyde
a3362bbf3e bump version 2017-03-18 16:49:09 +02:00
Nubis
9a271d115b Allow contents in buffer when using _legacy_pubs 2017-03-09 20:13:23 +02:00
Roman Zeyde
6a7165298f decode: skip invalid pubkeys (instead of crashing) 2017-03-07 21:43:32 +02:00
Roman Zeyde
c4f3fa6e04 agent: reply correctly to HAVEKEY requests 2017-02-21 13:12:02 +02:00
Roman Zeyde
8a77fa519f decode: raise an error when keygrip is missing 2017-02-21 13:12:00 +02:00
Roman Zeyde
59560ec0b0 util: add simple memoization decorator 2017-02-21 13:05:48 +02:00
Roman Zeyde
7a91196dd5 agent: add link to HAVEKEY implementation 2017-02-21 11:48:22 +02:00
Roman Zeyde
43c424a402 ssh: allow "just-in-time" connection for agent-like behaviour
This would allow launching trezor-agent into the background
during the system startup, and the connecting the device
when the cryptographic operations are required.
2017-01-07 18:29:12 +02:00
Roman Zeyde
6672ea9bc4 device: set passphrase from environment 2017-01-06 12:52:45 +02:00
Roman Zeyde
002dc2a0e0 tox: order imports 2017-01-06 12:37:14 +02:00
Roman Zeyde
61ced2808f device: allow non-empty passphrases 2017-01-06 11:59:57 +02:00
Roman Zeyde
71a8930021 bump version 2017-01-05 23:12:54 +02:00
Roman Zeyde
74e8f21a22 gpg: export secret subkey 2017-01-01 18:14:52 +02:00
Roman Zeyde
897236d556 gpg: allow decoding secret keys 2017-01-01 18:14:28 +02:00
Roman Zeyde
5bec0e8382 README: upgrade also pip 2017-01-01 10:08:19 +02:00
Roman Zeyde
3cb7f6fd21 gpg: export secret primary key 2016-12-30 18:55:18 +02:00
Roman Zeyde
cad2ec1239 device: import device-specific defs module lazily
It may fail on unsupported platforms (e.g. keepkeylib does not supoprt Python 3)
2016-12-27 12:34:07 +02:00
Roman Zeyde
604b2b7e99 gpg: allow GPG 2.1.11+ (to support Ubuntu 16.04 & Mint 18) 2016-12-27 10:12:34 +02:00
Roman Zeyde
159bd79b5f gpg: list fingerpints explicitly during init 2016-12-27 10:04:48 +02:00
Roman Zeyde
dde0b60e83 Merge pull request #87 from aceat64/bugfix/mosh
Mosh doesn't support "-l" for user, only user@host for args
2016-12-15 19:25:19 +02:00
Andrew LeCody
109bb3b47f Mosh doesn't support "-l" for user, only user@host for args 2016-12-14 23:59:45 -06:00
Roman Zeyde
0f20bfa239 README: add a note about udev 2016-12-06 18:36:14 +02:00
Roman Zeyde
798597c436 bump version 2016-12-06 10:19:23 +02:00
Roman Zeyde
a13b1103f7 setup: temporary pin dependency for keepkey compatibilty 2016-12-06 10:07:18 +02:00
Roman Zeyde
9fe1a235c1 gpg: check that the configuration is in place 2016-12-02 13:10:33 +02:00
Roman Zeyde
f86aae9a40 gpg: check that GnuPG v2 is installed 2016-12-02 12:48:56 +02:00
Roman Zeyde
fc070e3ca0 GPG: add OS X installation link to README 2016-11-30 23:08:56 +02:00
Roman Zeyde
05fac995eb README: add 'setup.py' installation 2016-11-26 14:01:06 +02:00
Roman Zeyde
188b74b327 gpg: use explicit '--subkey' flag for adding a subkey to an existing GPG key 2016-11-25 19:35:40 +02:00
Roman Zeyde
fc31847f8e decode: add test for custom markers 2016-11-19 20:06:29 +02:00
Roman Zeyde
0faf21a102 README: easier tag signature verification 2016-11-19 13:37:33 +02:00
Roman Zeyde
6b82f8b9b7 keyring: add test for get_agent_sock_path() 2016-11-12 20:51:35 +02:00
Roman Zeyde
fabfcaaae2 keyring: fix test case for iterlines() 2016-11-12 20:51:14 +02:00
Roman Zeyde
f0f89310ac main: add '--mosh' for better SSH client 2016-11-11 22:26:22 +02:00
Roman Zeyde
47ff7c5cb3 gpg: add usage example for GPA 2016-11-11 20:05:47 +02:00
Roman Zeyde
0440025083 gpg: use explicit function to check for custom subpacket marker 2016-11-11 13:02:02 +02:00
Roman Zeyde
c49fe97f63 gpg: remove unused parser for literal packets 2016-11-11 13:01:54 +02:00
Roman Zeyde
7f8abcb5c5 client: remove unused code 2016-11-11 13:01:47 +02:00
Roman Zeyde
e13039e52d gpg: remove property method and unused member variable from PublicKey 2016-11-11 13:01:33 +02:00
Roman Zeyde
c420571eb8 gpg: import test coverage for protocol 2016-11-11 09:14:33 +02:00
Roman Zeyde
827119a18d gpg: handle KILLAGENT command
so `gpg-connect-agent KILLAGENT` should stop the running agent
2016-11-10 23:29:47 +02:00
Roman Zeyde
9be6504658 util: import test coverage 2016-11-10 14:33:41 +02:00
Roman Zeyde
07cbe65875 formats: improve test coverage 2016-11-10 14:33:27 +02:00
Roman Zeyde
180120e787 README: link to trezor-ssh-agent for Windows support 2016-11-10 13:09:53 +02:00
Roman Zeyde
f4ce81fa94 README: add 1.4.0-related post 2016-11-10 13:03:12 +02:00
Roman Zeyde
176bf4ef7c README: add Hg demo 2016-11-08 21:45:42 +02:00
Roman Zeyde
d22cd7512d gpg: launch agent before starting the shell 2016-11-06 22:39:45 +02:00
Roman Zeyde
83f17704cb server: remove 'SSH_AUTH_SOCK=' from logging 2016-11-06 22:02:35 +02:00
Roman Zeyde
92f6751ccb README: multiple SSH identities from config file 2016-11-06 20:46:42 +02:00
Roman Zeyde
abe80533eb README: trim whitespace 2016-11-06 20:43:45 +02:00
Roman Zeyde
de51665c71 bump version 2016-11-04 21:16:15 +02:00
Roman Zeyde
c30e5f5a67 gpg: add optional arguments to gpg-shell 2016-11-04 19:33:05 +02:00
Roman Zeyde
2eab2a152c device: verify keepkey constraints 2016-11-04 19:31:54 +02:00
Roman Zeyde
5e93d97be3 Merge branch 'ssh-ids' 2016-11-04 16:07:29 +02:00
Roman Zeyde
4c8fcd6714 ssh: use special UNIX socket name 2016-11-04 10:36:53 +02:00
Roman Zeyde
ee593bc66e gpg: show user ID on a single line 2016-11-03 23:36:11 +02:00
Roman Zeyde
dbed773e54 fix pylint and tests 2016-11-03 23:29:45 +02:00
Roman Zeyde
ac4a86d312 ssh: remove git utility 2016-11-03 23:12:59 +02:00
Roman Zeyde
021831073e ssh: simple support for multiple public keys loading 2016-11-03 23:05:27 +02:00
Roman Zeyde
6a5acba0b0 gpg: decouple identity from device 2016-11-03 22:00:56 +02:00
Roman Zeyde
9123cef810 ssh: decouple identity from device 2016-11-03 22:00:43 +02:00
Roman Zeyde
6f6e7c0bcc device: allow loading identities from a file (instead of argument) 2016-11-03 22:00:22 +02:00
75 changed files with 2028 additions and 1061 deletions

7
.bumpversion.cfg Normal file
View File

@@ -0,0 +1,7 @@
[bumpversion]
commit = True
tag = True
current_version = 0.9.6
[bumpversion:file:setup.py]

View File

@@ -1,2 +1,5 @@
[MESSAGES CONTROL]
disable=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

View File

@@ -4,29 +4,25 @@ python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
cache:
directories:
- $HOME/.cache/pip
addons:
apt:
packages:
- libudev-dev
- libusb-1.0-0-dev
before_install:
- pip install -U setuptools pylint coverage pep8 pydocstyle "pip>=7.0" wheel
- pip install -e git+https://github.com/keepkey/python-keepkey@6e8baa8b935e830d05f87b6dfd9bc7c927a96dc3#egg=keepkey
- pip install -U pip wheel
- pip install -U setuptools
- pip install -U pylint coverage pycodestyle pydocstyle
install:
- pip install -e .
- 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

View File

@@ -1,37 +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.15
```
Update you TREZOR firmware to the latest version (at least v1.4.0).
Install latest `trezor-agent` package from GitHub:
```
$ pip install --user git+https://github.com/romanz/trezor-agent.git
```
# Quickstart
## Identity creation
[![asciicast](https://asciinema.org/a/c2yodst21h9obttkn9wgf3783.png)](https://asciinema.org/a/c2yodst21h9obttkn9wgf3783)
## Sample usage (signature and decryption)
[![asciicast](https://asciinema.org/a/7x0h9tyoyu5ar6jc8y9oih0ba.png)](https://asciinema.org/a/7x0h9tyoyu5ar6jc8y9oih0ba)
## Git commit & tag signatures:
Git can use GPG to sign and verify commits and tags (see [here](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)):
```
$ git config --local gpg.program $(which gpg2)
$ git commit --gpg-sign # create GPG-signed commit
$ git log --show-signature -1 # verify commit signature
$ git tag --sign "v1.2.3" # create GPG-signed tag
$ git verify-tag "v1.2.3" # verify tag signature
```

View File

@@ -1,53 +1,25 @@
# Using TREZOR as a hardware SSH/GPG agent
[![Build Status](https://travis-ci.org/romanz/trezor-agent.svg?branch=master)](https://travis-ci.org/romanz/trezor-agent)
[![Python Versions](https://img.shields.io/pypi/pyversions/trezor_agent.svg)](https://pypi.python.org/pypi/trezor_agent/)
[![Package Version](https://img.shields.io/pypi/v/trezor_agent.svg)](https://pypi.python.org/pypi/trezor_agent/)
[![Development Status](https://img.shields.io/pypi/status/trezor_agent.svg)](https://pypi.python.org/pypi/trezor_agent/)
[![Downloads](https://img.shields.io/pypi/dm/trezor_agent.svg)](https://pypi.python.org/pypi/trezor_agent/)
[![Chat](https://badges.gitter.im/romanz/trezor-agent.svg)](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.6GPG Signing, SSH Login Updates and Advanced Transaction Features for Segwit](https://medium.com/@satoshilabs/trezor-firmware-1-3-6-20a7df6e692)
- [TREZOR Firmware 1.4.0GPG 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 -U setuptools
$ pip install Cython trezor
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).
See [here](https://github.com/romanz/python-trezor#pin-entering) for PIN entering instructions.
## Troubleshooting
If there is an import problem with the installed `protobuf` package,
see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it.
### Gitter
Questions, suggestions and discussions are welcome: [![Chat](https://badges.gitter.im/romanz/trezor-agent.svg)](https://gitter.im/romanz/trezor-agent)

View 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
View 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',
]},
)

View 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
View 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',
]},
)

View 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
View 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
View 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',
]},
)

View 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
View 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
View 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
View 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
[![asciicast](https://asciinema.org/a/3iNw2L9QWB8R3EVdYdAxMOLK8.png)](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)
[![asciicast](https://asciinema.org/a/120441.png)](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
```
[![GPA](https://cloud.githubusercontent.com/assets/9900/20224804/053d7474-a849-11e6-87f3-ab07dc536158.png)](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
[![asciicast](https://asciinema.org/a/5tIQa5qt5bV134oeOqFyKEU29.png)](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
```
[![asciicast](https://asciinema.org/a/Ick5G724zrZRFsGY7ZUdFSnV1.png)](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
```

View File

@@ -9,6 +9,9 @@
## Using for GitHub SSH authentication (via `trezor-git` utility)
[![GitHub](https://asciinema.org/a/38337.png)](https://asciinema.org/a/38337)
## Loading multiple SSH identities from configuration file
[![Config](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny.png)](https://asciinema.org/a/bdxxtgctk5syu56yfz8lcp7ny)
# Public key generation
Run:
@@ -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/)):
@@ -58,6 +61,10 @@ 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
@@ -69,10 +76,10 @@ with a verbose log attached (by running `trezor-agent -vv`) .
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.
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.
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:

26
doc/enigmail.md Normal file
View 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:
![01](https://user-images.githubusercontent.com/9900/31327339-47a5f69a-acd7-11e7-997c-7b5a286fe5bc.png)
![02](https://user-images.githubusercontent.com/9900/31327344-51dcd246-acd7-11e7-8cdc-dd305a512dbb.png)
![03](https://user-images.githubusercontent.com/9900/31327346-546862a0-acd7-11e7-8e00-b40994bd6f17.png)
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.
![04](https://user-images.githubusercontent.com/9900/31327356-660d098e-acd7-11e7-9e43-762898f5b57e.png)
![05](https://user-images.githubusercontent.com/9900/31327365-76679dda-acd7-11e7-9403-6965f0c6d8fe.png)
After receiving the email, you will be asked to confirm the decryption the hardware device:
![06](https://user-images.githubusercontent.com/9900/31327371-7c1da4cc-acd7-11e7-9a5a-20accf621b49.png)

View File

@@ -0,0 +1,3 @@
"""Cryptographic hardware device management."""
from . import interface

View 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()

View File

@@ -6,6 +6,8 @@ import logging
import re
import struct
import unidecode
from .. import formats, util
log = logging.getLogger(__name__)
@@ -45,20 +47,6 @@ def identity_to_string(identity_dict):
return ''.join(result)
def get_bip32_address(identity_dict, ecdh=False):
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
index = struct.pack('<L', identity_dict.get('index', 0))
addr = index + identity_to_string(identity_dict).encode('ascii')
log.debug('bip32 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(util.recv(s, '<LLLL'))
return [(hardened | value) for value in address_n]
class Error(Exception):
"""Device-related error."""
@@ -68,21 +56,58 @@ class NotFoundError(Error):
class DeviceError(Error):
""""Error during device operation."""
"""Error during device operation."""
class Device(object):
"""Abstract cryptographic hardware device interface."""
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
self.conn = None
def identity_str(self):
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 identity_to_string(self.identity_dict)
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."""
@@ -101,25 +126,18 @@ class Device(object):
log.exception('close failed: %s', e)
self.conn = None
def pubkey(self, ecdh=False):
def pubkey(self, identity, ecdh=False):
"""Get public key (as bytes)."""
raise NotImplementedError()
def sign(self, blob):
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
raise NotImplementedError()
def ecdh(self, pubkey):
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__)
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

View 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)

View 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

View File

@@ -4,7 +4,7 @@ import binascii
import logging
import struct
from ledgerblue import comm
from ledgerblue import comm # pylint: disable=import-error
from . import interface
@@ -44,11 +44,10 @@ class LedgerNanoS(interface.Device):
raise interface.NotFoundError(
'{} not connected: "{}"'.format(self, e))
def pubkey(self, ecdh=False):
def pubkey(self, identity, ecdh=False):
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
curve_name = self.get_curve_name(ecdh)
path = _expand_path(interface.get_bip32_address(self.identity_dict,
ecdh=ecdh))
curve_name = identity.get_curve_name(ecdh)
path = _expand_path(identity.get_bip32_address(ecdh))
if curve_name == 'nist256p1':
p2 = '01'
else:
@@ -57,30 +56,33 @@ class LedgerNanoS(interface.Device):
apdu = binascii.unhexlify(apdu)
apdu += bytearray([len(path) + 1, len(path) // 4])
apdu += path
result = bytearray(self.conn.exchange(bytes(apdu)))[1:]
return _convert_public_key(curve_name, result)
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, blob):
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
path = _expand_path(interface.get_bip32_address(self.identity_dict,
ecdh=False))
if self.identity_dict['proto'] == 'ssh':
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 self.curve_name == 'nist256p1':
p2 = '81' if self.identity_dict['proto'] == 'ssh' else '01'
if identity.curve_name == 'nist256p1':
p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01'
else:
p2 = '82' if self.identity_dict['proto'] == 'ssh' else '02'
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)))
if self.curve_name == 'nist256p1':
log.debug('result: %r', result)
if identity.curve_name == 'nist256p1':
offset = 3
length = result[offset]
r = result[offset+1:offset+1+length]
@@ -96,11 +98,10 @@ class LedgerNanoS(interface.Device):
else:
return bytes(result[:64])
def ecdh(self, pubkey):
def ecdh(self, identity, pubkey):
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
path = _expand_path(interface.get_bip32_address(self.identity_dict,
ecdh=True))
if self.curve_name == 'nist256p1':
path = _expand_path(identity.get_bip32_address(ecdh=True))
if identity.curve_name == 'nist256p1':
p2 = '01'
else:
p2 = '02'
@@ -109,6 +110,8 @@ class LedgerNanoS(interface.Device):
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
View 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)

View 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

View File

@@ -184,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):
@@ -193,7 +193,7 @@ 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

255
libagent/gpg/__init__.py Normal file
View 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
View 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
View 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)

View File

@@ -54,7 +54,8 @@ def parse_mpis(s, n):
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)
return ecdsa.VerifyingKey.from_public_point(
@@ -64,7 +65,8 @@ def _parse_nist256p1_pubkey(mpi):
def _parse_ed25519_pubkey(mpi):
prefix, value = util.split_bits(mpi, 8, 256)
assert prefix == 0x40
if prefix != 0x40:
raise ValueError('Invalid MPI prefix: {}'.format(prefix))
return ed25519.VerifyingKey(util.num2bytes(value, size=32))
@@ -83,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)
@@ -104,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'}
@@ -127,10 +123,6 @@ def _parse_signature(stream):
log.debug('embedded sigs: %s', embedded)
p['embedded'] = embedded
# Detect our custom public keys by matching subpacket data
p['_is_custom'] = any(protocol.CUSTOM_KEY_LABEL == subpacket[1:]
for 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))
@@ -170,7 +162,7 @@ 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))
@@ -194,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')
@@ -203,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,
@@ -254,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
@@ -300,6 +297,7 @@ def load_by_keygrip(pubkey_bytes, keygrip):
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):

View File

@@ -8,15 +8,14 @@ from .. import util
log = logging.getLogger(__name__)
def create_primary(user_id, pubkey, signer_func):
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=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')))
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
@@ -24,12 +23,14 @@ def create_primary(user_id, pubkey, signer_func):
# 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)
protocol.subpacket_bytes(0x15, [8, 9, 10]), # preferred hash
# https://tools.ietf.org/html/rfc4880#section-5.2.3.8
protocol.subpacket_byte(0x16, 0), # preferred compression (none)
protocol.subpacket_bytes(0x16, [2, 3, 1]), # preferred compression
# https://tools.ietf.org/html/rfc4880#section-5.2.3.9
protocol.subpacket_byte(0x17, 0x80) # key server prefs (no-modify)
protocol.subpacket_byte(0x17, 0x80), # key server prefs (no-modify)
# https://tools.ietf.org/html/rfc4880#section-5.2.3.17
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
@@ -47,9 +48,10 @@ def create_primary(user_id, pubkey, signer_func):
return pubkey_packet + user_id_packet + sign_packet
def create_subkey(primary_bytes, subkey, signer_func, user_id=None):
def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''):
"""Export new subkey to GPG primary key."""
subkey_packet = protocol.packet(tag=14, blob=subkey.data())
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]
@@ -87,7 +89,7 @@ def create_subkey(primary_bytes, subkey, signer_func, user_id=None):
unhashed_subpackets.append(protocol.subpacket(32, embedded_sig))
unhashed_subpackets.append(protocol.CUSTOM_SUBPACKET)
if not signature['_is_custom']:
if not decode.has_custom_subpacket(signature):
signer_func = keyring.create_agent_signer(user_id['value'])
signature = protocol.make_signature(

View File

@@ -14,16 +14,19 @@ 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)
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)
@@ -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
@@ -175,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])
@@ -201,25 +219,28 @@ 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(sp=subprocess):
def export_public_keys(env=None, sp=subprocess):
"""Export all GPG public keys."""
args = gpg_command(['--export'])
return sp.check_output(args=args)
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()
sock = connect_to_agent(env=os.environ)
keygrip = get_keygrip(user_id)
def sign(digest):

View File

@@ -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)
@@ -185,6 +190,7 @@ 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
@@ -196,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):
@@ -227,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__

View File

@@ -41,5 +41,22 @@ def public_key_path(request):
def test_gpg_files(public_key_path): # pylint: disable=redefined-outer-name
with open(public_key_path, 'rb') as f:
packets = list(decode.parse_packets(f))
assert len(packets) > 0
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'')

View File

@@ -80,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

View File

@@ -1,5 +1,6 @@
import ecdsa
import ed25519
import pytest
from .. import protocol
from ... import formats
@@ -69,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():
@@ -78,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():
@@ -87,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')

View File

@@ -1,19 +1,15 @@
"""UNIX-domain socket server for ssh-agent implementation."""
import contextlib
import functools
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."""
@@ -31,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)
@@ -114,39 +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.
"""
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='ssh-agent-')
environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())}
device_mutex = threading.Lock()
with unix_domain_socket_server(sock_path) as sock:
sock.settimeout(timeout)
quit_event = threading.Event()
handle_conn = functools.partial(handle_connection,
handler=handler,
mutex=device_mutex)
kwargs = dict(sock=sock,
handle_conn=handle_conn,
quit_event=quit_event)
with spawn(server_thread, kwargs):
try:
yield environ
finally:
log.debug('closing server')
quit_event.set()
def run_process(command, environ):
"""
Run the specified process and wait until it finishes.

230
libagent/ssh/__init__.py Normal file
View 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)

View File

@@ -16,34 +16,37 @@ class Client(object):
def __init__(self, device):
"""Connect to hardware device."""
device.identity_dict['proto'] = 'ssh'
self.device = device
def get_public_key(self):
"""Get SSH public key from the device."""
def export_public_keys(self, identities):
"""Export SSH public keys from the device."""
public_keys = []
with self.device:
pubkey = self.device.pubkey()
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
vk = formats.decompress_pubkey(pubkey=pubkey,
curve_name=self.device.curve_name)
return formats.export_public_key(vk=vk,
label=self.device.identity_str())
def sign_ssh_challenge(self, blob):
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'])
log.debug('fingerprint: %s', msg['public_key']['fingerprint'])
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'), self.device.identity_str(),
msg['user'].decode('ascii'), identity.to_string(),
self.device)
with self.device:
return self.device.sign(blob=blob)
return self.device.sign(blob=blob, identity=identity)
def _parse_ssh_blob(data):

View File

@@ -62,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)
@@ -71,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 = {
@@ -107,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])
@@ -129,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
@@ -137,10 +138,10 @@ class Handler(object):
else:
raise KeyError('key not found')
label = key['name'].decode('ascii') # label should be a string
label = key['name'].decode('utf-8')
log.debug('signing %d-byte blob with "%s" key', len(blob), label)
try:
signature = self.signer(blob=blob)
signature = self.conn.sign(blob=blob, identity=key['identity'])
except IOError:
return failure()
log.debug('signature: %r', signature)

View File

@@ -12,7 +12,7 @@ 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')
'VUfhvrGljR2Z/CMRONY6ejB+9PnpUOPuzYqi8= <localhost:22|nist256p1>\n')
class MockDevice(device.interface.Device): # pylint: disable=abstract-method
@@ -20,30 +20,17 @@ class MockDevice(device.interface.Device): # pylint: disable=abstract-method
def connect(self): # pylint: disable=no-self-use
return mock.Mock()
def close(self):
self.conn = None
def pubkey(self, ecdh=False): # pylint: disable=unused-argument
def pubkey(self, identity, ecdh=False): # pylint: disable=unused-argument
assert self.conn
return PUBKEY
def sign(self, blob):
def sign(self, identity, blob):
"""Sign given blob and return the signature (as bytes)."""
assert self.conn
assert blob == BLOB
return SIG
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
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'
@@ -59,11 +46,11 @@ SIG = (b'R\x19T\xf2\x84$\xef#\x0e\xee\x04X\xc6\xc3\x99T`\xd1\xd8\xf7!'
def test_ssh_agent():
identity_str = 'localhost:22'
c = client.Client(device=MockDevice(identity_str=identity_str,
curve_name=CURVE))
assert c.get_public_key() == PUBKEY_TEXT
signature = c.sign_ssh_challenge(blob=BLOB)
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)
@@ -77,9 +64,9 @@ def test_ssh_agent():
assert r[1:] + s[1:] == SIG
# pylint: disable=unused-argument
def cancel_sign(blob):
def cancel_sign(identity, blob):
raise IOError(42, 'ERROR')
c.device.sign = cancel_sign
with pytest.raises(IOError):
c.sign_ssh_challenge(blob=BLOB)
c.sign_ssh_challenge(blob=BLOB, identity=identity)

View File

@@ -1,6 +1,7 @@
import mock
import pytest
from .. import formats, protocol
from .. import device, formats, protocol
# pylint: disable=line-too-long
@@ -15,57 +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(blob):
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(blob):
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(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()
@@ -77,13 +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(blob):
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

View File

@@ -0,0 +1 @@
"""Unit-tests for this package."""

View File

@@ -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))

View 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')]

View File

@@ -1,4 +1,3 @@
import functools
import io
import os
import socket
@@ -8,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():
@@ -37,10 +37,16 @@ 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():
mutex = threading.Lock()
handler = protocol.Handler(keys=[], signer=None)
handler = protocol.Handler(conn=empty_device())
conn = FakeSocket()
server.handle_connection(conn, handler, mutex)
@@ -67,25 +73,27 @@ def test_handle():
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'
handler = protocol.Handler(keys=[], signer=None),
handle_conn = functools.partial(server.handle_connection, handler=handler)
def handle_conn(conn):
assert conn is sock
quit_event.set()
server.server_thread(sock=FakeServer(),
handle_conn=handle_conn,
quit_event=quit_event)
quit_event.wait()
def test_spawn():
@@ -110,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'

View File

@@ -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)]

View File

@@ -1,6 +1,7 @@
"""Various I/O and serialization utilities."""
import binascii
import contextlib
import functools
import io
import logging
import struct
@@ -178,10 +179,53 @@ class Reader(object):
self._captured = None
def setup_logging(verbosity, **kwargs):
def setup_logging(verbosity, filename=None):
"""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, **kwargs)
logging.root.setLevel(level)
fmt = logging.Formatter('%(asctime)s %(levelname)-12s %(message)-100s '
'[%(filename)s:%(lineno)d]')
hdlr = logging.StreamHandler() # stderr
hdlr.setFormatter(fmt)
logging.root.addHandler(hdlr)
if filename:
hdlr = logging.FileHandler(filename, 'a')
hdlr.setFormatter(fmt)
logging.root.addHandler(hdlr)
def memoize(func):
"""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
@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

View File

@@ -1,32 +0,0 @@
#!/bin/bash
set -eu
USER_ID="${1}"
HOMEDIR=~/.gnupg/trezor
CURVE=${CURVE:="nist256p1"} # or "ed25519"
TIMESTAMP=${TIMESTAMP:=`date +%s`} # key creation timestamp
# Prepare new GPG home directory for TREZOR-based identity
rm -rf "${HOMEDIR}"
mkdir -p "${HOMEDIR}"
chmod 700 "${HOMEDIR}"
# Generate new GPG identity and import into GPG keyring
trezor-gpg-create -v "${USER_ID}" -t "${TIMESTAMP}" -e "${CURVE}" > "${HOMEDIR}/pubkey.asc"
gpg2 --homedir "${HOMEDIR}" --import < "${HOMEDIR}/pubkey.asc"
rm -f "${HOMEDIR}/S.gpg-agent" # (otherwise, our agent won't be started automatically)
# Make new GPG identity with "ultimate" trust (via its fingerprint)
FINGERPRINT=$(gpg2 --homedir "${HOMEDIR}" --list-public-keys --with-colons | sed -n -E 's/^fpr:::::::::([0-9A-F]+):$/\1/p' | head -n1)
echo "${FINGERPRINT}:6" | gpg2 --homedir "${HOMEDIR}" --import-ownertrust
# Prepare GPG configuration file
echo "# TREZOR-based GPG configuration
agent-program $(which trezor-gpg-agent)
personal-digest-preferences SHA512
" | tee "${HOMEDIR}/gpg.conf"
echo "# TREZOR-based GPG agent emulator
log-file ${HOMEDIR}/gpg-agent.log
verbosity 2
" | tee "${HOMEDIR}/gpg-agent.conf"

View File

@@ -1,10 +0,0 @@
#!/bin/bash
set -eu
export GNUPGHOME=~/.gnupg/trezor
gpg2 --list-public-keys
# Make sure that the device is unlocked before starting the shell
trezor-gpg-unlock
${SHELL}

32
setup.py Normal file → Executable file
View File

@@ -2,15 +2,27 @@
from setuptools import setup
setup(
name='trezor_agent',
version='0.7.6',
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.device', 'trezor_agent.gpg'],
install_requires=['ecdsa>=0.13', 'ed25519>=1.4', 'Cython>=0.23.4', 'protobuf>=3.0.0', 'semver>=2.2',
'trezor>=0.7.6', 'keepkey>=0.7.3', 'ledgerblue>=0.1.8'],
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',
@@ -23,17 +35,11 @@ setup(
'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.__main__:run_agent',
'trezor-git = trezor_agent.__main__:run_git',
'trezor-gpg-create = trezor_agent.gpg.__main__:main_create',
'trezor-gpg-agent = trezor_agent.gpg.__main__:main_agent',
'trezor-gpg-unlock = trezor_agent.gpg.__main__:auto_unlock',
]},
)

16
tox.ini
View File

@@ -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

View File

@@ -1,177 +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, device, formats, protocol, server, util
log = logging.getLogger(__name__)
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 ['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 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()
util.setup_logging(verbosity=args.verbose)
d = device.detect(identity_str=args.identity,
curve_name=args.ecdsa_curve_name)
conn = client_factory(device=d)
command = args.command
public_key = conn.get_public_key()
if args.connect:
command = ssh_args(args.identity) + 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()
util.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)

View File

@@ -1,28 +0,0 @@
"""Cryptographic hardware device management."""
import logging
from . import trezor
from . import keepkey
from . import ledger
from . import interface
log = logging.getLogger(__name__)
DEVICE_TYPES = [
trezor.Trezor,
keepkey.KeepKey,
ledger.LedgerNanoS,
]
def detect(identity_str, curve_name):
"""Detect the first available device and return it to the user."""
for device_type in DEVICE_TYPES:
try:
with device_type(identity_str, curve_name) as d:
return d
except interface.NotFoundError as e:
log.debug('device not found: %s', e)
raise IOError('No device found: "{}" ({})'.format(identity_str,
curve_name))

View File

@@ -1,25 +0,0 @@
"""KeepKey-related code (see https://www.keepkey.com/)."""
from . import interface, trezor
from .. import formats
class KeepKey(trezor.Trezor):
"""Connection to KeepKey device."""
from . import keepkey_defs as defs
required_version = '>=1.0.4'
def connect(self):
"""No support for other than NIST256P elliptic curves."""
if self.curve_name not in {formats.CURVE_NIST256}:
fmt = 'KeepKey does not support {} curve'
raise interface.NotFoundError(fmt.format(self.curve_name))
return trezor.Trezor.connect(self)
def ecdh(self, pubkey):
"""No support for ECDH in KeepKey firmware."""
msg = 'KeepKey does not support ECDH'
raise interface.NotFoundError(msg)

View File

@@ -1,8 +0,0 @@
"""KeepKey-related definitions."""
# pylint: disable=unused-import
from keepkeylib.client import KeepKeyClient as Client
from keepkeylib.client import CallException
from keepkeylib.transport_hid import HidTransport
from keepkeylib.messages_pb2 import PassphraseAck
from keepkeylib.types_pb2 import IdentityType

View File

@@ -1,108 +0,0 @@
"""TREZOR-related code (see http://bitcointrezor.com/)."""
import binascii
import logging
import semver
from . import interface
log = logging.getLogger(__name__)
class Trezor(interface.Device):
"""Connection to TREZOR device."""
from . import trezor_defs as defs
required_version = '>=1.4.0'
def connect(self):
"""Enumerate and connect to the first USB HID interface."""
def empty_passphrase_handler(_):
return self.defs.PassphraseAck(passphrase='')
for d in self.defs.HidTransport.enumerate():
log.debug('endpoint: %s', d)
transport = self.defs.HidTransport(d)
connection = self.defs.Client(transport)
connection.callback_PassphraseRequest = empty_passphrase_handler
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))
connection.ping(msg='', pin_protection=True) # unlock PIN
return connection
raise interface.NotFoundError('{} not connected'.format(self))
def close(self):
"""Close connection."""
self.conn.close()
def pubkey(self, ecdh=False):
"""Return public key."""
curve_name = self.get_curve_name(ecdh=ecdh)
log.debug('"%s" getting public key (%s) from %s',
interface.identity_to_string(self.identity_dict),
curve_name, self)
addr = interface.get_bip32_address(self.identity_dict, 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):
result = self.defs.IdentityType()
for name, value in self.identity_dict.items():
setattr(result, name, value)
return result
def sign(self, blob):
"""Sign given blob and return the signature (as bytes)."""
curve_name = self.get_curve_name(ecdh=False)
log.debug('"%s" signing %r (%s) on %s',
interface.identity_to_string(self.identity_dict), blob,
curve_name, self)
try:
result = self.conn.sign_identity(
identity=self._identity_proto(),
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, pubkey):
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
curve_name = self.get_curve_name(ecdh=True)
log.debug('"%s" shared session key (%s) for %r from %s',
interface.identity_to_string(self.identity_dict),
curve_name, pubkey, self)
try:
result = self.conn.get_ecdh_session_key(
identity=self._identity_proto(),
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)

View File

@@ -1,8 +0,0 @@
"""TREZOR-related definitions."""
# pylint: disable=unused-import
from trezorlib.client import TrezorClient as Client
from trezorlib.client import CallException
from trezorlib.transport_hid import HidTransport
from trezorlib.messages_pb2 import PassphraseAck
from trezorlib.types_pb2 import IdentityType

View File

@@ -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
"""

View File

@@ -1,127 +0,0 @@
#!/usr/bin/env python
"""Create signatures and export public keys for GPG using TREZOR."""
import argparse
import contextlib
import io
import logging
import os
import sys
import time
import semver
from . import agent, decode, client, encode, keyring, protocol
from .. import device, formats, server, util
log = logging.getLogger(__name__)
def key_exists(user_id):
"""Return True iff there is a GPG key with specified user ID."""
for p in decode.parse_packets(io.BytesIO(keyring.export_public_keys())):
if p['type'] == 'user_id' and p['value'] == user_id:
return True
return False
def run_create(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)
d = client.Client(user_id=args.user_id, curve_name=args.ecdsa_curve)
verifying_key = d.pubkey(ecdh=False)
decryption_key = d.pubkey(ecdh=True)
if key_exists(args.user_id): # 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=d.sign)
result = encode.create_subkey(primary_bytes=result,
subkey=encryption_key,
signer_func=d.sign)
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=d.sign)
result = encode.create_subkey(primary_bytes=result,
subkey=subkey,
signer_func=d.sign)
sys.stdout.write(protocol.armor(result, 'PUBLIC KEY BLOCK'))
def main_create():
"""Main function for GPG identity creation."""
p = argparse.ArgumentParser()
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')
args = p.parse_args()
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!')
existing_gpg = keyring.gpg_version().decode('ascii')
required_gpg = '>=2.1.15'
if semver.match(existing_gpg, required_gpg):
run_create(args)
else:
log.error('Existing gpg2 has version "%s" (%s required)',
existing_gpg, required_gpg)
def main_agent():
"""Run a simple GPG-agent server."""
home_dir = os.environ.get('GNUPGHOME', os.path.expanduser('~/.gnupg/trezor'))
config_file = os.path.join(home_dir, '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'])
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):
try:
agent.handle_connection(conn)
except Exception as e: # pylint: disable=broad-except
log.exception('gpg-agent failed: %s', e)
def auto_unlock():
"""Automatically unlock first found device (used for `gpg-shell`)."""
p = argparse.ArgumentParser()
p.add_argument('-v', '--verbose', default=0, action='count')
args = p.parse_args()
util.setup_logging(verbosity=args.verbose)
d = device.detect(identity_str='', curve_name='')
log.info('unlocked %s device', d)

View File

@@ -1,149 +0,0 @@
"""GPG-agent utilities."""
import binascii
import logging
from . import decode, client, 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 open_connection(keygrip_bytes):
"""
Connect to the device for the specified keygrip.
Parse GPG public key to find the first user ID, which is used to
specify the correct signature/decryption key on the device.
"""
pubkey_dict, user_ids = decode.load_by_keygrip(
pubkey_bytes=keyring.export_public_keys(),
keygrip=keygrip_bytes)
# We assume the first user ID is used to generate TREZOR-based GPG keys.
user_id = user_ids[0]['value'].decode('ascii')
curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid'])
ecdh = (pubkey_dict['algo'] == protocol.ECDH_ALGO_ID)
conn = client.Client(user_id, curve_name=curve_name)
pubkey = protocol.PublicKey(
curve_name=curve_name, created=pubkey_dict['created'],
verifying_key=conn.pubkey(ecdh=ecdh), ecdh=ecdh)
assert pubkey.key_id() == pubkey_dict['key_id']
assert pubkey.keygrip == keygrip_bytes
return conn
def pksign(keygrip, digest, algo):
"""Sign a message digest using a private EC key."""
log.debug('signing %r digest (algo #%s)', digest, algo)
keygrip_bytes = binascii.unhexlify(keygrip)
conn = open_connection(keygrip_bytes)
r, s = conn.sign(binascii.unhexlify(digest))
result = sig_encode(r, s)
log.debug('result: %r', result)
return result
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 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)
keygrip_bytes = binascii.unhexlify(keygrip)
conn = open_connection(keygrip_bytes)
return _serialize_point(conn.ecdh(remote_pubkey))
def handle_connection(conn):
"""Handle connection from GPG binary using the ASSUAN protocol."""
keygrip = None
digest = None
algo = None
version = keyring.gpg_version() # "Clone" existing GPG version
keyring.sendline(conn, b'OK')
for line in keyring.iterlines(conn):
parts = line.split(b' ')
command = parts[0]
args = parts[1:]
if command in {b'RESET', b'OPTION', b'HAVEKEY', b'SETKEYDESC'}:
pass # reply with OK
elif command == b'GETINFO':
keyring.sendline(conn, b'D ' + version)
elif command == b'AGENT_ID':
keyring.sendline(conn, b'D TREZOR') # "Fake" agent ID
elif command in {b'SIGKEY', b'SETKEY'}:
keygrip, = args
elif command == b'SETHASH':
algo, digest = args
elif command == b'PKSIGN':
sig = pksign(keygrip, digest, algo)
keyring.sendline(conn, b'D ' + sig)
elif command == b'PKDECRYPT':
sec = pkdecrypt(keygrip, conn)
keyring.sendline(conn, b'D ' + sec)
elif command == b'KEYINFO':
keygrip, = args
# Dummy reply (mainly for 'gpg --edit' to succeed).
# For details, see GnuPG agent KEYINFO command help.
fmt = 'S KEYINFO {0} X - - - - - - -'
keyring.sendline(conn, fmt.format(keygrip).encode('ascii'))
elif command == b'BYE':
return
else:
log.error('unknown request: %r', line)
return
keyring.sendline(conn, b'OK')

View File

@@ -1,44 +0,0 @@
"""Device abstraction layer for GPG operations."""
import logging
from .. import device, formats, util
log = logging.getLogger(__name__)
class Client(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.device = device.detect(identity_str='',
curve_name=curve_name)
self.device.identity_dict['proto'] = 'gpg'
self.device.identity_dict['host'] = user_id
self.user_id = user_id
def pubkey(self, ecdh=False):
"""Return public key as VerifyingKey object."""
with self.device:
pubkey = self.device.pubkey(ecdh=ecdh)
return formats.decompress_pubkey(
pubkey=pubkey, curve_name=self.device.curve_name)
def sign(self, digest):
"""Sign the digest and return a serialized signature."""
log.info('please confirm GPG signature on %s for "%s"...',
self.device, self.user_id)
if self.device.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)
return (util.bytes2num(sig[:32]), util.bytes2num(sig[32:]))
def ecdh(self, pubkey):
"""Derive shared secret using ECDH from remote public key."""
log.info('please confirm GPG decryption on %s for "%s"...',
self.device, self.user_id)
with self.device:
return self.device.ecdh(pubkey=pubkey)